From f43e834f7ea9bbbfdee19c6928cf788a709c100f Mon Sep 17 00:00:00 2001 From: Scion Date: Sun, 31 May 2026 01:13:28 +0000 Subject: [PATCH 01/89] chore: add implementation task briefs and finalized design docs (phases 0-6) --- .design/linked-groves-ui.md | 447 ++++++++++++++++ .design/workstation-onboarding-wizard.md | 633 +++++++++++++++++++++++ .design/workstation-onboarding.md | 343 ++++++++++++ .tasks/phase-0-1-foundations-identity.md | 66 +++ .tasks/phase-2-system-api.md | 89 ++++ .tasks/phase-3-wizard-shell.md | 62 +++ .tasks/phase-4-images.md | 87 ++++ .tasks/phase-5-linked-groves.md | 122 +++++ .tasks/phase-6-polish.md | 56 ++ 9 files changed, 1905 insertions(+) create mode 100644 .design/linked-groves-ui.md create mode 100644 .design/workstation-onboarding-wizard.md create mode 100644 .design/workstation-onboarding.md create mode 100644 .tasks/phase-0-1-foundations-identity.md create mode 100644 .tasks/phase-2-system-api.md create mode 100644 .tasks/phase-3-wizard-shell.md create mode 100644 .tasks/phase-4-images.md create mode 100644 .tasks/phase-5-linked-groves.md create mode 100644 .tasks/phase-6-polish.md diff --git a/.design/linked-groves-ui.md b/.design/linked-groves-ui.md new file mode 100644 index 000000000..9134dc31f --- /dev/null +++ b/.design/linked-groves-ui.md @@ -0,0 +1,447 @@ +# Linked Groves from the Browser + +**Date:** 2026-05-30 (decisions folded in 2026-05-31) +**Status:** Sub-design — detailed plan (W5 of `workstation-onboarding.md`) +**Author:** Scion Agent (workstation-improvements) +**Parent:** [`.design/workstation-onboarding.md`](./workstation-onboarding.md) §2.5, §5 (W5), §1a (D5/D6/D7) + +> **Confirmed decisions (2026-05-31):** **D5** — ship the **server-side directory +> browser** (a custom web folder tree + a **"New folder"** button), **strictly 404'd +> when serving in production**; *not* a native OS dialog. **D6** — **hard-fail** on +> managed-path overlap. **D7** — **two-step** create (project, then provider). These +> supersede the original "Option A first" recommendation below; §2 has been updated. + +--- + +## 1. Scope + +Surface **linked groves** — local directories that live *outside* the Hub's +managed path space (`~/.scion/projects//`) — as a first-class create flow in +the workstation Web UI. + +Everything below the UI already exists: + +- **Data model** — `ProjectProvider` carries `LocalPath`, `BrokerID`, `LinkedBy`, + `LinkedAt` (`pkg/store/models.go:337-379`); `ProjectType` returns `linked` when a + provider supplies a `LocalPath` (`pkg/store/models.go:186-246`). +- **Link API** — `POST /api/v1/projects/{projectId}/providers` → + `addProjectProvider` (`pkg/hub/handlers.go:7980-8043`), request shape + `AddProviderRequest{ BrokerID, LocalPath }` (`pkg/hub/handlers.go:3211-3215`). +- **WebDAV/file resolution** honors a co-located broker's `LocalPath` directly + (`pkg/hub/project_webdav.go:136-190`). +- **CLI precedent** — `scion hub link` ensures the project exists, then adds the + local broker as a provider with `LocalPath: resolvedPath` + (`cmd/hub.go:2376-2388`, full command `runHubLink` at `cmd/hub.go:2172`). + +The gaps this doc closes: + +1. A **third mode** (`'linked'`) in `web/src/components/pages/project-create.ts` + (today only `'git' | 'hub'`, line 29), with a **directory-browser modal** and a + **"New folder"** button (D5). +2. A small family of **filesystem API endpoints** the co-located broker exposes: + `fs/list` (browse), `fs/mkdir` (create new dir), and `fs/validate-path` (confirm a + candidate directory is real, readable, and not already managed) — before it is linked. +3. **Security fencing** so those endpoints (which read/create on the host filesystem on + the user's behalf) are reachable **only** in workstation mode (404 in prod), on a + loopback bind, behind auth. +4. A way for the UI to **discover the co-located broker ID** to link against. + +Non-goals (inherited from parent §3): remote-broker linked-grove UX (focus is the +co-located workstation broker only), and the grove→project rename. + +--- + +## 2. UX decision: path-entry vs. directory-browser + +Two candidate interactions, both noted as open in parent §5 (W5) / §2.5. + +### Option A — Free-text path entry + validate (retained as the underlying field; see Decision) + +A single text input ("Local directory path", e.g. `/home/alice/code/myrepo`) with a +debounced **Validate** call to the new endpoint (mirroring the existing debounced +`checkExistingProjects` pattern at `project-create.ts:307-349`). The result renders +inline as a pass/warn/fail line (reuse `status-badge.js`, already imported at +`project-create.ts:27`). + +- **Pros:** small surface area; one new endpoint; no recursive filesystem exposure; + matches `scion hub link`'s "you are already standing in the directory" mental + model; trivially fenceable. +- **Cons:** user must know/paste the absolute path; no discoverability. + +### Option B — Server-side directory browser + +A modal tree/list backed by a `GET .../fs/list?path=` endpoint that enumerates +directory entries the broker can read, letting the user click down the tree. + +- **Pros:** friendlier; discoverable; no typing of long paths. +- **Cons:** a second, **more dangerous** endpoint — it lists arbitrary host + directory contents over HTTP. Larger fencing burden (parent §6 Q1), more UI, path + traversal/symlink surface, and a "home root" decision (where does browsing start?). + +### Decision (updated 2026-05-31 — D5) + +**Ship Option B: the server-side directory browser**, with a **"New folder"** button +for creating a destination directory inline. The browser is a **custom web component** +(a folder tree the hub serves over the fenced `fs/list` endpoint) — **not a native OS +dialog**, which a served web page cannot invoke to obtain a server-usable absolute path. +The browser is **strictly disabled (404) when serving in production** (§4). + +The path-entry input from Option A is **retained as the underlying source of truth**: +the directory browser and "New folder" action simply populate `localPath`, and a +debounced `validate-path` call still runs against the resolved selection before submit. +So Option A's validation endpoint and field remain; Option B adds two siblings +(`fs/list`, `fs/mkdir`) under the same workstation fence and path-safety helpers. + +This doc specifies all three endpoints (`fs/validate-path`, `fs/list`, `fs/mkdir`) and +the browser UI. Why this is acceptable despite the larger surface: every endpoint is +404 in production, loopback-asserted, auth-gated, and shares one path-safety helper +(symlink-expand + managed-root checks), keeping the attack surface bounded (§4). + +--- + +## 3. Path-validation API endpoint + +### 3.1 Surface + +``` +POST /api/v1/system/fs/validate-path +Request: { "path": "/abs/or/~-relative/path" } +Response: { + "valid": true, + "resolved": "/home/alice/code/myrepo", // absolute, symlink-expanded + "exists": true, + "isDir": true, + "readable": true, + "isGitRepo": true, // contains .git + "alreadyManaged": false, // under ~/.scion/projects (or legacy groves) + "alreadyLinked": false, // a provider already points here + "warnings": ["Path is a git repository; agents will operate on the working tree."], + "error": "" // human-readable when valid=false +} +``` + +Aligns with the parent's proposed `/api/v1/system/*` namespace (W1, parent §5:214-218) +so onboarding's system endpoints cluster together. `fs/` sub-namespace reserves room +for the Option B `fs/list` sibling. + +### 3.2 Checks performed (in order) + +1. **Resolve & normalize** — expand `~`, make absolute, `filepath.Clean`, then + `filepath.EvalSymlinks` to defeat symlink games. Reject empty/`.`-only input. +2. **Exists + is directory** — `os.Stat`; populate `exists`, `isDir`. +3. **Readable** — attempt to open the directory for listing; populate `readable`. +4. **Not already managed** — reject (or warn) if `resolved` is within the managed + path space. Compute the managed root the same way the hub does: + `hubNativeProjectPath()` at `pkg/hub/handlers.go:3736-3752` places projects under + `~/.scion/projects//` (legacy `~/.scion/groves//`). Linking a managed + directory back in as "linked" is nonsensical and is a hard `valid=false`. +5. **Not already linked** — scan existing providers for a `LocalPath` whose resolved + form equals `resolved`; surface as `alreadyLinked` (warn, not hard fail — the user + may be re-linking). +6. **Git detection** — presence of a `.git` entry → `isGitRepo` (informational; the + parent notes "optionally is/isn't a git repo", §5:250). + +`valid` is `true` only when `exists && isDir && readable && !alreadyManaged`. + +### 3.3 Where the code lives + +- **Handler** — new method `(s *Server) validateLocalPath(w, r)` in a new file + `pkg/hub/system_handlers.go` (groups the W1 `/system/*` handlers — check, runtime, + init, images — alongside this one). Request/response structs (`ValidatePathRequest`, + `ValidatePathResponse`) beside it. +- **Path-safety helper** — factor the resolve+managed-root logic into + `pkg/hub/fs_safety.go` (`resolveAndClassifyPath(path string) (...)`) so Option B's + `fs/list` can reuse it. The managed-root computation should call/share the existing + `hubNativeProjectPath` logic rather than re-deriving `~/.scion/projects`. +- **Provider scan** — reuse `s.store.GetProjectProviders` (already used at + `pkg/hub/handlers.go:5373`, `:7969`) across the user's projects, or add a + store helper `GetProviderByLocalPath` if a full scan proves too coarse. Start with + the scan; it's a workstation, the project count is small. + +### 3.4 Routing + +The hub dispatches by path-prefix string matching (see the nested-path style at +`pkg/hub/handlers.go:4410-4416` for `/providers`). Add a `system/fs/` branch in the +top-level API router that, after the workstation guard (§4), routes +`validate-path` (POST), `list` (GET), and `mkdir` (POST) to their handlers. Mount under +the same `MountHubAPI` tree (`pkg/hub/web.go:518-527`) as everything else. + +### 3.5 `GET /api/v1/system/fs/list` (directory browser — D5) + +Backs the folder tree. Lists the **immediate** entries of one directory (no recursion). + +``` +GET /api/v1/system/fs/list?path=/home/alice +Response: { + "path": "/home/alice", // resolved, symlink-expanded + "parent": "/home", // null at filesystem root + "entries": [ + { "name": "code", "isDir": true, "isGitRepo": true, "readable": true }, + { "name": "Documents", "isDir": true, "isGitRepo": false, "readable": true } + ] +} +``` + +- **Default root:** when `path` is empty, start at `$HOME` (confirmed default). The UI + shows a breadcrumb and can navigate up via `parent` (never above what the helper + permits). +- **Dirs only by default** — the picker only needs directories; files may be omitted or + flagged `isDir:false` and rendered disabled. `isGitRepo` annotates folders for the + user. +- Reuses `resolveAndClassifyPath` / the shared path-safety helper (§3.3) for each path; + unreadable entries are returned with `readable:false` rather than omitted, so the user + sees why they can't descend. + +### 3.6 `POST /api/v1/system/fs/mkdir` ("New folder" — D5) + +Creates a single new directory under a parent the user is browsing. + +``` +POST /api/v1/system/fs/mkdir +Request: { "parent": "/home/alice/code", "name": "my-new-project" } +Response: { "created": true, "path": "/home/alice/code/my-new-project" } +``` + +- Validate `name` (no path separators, no `.`/`..`, length-bounded) and that `parent` + resolves, exists, is a dir, and is **not** inside the managed path space (§3.2 step 4 + — D6). `os.Mkdir` (not `MkdirAll`) so a typo can't create a deep tree; `409` if it + already exists. On success the UI selects the new directory and runs `validate-path`. + +--- + +## 4. Security fencing to workstation mode only + +This is parent §6 Q1 — the must-solve. The endpoint reads the host filesystem on the +user's behalf and must **never** be reachable on a multi-user / remote Hub. + +### 4.1 The signal: an explicit `Workstation` flag on `ServerConfig` + +There is currently **no mode field on `hub.ServerConfig`** (it has `AdminMode`, +`UserAccessMode`, `DevAuthToken`, etc., but not server operating mode — verified in +`pkg/hub/server.go` ServerConfig struct). The operating mode lives one layer up in +`config.GlobalConfig.Mode` (`pkg/config/hub_config.go:190-192`, read standalone via +`LoadServerMode`, `:610`) and is consulted in `cmd/server_foreground.go:524` +(`cfg.Mode == "production"`). + +**Add a field** `Workstation bool` to `hub.ServerConfig` and set it where the config +is assembled at `cmd/server_foreground.go:774` (the `hub.ServerConfig{...}` literal), +from `!productionMode` (the same boolean already computed in `loadAndReconcileConfig`, +`cmd/server_foreground.go:522-542`). Do **not** infer mode from `DevAuthToken != ""` +or the bind host — those are separately overridable and would couple unrelated +concerns. + +Store it on the server (e.g. `s.workstation bool`) and expose a guard helper: + +```go +// pkg/hub/fs_safety.go +func (s *Server) requireWorkstation(w http.ResponseWriter, r *http.Request) bool { + if !s.workstation { + NotFound(w) // 404, not 403 — do not advertise the route's existence off-workstation + return false + } + return true +} +``` + +### 4.2 Defense in depth + +1. **Mode gate (primary)** — `requireWorkstation` at the top of `validateLocalPath` + (and any future `fs/list`). Return **404** so the route is invisible in production. +2. **Loopback assertion (secondary)** — additionally require the request to be local + (remote addr is loopback / matches the configured `127.0.0.1` bind). Workstation + already binds `127.0.0.1` by default (`applyWorkstationDefaults`, + `cmd/server_config.go:42-44`; reasserted `cmd/server_foreground.go:534-536`), but + asserting in-handler protects against a misconfigured non-loopback workstation + bind. Reuse `getClientIP(r)` (already used at `pkg/hub/handlers.go:8038`). +3. **Auth still required** — the endpoint sits behind the normal + `UnifiedAuthMiddleware` (`pkg/hub/auth.go:60-248`); the developer (dev) token is + required like every other `/api/v1` call. The guard is *in addition to* auth. +4. **Embedded-broker-only linking** — the create flow (§5) links against the + **co-located/embedded broker only**. The hub already tracks it via + `embeddedBrokerID` + `isEmbeddedBroker()` (`pkg/hub/server.go:1062-1074`, set at + `cmd/server_foreground.go:1116`). A remote broker's filesystem is never read by the + hub, so validation is meaningless there and is refused. +5. **Directory listing is in scope (D5) — and is the riskiest endpoint.** `fs/list` + enumerates host directory contents and `fs/mkdir` creates directories, so the fence + matters more, not less. Mitigations beyond the mode gate: list is **non-recursive** + (one level per call); `mkdir` uses `os.Mkdir` (one level, no `MkdirAll`) with strict + `name` validation (no separators/`.`/`..`); both run every path through the shared + `resolveAndClassifyPath` helper (symlink-expand + managed-root rejection); and both + inherit the 404-in-prod + loopback + auth gates above. The bound on blast radius is + the fence + the path-safety helper, which `fs/list`/`fs/mkdir`/`validate-path` all + share — there is exactly one place to get path safety right. + +### 4.3 Why a model field rather than reading `LoadServerMode()` per request + +`LoadServerMode` re-reads `settings.yaml` from disk; the authoritative resolved mode +(after flag reconciliation, including `--production` overrides) lives in the +already-computed `productionMode` boolean at server start. Threading it into +`ServerConfig` once is cheaper and matches how `AdminMode` is already plumbed. + +--- + +## 5. Changes to `project-create.ts` + +File: `web/src/components/pages/project-create.ts`. + +### 5.1 Mode type and selector + +- Extend the mode union (line 29): + `type ProjectMode = 'git' | 'hub' | 'linked';` +- Add an `` to the Workspace Type select (lines 466-480) + labeled e.g. **"Local Directory (linked)"**, with hint copy describing that the + directory stays where it is and is operated on in place. +- `onModeChange` (line 303) needs no change — it already casts to `ProjectMode`. + +### 5.2 New state + validation handler + +Add `@state()` fields mirroring the existing git block: + +```ts +@state() private localPath = ''; +@state() private pathValidation: ValidatePathResponse | null = null; +@state() private validatingPath = false; +private pathCheckTimer: ReturnType | null = null; +``` + +Add `onLocalPathInput` that debounces a `POST /api/v1/system/fs/validate-path` call +— structurally identical to `onGitRemoteInput` + `checkExistingProjects` +(lines 307-349). Render the result inline using `status-badge.js` plus the existing +`.info-banner` / `.error-banner` styles (already defined, lines 196-230). Surface +`isGitRepo`/`alreadyLinked` as warnings, `valid=false` as a blocking error. + +### 5.3 Conditional form fields + +In `render()`, add a `${this.mode === 'linked' ? html\`...\` : nothing}` block +(parallel to the `this.mode === 'git'` block at lines 482-568) containing: + +- the **Local directory path** input bound to `localPath` / `onLocalPathInput` + (retained as the source of truth — D5), plus a **"Browse…"** button that opens the + directory-browser modal; +- the **directory-browser modal** (new component, e.g. + `web/src/components/dialogs/directory-browser.ts`, `scion-directory-browser`): a + breadcrumb + folder list backed by `GET /system/fs/list`, a **"New folder"** button + backed by `POST /system/fs/mkdir`, and a **Select** action that writes the chosen + resolved path back into `localPath` and closes the modal (which triggers + `onLocalPathInput`'s `validate-path` call); +- the inline validation result; +- (no git-remote, branch, workspace-mode, or github-token fields — hide them). + +Name/slug/visibility fields below (lines 570-622) stay shared. The git-only Default +Branch block (lines 592-607) already keys off `this.mode === 'git'`, so it stays +hidden for `linked` with no change. + +### 5.4 Submit path (two-step, mirroring `scion hub link`) + +`handleSubmit` (line 356) currently builds one `POST /api/v1/projects` body. For +`linked`, follow the CLI's link semantics (`cmd/hub.go:2376-2388`): **create the +project, then add the embedded broker as a provider carrying `LocalPath`.** + +1. **Guard:** require `name` (existing check, line 357) and a `pathValidation?.valid` + result; otherwise set `this.error` and return. +2. **Discover the broker:** the UI needs the embedded broker's ID (§6). Fetch it + once (see §6) and keep it in state. +3. **Create the project** via the existing `POST /api/v1/projects` call (lines + 405-410). Body is the minimal `{ name, slug?, visibility }` — **no `gitRemote`, + no `workspaceMode`** (this makes it a managed/hub-shell project that the provider + then redirects to the local path). Capture `projectId` exactly as today + (lines 416-421), including the 200-vs-201 existing-project handling (lines 423-427). +4. **Add the provider:** + `POST /api/v1/projects/{projectId}/providers` with + `{ brokerId: , localPath: pathValidation.resolved }` — the same + request the CLI sends (`AddProviderRequest`, `cmd/hub.go:2379-2382`; + server handler `addProjectProvider`, `pkg/hub/handlers.go:7980-8043`). Use the + **resolved** path from validation so the stored `LocalPath` is canonical. +5. On success, `navigateToProject(projectId)` (line 430). On provider failure after + project creation, surface the error and leave the user on the form (the project + exists but is unlinked — acceptable; re-submitting will hit the 200 existing-project + path and retry the provider add). + +> Atomic alternative (optional, larger): extend `POST /api/v1/projects` to accept an +> optional `{ linkedPath, brokerId }` and have the handler create the provider in the +> same transaction. Cleaner UX (no orphaned project on failure) but a backend change +> to the create handler; defer unless the two-step proves flaky. + +--- + +## 6. Discovering the co-located broker in the UI + +The provider add needs the embedded broker's ID. The hub knows it +(`embeddedBrokerID`, `pkg/hub/server.go:503`, accessor surface at `:1059-1074` — +note only `isEmbeddedBroker` exists today; **add a `GetEmbeddedBrokerID() string` +accessor**). Expose it to the browser via one of: + +- **Preferred:** include an `embedded: true` flag on the relevant broker in the + existing brokers list the UI already consumes, and/or surface the ID on the public + settings/bootstrap payload the workstation UI loads at startup. The UI filters for + the embedded broker and uses its ID. +- The validation response could also echo the broker that performed the check + (`"brokerId": ""`), letting the UI carry it straight from validate → + provider-add without a separate lookup. This is the lowest-friction option and is + recommended: the path is only meaningful relative to the broker that validated it. + +Gate this disclosure behind the same workstation flag (§4) — production UIs never +need an embedded-broker shortcut. + +--- + +## 7. File-by-file change list + +**Backend** + +| File | Change | +| --- | --- | +| `pkg/hub/server.go` | Add `Workstation bool` to `ServerConfig`; store `s.workstation`; add `GetEmbeddedBrokerID()` accessor. | +| `cmd/server_foreground.go:774` | Set `Workstation: !productionMode` in the `hub.ServerConfig{}` literal. | +| `pkg/hub/system_handlers.go` *(new)* | `validateLocalPath`, `listDir` (`fs/list`), `mkdir` (`fs/mkdir`) handlers + request/response structs. | +| `pkg/hub/fs_safety.go` *(new)* | `requireWorkstation` guard, `resolveAndClassifyPath` (resolve, symlink-expand, managed-root + git + already-linked classification), reusing `hubNativeProjectPath` logic (`handlers.go:3736-3752`). Shared by all three `fs/*` handlers. | +| `pkg/hub/handlers.go` router | Add a guarded `system/fs/` branch routing `validate-path` (POST), `list` (GET), `mkdir` (POST) in the `MountHubAPI` dispatch (style per `:4410-4416`). | +| `pkg/store` *(optional)* | `GetProviderByLocalPath` helper if the provider scan is too coarse. | + +**Frontend** + +| File | Change | +| --- | --- | +| `web/src/components/pages/project-create.ts` | `'linked'` mode: type (line 29), selector option (466-480), state + debounced `onLocalPathInput`, "Browse…" button, conditional render block, two-step `handleSubmit` (356). | +| `web/src/components/dialogs/directory-browser.ts` *(new)* | `scion-directory-browser`: breadcrumb + folder list over `fs/list`, "New folder" over `fs/mkdir`, Select writes resolved path to `localPath`. | +| brokers/settings client payload | Surface embedded-broker ID/flag for the UI (§6), or echo `brokerId` from validate response. | + +**Docs** + +| File | Change | +| --- | --- | +| `.design/workstation-onboarding.md` | Mark W5 as detailed here; cross-link. | + +--- + +## 8. Testing + +- **Backend unit** — `resolveAndClassifyPath`: nonexistent path, file-not-dir, + unreadable dir, managed-path rejection (`~/.scion/projects/...`), symlink that + escapes into a managed path, git vs non-git, already-linked. Follow existing hub + handler test style (`pkg/hub/handlers_project_test.go`, which already sets + `SetEmbeddedBrokerID`, e.g. `:866`). +- **Fencing** — `validate-path`, `fs/list`, and `fs/mkdir` each return 404 when + `Workstation=false`; return results when `true`; reject non-loopback client when bind + is non-loopback. `fs/list` is non-recursive; `fs/mkdir` rejects names with + separators/`.`/`..` and refuses managed-path parents. +- **Integration** — two-step create: project + provider, assert resulting + `ProjectType == "linked"` (`models.go:186-246`) and WebDAV resolves to the local + path (`project_webdav.go:136-190`). +- **Frontend** — mode switch hides git fields; debounced validation renders + pass/warn/fail; submit issues the two calls in order with the resolved path. + +--- + +## 9. Resolved decisions (2026-05-31) + +1. **Managed-path overlap → hard-fail (D6).** No legitimate reason to link a subdir of + the managed space; `validate-path` and `mkdir` both reject it (§3.2 step 4, §3.6). +2. **Provider-add failure → two-step, accept the recovery model (D7).** §5.4's + create-then-provider flow stays; a failed provider-add leaves a recoverable unlinked + project (re-submit retries). The atomic create-handler change is deferred unless this + proves flaky. +3. **Directory-browser home root → `$HOME`.** `fs/list` defaults to `$HOME` when no + `path` is given (§3.5); the user can navigate elsewhere, every read fenced. +4. **Single embedded broker assumed.** Exactly one co-located broker per workstation + (matches today's combo mode). If that ever changes, §6 needs a picker. diff --git a/.design/workstation-onboarding-wizard.md b/.design/workstation-onboarding-wizard.md new file mode 100644 index 000000000..6af5bc5fc --- /dev/null +++ b/.design/workstation-onboarding-wizard.md @@ -0,0 +1,633 @@ +# Workstation Onboarding Wizard — Detailed Sub-Design (W1 + W4) + +**Date:** 2026-05-30 (decisions folded in 2026-05-31) +**Status:** Proposal — Detailed Design (decisions confirmed) +**Author:** Scion Agent (workstation-onboarding) +**Parent doc:** [`workstation-onboarding.md`](./workstation-onboarding.md) (see §1a for +the confirmed decisions and §7 for the single primary work sequence) +**Scope:** W1 (onboarding wizard + supporting API) and W4 (harness-aware, Go-native +image **pull + local build** with SSE progress). Touches W2/W3 only where the wizard +depends on them. + +> **Confirmed decisions (2026-05-31):** **D1** identity is cosmetic (settable display +> name/email, stable UUID, default OS user) — the identity step writes it via +> `PUT /system/identity`. **D3** images: prebuilt pull from the pre-seeded +> `ghcr.io/homebrew-scion` is the default; **add a local build option** once a runtime +> is confirmed. **D4** per-image pull progress (build streams log lines). **D8** +> `scion server start` **auto-opens** the browser to `/onboarding` when un-onboarded and +> **always prints the URL, before backgrounding**. SSE travels on the shared `/events` +> stream (`system.images.`); status cached in `sessionStorage`. + +--- + +## 1. Scope & Relationship to Parent Doc + +The parent doc surveys the whole initiative and recommends two follow-up sub-designs. +This is the first: it specifies **how the browser-based first-run wizard works** and +**the API it stands on**. It deliberately defers: + +- **Linked-groves-from-browser UX (W5)** → its own doc (`linked-groves-ui.md`). This + doc only defines the *workspace step's* contract with that work. +- **Identity config internals (W2)** and **dev-token rename (W3)** → folded into + implementation PRs. This doc consumes them: the wizard's "identity" step is the UI + that *writes* the identity config W2 introduces. + +What this doc fully owns: +1. The wizard **UX flow** and an explicit **state machine** (steps, transitions, + resumability). +2. The **first-run detection signal** — what marks a machine as "needs onboarding". +3. The **bootstrap-auth window** — how the wizard is reachable *before* identity is set. +4. The **API surface**: `system/check`, `system/runtime`, `system/init`, + `system/images/*` (including SSE progress), plus a small `system/status` endpoint + that powers first-run detection and resumability. + +--- + +## 2. Architecture Overview + +``` +Browser (Lit SPA) Hub (Go, net/http.ServeMux) +───────────────── ─────────────────────────── +/onboarding route ── GET ──▶ GET /api/v1/system/status ┐ + scion-page-onboarding │ thin wrappers over + (wizard state machine) ── GET ──▶ GET /api/v1/system/check │ existing logic: + GET /api/v1/system/runtime │ • doctor.go + ── POST ─▶ PUT /api/v1/system/runtime │ • runtime_detect.go + POST /api/v1/system/init │ • config.InitMachine + ── POST ─▶ POST /api/v1/system/images/pull│ • runtime.PullImage (NEW glue) + ── SSE ──▶ GET /api/v1/system/images/events ┘ (new SSE channel) +``` + +Everything runs against the **co-located workstation Hub** bound to `127.0.0.1`. No new +process, no new server — new handlers register on the existing `s.mux` in +`pkg/hub/server.go` and a new Lit page registers on the existing client router in +`web/src/client/main.ts`. + +--- + +## 3. First-Run Detection + +### 3.1 The signal + +A machine "needs onboarding" when its bootstrap is **incomplete**. Rather than a single +boolean, compute a small struct from cheap filesystem/config probes and let the wizard +decide which steps remain. This makes the signal double as the resumability source +(§5.4). + +The authoritative computation lives server-side in a new +`GET /api/v1/system/status` handler. It assembles: + +| Field | Source of truth | "Done" when | +|---|---|---| +| `initialized` | `config.GetSettingsPath()` returns a path (i.e. `~/.scion/settings.yaml` exists) — see `pkg/config/init.go:560` | settings file present | +| `runtimeDetected` | `config.DetectLocalRuntime()` (`pkg/config/runtime_detect.go:57`) returns no error | a runtime is reachable | +| `runtimeConfigured` | `VersionedSettings.ResolveRuntime("")` yields a non-empty type (`pkg/config/settings_v1.go:90`) | runtime persisted in settings | +| `harnessesSeeded` | non-empty `VersionedSettings.HarnessConfigs` (`settings_v1.go:224`) | at least one harness-config seeded | +| `imageRegistry` | `VersionedSettings.ResolveImageRegistry("")` (`settings_v1.go:152`) | (informational; may be empty — see §7.4) | +| `imagesPresent` | per chosen harness, `runtime.ImageExists(ctx, img)` (`pkg/runtime/interface.go:64`) | all chosen-harness images exist | +| `identitySet` | new identity fields on `V1AuthConfig` (W2) are non-default (not `dev@localhost`) | user has named themselves | +| `hasWorkspace` | `store.ListProjects` returns ≥1 project | at least one project exists | + +`needsOnboarding = !initialized || !harnessesSeeded || !hasWorkspace` (the hard +minimum). Soft-incomplete fields (no images, default identity) don't *force* the wizard +but pre-select the resume step. + +### 3.2 Where detection is consumed + +Two consumers: + +1. **CLI quickstart + auto-open (D8)** — `printWorkstationQuickstart()` + (`cmd/server_daemon.go:362`) currently prints only a URL + token. Extend it: if + `needsOnboarding`, **print the `/onboarding` URL prominently** (e.g. + `Open http://127.0.0.1:8080/onboarding to finish setup`) and **auto-open the browser** + to it. Two firm requirements from D8: + - **Always print, and print *before* the daemon backgrounds itself** — the URL must + reach the terminal regardless of whether auto-open succeeds, and before the process + detaches. (The daemon launch path forks/backgrounds in + `runServerStartOrDaemon`, `cmd/server_daemon.go:33-161`; emit the quickstart on the + parent/foreground side before detaching.) + - **Auto-open is best-effort and guarded** — use `open`/`xdg-open`/`start` behind a + `--no-browser` opt-out, and **skip auto-open when stdout is not a TTY or the session + looks headless/SSH** (`$SSH_CONNECTION`), so CI and remote starts don't try to + launch a browser. Print-only is the always-on fallback. + This requires the daemon to call the same status computation (factor it into + `pkg/config` so both CLI and handler share it — `pkg/config/onboarding_status.go`, + returning a struct the handler JSON-encodes). + +2. **Browser redirect** — the client router (`web/src/client/main.ts`, `renderRoute()` + ~line 329) gains a guard: on first navigation to `/` (dashboard), if the SPA has not + yet confirmed setup, fetch `/api/v1/system/status`; when `needsOnboarding`, call + `navigateTo('/onboarding')`. Gate this behind a workstation-mode flag exposed in the + bootstrapped page data (`__SCION_DATA__`, consumed in `main.ts:238`) so production + Hubs never trigger it. Cache the "setup complete" result in `sessionStorage` to avoid + a status fetch on every navigation. + +### 3.3 Why not just "settings.yaml missing" + +The parent doc's open question (Q2) asks for "a crisp, cheap signal." A bare +`settings.yaml`-exists check is cheap but wrong: `scion server start` itself can create +settings via workstation defaults before the user has chosen harnesses or made a +workspace. The struct approach keeps each probe cheap (all are stat/in-memory except +`imagesPresent`, which is only computed when explicitly requested with +`?images=true`) while correctly distinguishing "server booted" from "user onboarded". + +--- + +## 4. Bootstrap-Auth Window + +This is the subtle part (parent Q3): onboarding must run *before* identity is set, yet +every `/api/v1/*` route sits behind auth. + +### 4.1 The window already exists — via dev-auth auto-login + +In workstation mode, `applyWorkstationDefaults()` (`cmd/server_config.go:25`) turns on +dev-auth, so the web server starts with `ws.config.DevAuthToken != ""`. The web +middleware chain (`buildHandler`, `pkg/hub/web.go:1618`) then runs +`devAuthMiddleware` (`web.go:1115`), which **auto-creates an admin session for any +browser request when no user is in the session** (`web.go:1141-1177`): + +```go +// No user — auto-login with dev identity +devUser := &webSessionUser{ UserID: DevUserID, Email: "dev@localhost", + Name: "Development User", Role: "admin" } +... session.Save ... +// also mints Hub JWTs so session-to-bearer can auth /api/v1 calls +``` + +Consequences for onboarding: + +- A fresh browser hitting `/onboarding` is **already authenticated** as the admin + DevUser, with no login screen (`sessionAuthMiddleware`, `web.go:1181`, sees the user + in context and passes through). +- API calls from the wizard use the same session cookie; the + session-to-bearer middleware (mounted at `MountHubAPI`, `web.go:518`) converts the + session's Hub JWT into a Bearer token, so `UnifiedAuthMiddleware` + (`pkg/hub/auth.go:68`) accepts them as the admin DevUser. + +**So the bootstrap window is "the dev-auth auto-login session on loopback."** No new +unauthenticated endpoints are required, and we explicitly **do not** add the +`system/*` routes to `isUnauthenticatedEndpoint()` (`auth.go:292`) — keeping them +admin-gated is safer. + +### 4.2 Fencing the window + +The auto-login is only acceptable because workstation mode binds to `127.0.0.1` +(`applyWorkstationDefaults`) and is single-user. To prevent these powerful endpoints +(they write `~/.scion`, pull images, read the host) from ever being reachable on a +multi-user/production Hub, every `system/*` handler **must** guard on workstation mode: + +```go +if !s.requireWorkstation(w, r) { return } // 404 in production +``` + +**Fence mechanism (reconciled with [`linked-groves-ui.md`](./linked-groves-ui.md) §4 — +this is the authoritative version):** `ServerConfig` has no operating-mode field today +(confirmed in `pkg/hub/server.go`). Add a **`Workstation bool`** to `ServerConfig`, set +from the already-computed `!productionMode` boolean where the config literal is +assembled (`cmd/server_foreground.go:774`). Store `s.workstation` and expose a +`requireWorkstation(w, r)` helper that returns **404** (not 403, keeping the surface +invisible) when false. Do **not** infer mode from `DevAuthToken != ""` or the bind host +— those are separately overridable and would couple unrelated concerns. The same flag +and helper guard the `system/*` endpoints here and the `fs/*` endpoints in W5. + +### 4.3 Transition to configured identity + +The identity step (W2) writes `username`/`displayName`/`email` into `V1AuthConfig` and +the wizard then refreshes the session so the DevUser's email/display name reflect the +chosen identity (the **UUID stays `DevUserID`** for DB integrity — parent §2.4). Two +implementation choices, decide at build time: + +- **Simplest:** identity fields are read at `DevUser`/`webSessionUser` construction + time from settings; after the identity POST, the wizard forces a session refresh + (clear `sessKeyUser*`, let `devAuthMiddleware` re-populate from the now-updated + config). Requires `devAuthMiddleware` to source identity from settings instead of the + hardcoded literals at `web.go:1144-1147` / `devauth.go:42-45`. +- That hardcoding is exactly what W2 replaces; this doc assumes W2 lands first or + alongside, and the wizard's identity step is its UI. + +--- + +## 5. Wizard UX & State Machine + +### 5.1 Route & shell + +- **Route:** add to the `ROUTES` array in `web/src/client/main.ts` (the table around + lines 127-158): + ```ts + { pattern: /^\/onboarding$/, tag: 'scion-page-onboarding', + load: () => import('../components/pages/onboarding.js') } + ``` +- **Shell:** use the **standalone** shell (like `/login` and `/invite`) by adding the + tag to `STANDALONE_ROUTES` (`main.ts:163`). The wizard is a full-screen takeover, not + a page inside the app chrome with sidebar. +- **New component:** `web/src/components/pages/onboarding.ts`, + `@customElement('scion-page-onboarding') class ScionPageOnboarding extends LitElement`. + Model it on `invite.ts` (`web/src/components/pages/invite.ts`) for the + multi-`@state()` step machine and on `admin-server-config.ts` for the form-field / + `apiFetch` / `extractApiError` patterns. Reuse Shoelace `sl-tab`/`sl-step`-style + components already in the bundle. + +### 5.2 Steps (matches parent §4) + +| # | Step id | Purpose | Primary API | Blocking? | +|---|---|---|---|---| +| 0 | `welcome` | intro + load `system/status` | `GET /system/status` | no | +| 1 | `identity` | set display name + email (W2, cosmetic — D1) | `PUT /system/identity` | no (defaults to OS user) | +| 2 | `system-check` | run doctor checks | `GET /system/check` | **block on hard fail** (no runtime) | +| 3 | `runtime` | confirm/switch runtime | `GET` + `PUT /system/runtime` | block until one valid runtime persisted | +| 4 | `harnesses` | pick harnesses; seed configs | `POST /system/init` | block until ≥1 chosen | +| 5 | `images` | pull/verify images w/ progress | `POST /system/images/pull` + SSE | non-block (skippable; can build later) | +| 6 | `workspace` | create first project / link grove | existing `POST /api/v1/projects` (+ W5) | block until ≥1 workspace | +| 7 | `done` | summary; `navigateTo('/')` | — | terminal | + +Step 1 (identity) is intentionally early ("welcome / identity" per parent §4) but +non-blocking: skipping it keeps the OS-user default (W2). The hard gates are 2→3 +(runtime must exist), 4 (a harness), and 6 (a workspace) — these define +`needsOnboarding` in §3.1. + +### 5.3 State machine + +``` + ┌─────────── on mount: GET /system/status ───────────┐ + ▼ │ + ┌────────────────────────────────────────────────────────────┐ │ + │ resume(status): pick first incomplete step (see 5.4 table) │─┘ + └────────────────────────────────────────────────────────────┘ + │ + ▼ + [welcome] ──next──▶ [identity] ──next/skip──▶ [system-check] + │ + hard fail (no runtime) ───┤ (show remediation, + │ allow re-run only) + ▼ pass/warn + [runtime] + │ PUT ok + ▼ + [harnesses] + │ POST /system/init ok + ▼ + [images] ──skip──┐ + │ pull done │ + ▼ ▼ + [workspace] ◀────┘ + │ project created + ▼ + [done] ──▶ navigateTo('/') +``` + +Each step is a `@state() currentStep` enum. `next()`/`back()` mutate it; guarded steps +refuse `next()` until their precondition holds (e.g. `runtimeConfigured`). All API +results are kept in component state (`status`, `checkReport`, `detectedRuntime`, +`selectedHarnesses`, `imageProgress[]`) so back/forward navigation never re-fetches +unnecessarily. Client state is **derived**, not authoritative — the server status is +the source of truth, re-fetched on `done` to confirm completion before redirect. + +### 5.4 Resumability + +On mount, `resume(status)` maps the §3.1 struct to a starting step: + +| Condition (first match wins) | Resume at | +|---|---| +| `!runtimeDetected` (doctor would hard-fail) | `system-check` | +| `!runtimeConfigured` | `runtime` | +| `!harnessesSeeded` | `harnesses` | +| `!imagesPresent` (and harnesses chosen) | `images` | +| `!hasWorkspace` | `workspace` | +| otherwise | `done` (then auto-redirect) | + +Every backing operation is **idempotent** (§6.5, §7.5), so resuming and re-running a +completed step is safe. A returning user with a half-set-up machine lands on the right +step instead of restarting (parent §4 closing requirement). + +--- + +## 6. API Surface — System (W1) + +All routes mount on the existing stdlib mux in `pkg/hub/server.go registerRoutes()` +(pattern around lines 1990-2116, e.g. `s.mux.HandleFunc("/api/v1/agents", s.handleAgents)`). +All use the `(s *Server)` receiver to reach `s.config`, `s.store`, `s.events` +(`pkg/hub/handlers.go`). All begin with the workstation-mode guard (§4.2) and require +the admin DevUser via the normal middleware chain. JSON via the existing `writeJSON` / +`writeError` helpers. + +New files: `pkg/hub/handlers_system.go` (handlers) and +`pkg/config/onboarding_status.go` (shared status logic). + +### 6.1 `GET /api/v1/system/status` + +Returns the §3.1 struct. `?images=true` additionally probes `ImageExists` per chosen +harness (slower). Powers first-run detection and wizard resume. + +```jsonc +{ + "needsOnboarding": true, + "initialized": true, + "runtimeDetected": "podman", + "runtimeConfigured": "", + "harnessesSeeded": [], + "imageRegistry": "", + "imagesPresent": null, // null unless ?images=true + "identitySet": false, + "hasWorkspace": false +} +``` + +Implementation: call the shared `config.ComputeOnboardingStatus(ctx, store, runtime)` +so the CLI quickstart (§3.2) reuses it. + +### 6.2 `GET /api/v1/system/check` + +Wraps doctor. Reuse `pkg/runtime/doctor.go` types directly: + +```go +type CheckResult struct { // pkg/runtime/doctor.go:17 + Name, Status, Message, Remediation string // status ∈ pass|warn|fail|skip +} +type DiagnosticReport struct { Runtime string; Checks []CheckResult } +``` + +The CLI's `runDoctor()` (`cmd/doctor.go:48`) prints directly and isn't reusable as-is. +**Refactor:** extract the check-gathering core from `cmd/doctor.go` into a returnable +`func GatherDiagnostics(ctx) DiagnosticReport` (in `pkg/runtime` or a new +`pkg/runtime/checks.go`) that runs `checkGit`/`checkTmux` (`cmd/doctor.go:154,168`) +plus runtime reachability and returns `[]CheckResult` instead of calling +`printCheck`. The CLI then becomes a thin printer over the same function — no behavior +drift between CLI doctor and wizard system-check. Response: `DiagnosticReport` as JSON. + +The wizard renders pass/warn/fail with remediation and only **blocks** when a `fail` +indicates no runtime. + +### 6.3 `GET /api/v1/system/runtime` and `PUT /api/v1/system/runtime` + +- **GET** → `{ "detected": "podman", "configured": "", "candidates": ["podman","docker"] }`. + `detected` from `config.DetectLocalRuntime()` (`runtime_detect.go:57`); `configured` + from `VersionedSettings.ResolveRuntime("")` (`settings_v1.go:90`); `candidates` from + probing the known set (podman/docker/container) for availability. +- **PUT** `{ "type": "docker" }` → validate it's a real, reachable runtime (instantiate + via `pkg/runtime/factory.go GetRuntime` or a lightweight probe), then persist. Persist + through `config.UpdateSetting(projectPath="", key="runtimes.local.type"/"runtime", + value, global=true)` (`pkg/config/settings.go:535`) or load→mutate→ + `SaveVersionedSettings` (`settings_v1.go:1870`). Return the updated GET payload. + +Setting a runtime that doesn't resolve returns `400` with remediation text. + +### 6.4 `POST /api/v1/system/init` + +Wraps `config.InitMachine(harnesses, opts)` (`pkg/config/init.go:548`): + +```jsonc +// request +{ "harnesses": ["claude", "gemini"], "imageRegistry": "ghcr.io/acme", "force": false } +``` + +- Map harness ids → `[]api.Harness` (the same resolution `cmd/project.go:82` uses). +- Build `InitMachineOpts{ Force: req.force, ImageRegistry: req.imageRegistry }` + (`init.go:538`). +- `InitMachine` creates `~/.scion`, detects runtime, seeds `settings.yaml`, seeds the + chosen harness-configs, seeds the default template, ensures a broker ID — all + idempotent (it skips seeding when settings already exist; `MkdirAll`/`ensureBrokerID` + are no-ops when present, per `init.go:554-635`). +- Response: the refreshed `GET /system/status` body so the wizard advances without a + second round-trip. + +Note: `InitMachine` seeds harness-configs for the harnesses passed. The wizard's +harness *selection* is the input; passing only chosen harnesses keeps the seed minimal. + +### 6.5 Idempotency & errors + +Every handler is safe to call repeatedly (re-init, re-detect, re-put runtime). Errors +use `writeError(w, status, code, message, details)` with actionable `message` strings +the wizard surfaces via `extractApiError` (`web/src/client/api.ts:96`). + +--- + +## 7. API Surface — Images (W4) + +Today image pulling is only the shell script +`image-build/scripts/pull-containers.sh` (pulls `scion-claude|gemini|opencode|codex`, +prunes after). There is no Go path wired to the server. W4 adds one, reusing the +runtime interface that already supports pulls. + +### 7.1 Building blocks to reuse + +- **Runtime interface** (`pkg/runtime/interface.go:56`): already has + `ImageExists(ctx, image) (bool, error)` (line 64) and `PullImage(ctx, image) error` + (line 65), implemented for Docker (`docker.go:274-281`), Podman (`podman.go:354,359`), + K8s (`k8s_runtime.go:1924,1937`). The workstation runtime comes from + `pkg/runtime/factory.go GetRuntime`. +- **Registry resolution** (`pkg/config/settings_v1.go`): + `ResolveImageRegistry(profile)` (line 152) and `RewriteImageRegistry(fullImage, + newRegistry)` (line 190) to compute the actual image refs per harness. +- **Image set per harness:** the four `scion-` names from the shell script, + resolved against the configured registry + tag. + +### 7.2 New Go glue + +New file `pkg/runtime/imagepull.go` (runtime-agnostic, depends only on the `Runtime` +interface + settings): + +```go +// ImagesForHarnesses maps chosen harness ids to fully-qualified image refs, +// applying ResolveImageRegistry + RewriteImageRegistry. +func ImagesForHarnesses(harnesses []string, settings *config.VersionedSettings) []string + +// PullImages pulls each image via rt, emitting progress callbacks. Skips images +// that already exist (ImageExists) unless force. Errors per-image are reported, +// not fatal to the batch. +func PullImages(ctx context.Context, rt Runtime, images []string, + force bool, progress func(ImagePullEvent)) error + +type ImagePullEvent struct { + Image string `json:"image"` + Status string `json:"status"` // queued|exists|pulling|done|error + Detail string `json:"detail,omitempty"` + Error string `json:"error,omitempty"` +} +``` + +Note `PullImage(ctx, image) error` is currently coarse (Docker uses +`runInteractiveCommand`, `docker.go:280`) — it doesn't stream layer-level progress. For +v1, progress is **per-image** (`queued → pulling → done|error`), which is honest and +enough for the wizard. A later enhancement can add a streaming variant +(`PullImageStream`) that parses `docker pull --progress` / `podman pull` output; call +that out as a follow-up rather than blocking W4 on it. + +### 7.3 `POST /api/v1/system/images/pull` + +```jsonc +// request +{ "harnesses": ["claude","gemini"], "force": false } +// response (starts the job, returns a job id) +{ "jobId": "imgpull-7f3a", "images": ["ghcr.io/acme/scion-claude:...", "..."] } +``` + +Starts `PullImages` in a goroutine, publishing each `ImagePullEvent` to the event bus +(see §7.4). Returns immediately with a `jobId`. Workstation-mode guarded; admin only. +Concurrency: refuse a second pull while one is active (return `409` with the active +`jobId`) to keep state simple. + +### 7.4 SSE progress: `GET /api/v1/system/images/events` + +Model on the existing SSE handler `handleSSE` (`pkg/hub/web.go:957-1024`): + +- Set headers exactly as `web.go:988`: `Content-Type: text/event-stream`, + `Cache-Control: no-cache`, `Connection: keep-alive`, `X-Accel-Buffering: no`; obtain + `http.Flusher` (`web.go:963`) and clear the write deadline with + `http.NewResponseController` (`web.go:980`) so the long-lived stream survives the + 60s `WriteTimeout` (`web.go:1661`). +- Emit each event in the same wire format (`web.go:1014`): + `event: image-progress\ndata: {}\n\n` then `flusher.Flush()`. +- **Reuse the existing event bus** rather than a bespoke channel: publish + `ImagePullEvent`s through `s.events` (the `EventPublisher` already wired for SSE, + subjects like `system.images.`) and let the wizard subscribe. Two options: + 1. **Reuse the existing `/events` SSE endpoint** with a new subject + `system.images.>` — preferred, since the client already has `sse-client.ts` and + `state.ts` subject plumbing (`web/src/client/state.ts:175`). The wizard adds the + subject to its scope; no new endpoint at all. The dedicated + `/system/images/events` route is the fallback if subject-scoping to a non-project + stream proves awkward. + 2. Dedicated endpoint as written above. + + **Recommendation:** option 1 (subject on the shared `/events` stream) — least new + code, reuses `SSEClient` reconnection/backoff (`sse-client.ts:148`). Document the + subject contract: `system.images.` carries `ImagePullEvent` payloads; + terminal event has `status:"done"` or `status:"error"` for the whole batch. + +- **Client:** the images step adds `system.images.>` to its SSE scope, renders a row per + image with a status pill, and enables **Next** when all images reach `done`/`exists` + or the user clicks **Skip** (images is non-blocking, §5.2). + +### 7.5 No-registry / build-path handling (parent Q5) + +When `ResolveImageRegistry("")` is empty, `images/pull` cannot pull. The handler returns +a structured `409`/`422` with code `image_registry_unset` and guidance text (mirroring +the existing `RequireImageRegistry`-style guidance referenced in +`.design/image-onboarding.md`). The wizard renders this as a non-dead-end: it shows the +build instructions (`image-build/`) and a **Skip for now** that advances to the +workspace step. `imagesPresent` simply stays incomplete in status; the user can pull +later. This satisfies "must gracefully handle 'you need to build images first'". + +Re-running a pull is idempotent: `ImageExists` short-circuits already-present images to +`status:"exists"`. + +### 7.6 Local image build (D3) — `POST /api/v1/system/images/build` + +Default onboarding pulls prebuilt images from the pre-seeded `ghcr.io/homebrew-scion` +registry (D3), so most users never build. But for users on their own registry, an +air-gapped setup, or who simply want local images, the wizard offers a **"Build images +locally"** action — **enabled only after a runtime is confirmed present** (the runtime +step, §5.2, must be complete; the button is disabled otherwise). + +- **Mechanism:** shell out to the existing build script + `image-build/scripts/build-images.sh` with the resolved registry/tag and a target of + `common` (scion-base + harnesses + hub; the script's default — see + `.design/image-onboarding.md`). The chosen builder follows the active runtime + (`local-docker` / `local-podman`). +- **Progress (D4):** builds are long-running, so unlike per-image pull pills, the build + step **streams raw build log lines** into a **collapsible log panel**, over the same + `/events` SSE stream under a `system.images.` subject (event payloads carry a + `line` field; terminal event carries `status:"done"|"error"`). One build job at a time + (`409` if already running), mirroring the pull job (§7.3). +- **Endpoint:** `POST /system/images/build { "target": "common", "force": false }` → + `{ "jobId": "imgbuild-…" }`. Workstation-mode guarded; admin only. New glue in + `pkg/runtime/imagebuild.go` (or alongside `imagepull.go`) wrapping the script + invocation and line-streaming. +- After a successful build, `ImageExists` reports the images present, so the wizard's + `images` step turns green and `imagesPresent` flips complete in status. + +> Building from source images also requires the build context (Dockerfiles under +> `image-build/`) to be present on disk. For a Homebrew-installed binary that ships +> without the build context, the build option is **hidden** unless the context is found +> (probe for `image-build/`); those users rely on the prebuilt pull path. The wizard +> surfaces which path is available rather than offering a build that can't run. + +--- + +## 8. Security Considerations + +1. **Workstation-mode hard fence (§4.2).** Every `system/*` handler returns `404` unless + `s.requireWorkstation(...)` (the `Workstation bool` flag set from `!production`). These + endpoints write `~/.scion`, mutate runtime config, and pull/build images — they must + be invisible on any multi-user/production Hub. This is the same fence the parent doc + demands for filesystem access (W5 shares the flag and helper). +2. **Loopback only.** Auto-login (§4.1) is acceptable solely because + `applyWorkstationDefaults` binds `127.0.0.1`. Do not relax the bind in workstation + mode. +3. **Admin role required.** Handlers require the admin DevUser (the default workstation + identity is `role: admin`), enforced by the normal `UnifiedAuthMiddleware` chain — no + bypass via `isUnauthenticatedEndpoint`. +4. **Input validation.** `runtime.type` must be in the known set; + `harnesses` must be known harness ids; `imageRegistry` validated as a registry ref + before persistence. Reject anything else with `400`. +5. **No arbitrary path input here.** This doc's endpoints take no host paths; the + filesystem-touching workspace step is W5's responsibility under its own fence. + +--- + +## 9. Files Touched / Created + +**Backend (Go):** + +| Action | Path | Notes | +|---|---|---| +| new | `pkg/hub/handlers_system.go` | all `system/*` handlers | +| new | `pkg/config/onboarding_status.go` | shared status logic (CLI + handler) | +| new | `pkg/runtime/imagepull.go` | `ImagesForHarnesses`, `PullImages`, `ImagePullEvent` | +| new | `pkg/runtime/checks.go` (or extend `doctor.go`) | returnable `GatherDiagnostics` | +| edit | `pkg/hub/server.go` | register routes (~`registerRoutes` 1990-2116); add `Workstation bool` to `ServerConfig` + `requireWorkstation()` helper (shared with W5) | +| edit | `cmd/doctor.go` | refactor `runDoctor` to print over `GatherDiagnostics` | +| edit | `cmd/server_daemon.go` | `printWorkstationQuickstart` prints `/onboarding` URL when `needsOnboarding` | +| edit | `pkg/hub/devauth.go` / `web.go` devAuthMiddleware | source identity from settings (W2 dependency) | + +**Frontend (Lit/TS):** + +| Action | Path | Notes | +|---|---|---| +| new | `web/src/components/pages/onboarding.ts` | `scion-page-onboarding` wizard + state machine | +| edit | `web/src/client/main.ts` | add route to `ROUTES`; add to `STANDALONE_ROUTES`; first-run redirect guard in `renderRoute` | +| reuse | `web/src/client/api.ts` | `apiFetch`, `extractApiError` | +| reuse | `web/src/client/sse-client.ts`, `state.ts` | subscribe `system.images.>` for image progress | + +--- + +## 10. Resolved Decisions (2026-05-31) + +1. **Identity persistence shape (W2) → small dedicated `PUT /system/identity`.** + Identity (cosmetic: display name + email, default OS user — D1) lives on + `V1AuthConfig`/`DevAuthConfig`; the wizard writes it via `PUT /system/identity` rather + than overloading admin server-config. +2. **Pull progress → per-image (D4).** v1 ships per-image status pills; parsed + layer-level progress (`PullImageStream`) is a later enhancement. Local builds stream + raw log lines (§7.6). +3. **SSE channel → shared `/events` stream** with a `system.images.` subject (no + dedicated endpoint), reusing `SSEClient` reconnection/backoff. +4. **Status caching → `sessionStorage`.** Cache the "setup complete" result in + `sessionStorage` and clear it on wizard completion; re-fetch `/system/status` on a + fresh session. (Cross-tab invalidation is unnecessary for a single-user workstation.) +5. **Launch behavior → auto-open + always print, before backgrounding (D8).** The daemon + prints the `/onboarding` URL prominently before detaching and best-effort auto-opens + the browser (with `--no-browser` and TTY/SSH guards). See §3.2. + +--- + +## 11. Build Order (W1/W4 slice) + +> The **authoritative cross-workstream sequence is the parent doc +> [`workstation-onboarding.md`](./workstation-onboarding.md) §7.** The list below is the +> W1/W4 slice of that sequence, for local reference. + +1. `Workstation bool` on `ServerConfig` + `requireWorkstation()` fence (parent §7 Phase 0 + — shared with W5; unblocks all handlers safely). +2. `GatherDiagnostics` refactor + `GET /system/check`; `GET/PUT /system/runtime` (thin, + high-leverage wrappers). +3. `ComputeOnboardingStatus` + `GET /system/status`; `PUT /system/identity` (W2); wire + CLI quickstart with auto-open before backgrounding (§3.2, D8). +4. `POST /system/init`. +5. Wizard shell (`onboarding.ts`) wiring steps 0–4,6–7 behind the first-run gate; + `sessionStorage` status cache. +6. W4 image **pull** (`imagepull.go`, `POST /system/images/pull`, per-image SSE) + images + step. +7. W4 image **local build** (`POST /system/images/build`, log-line SSE, gated on runtime + present and build-context present — §7.6). +8. (W5, [`linked-groves-ui.md`](./linked-groves-ui.md)) workspace step's linked-grove + directory-browser mode. diff --git a/.design/workstation-onboarding.md b/.design/workstation-onboarding.md new file mode 100644 index 000000000..eff86955a --- /dev/null +++ b/.design/workstation-onboarding.md @@ -0,0 +1,343 @@ +# Workstation Mode as a First-Class Onboarding Experience + +**Date:** 2026-05-30 (decisions folded in 2026-05-31) +**Status:** Proposal — Survey & Planning (decisions confirmed) +**Author:** Scion Agent (workstation-onboarding) +**Sub-designs:** +[`workstation-onboarding-wizard.md`](./workstation-onboarding-wizard.md) (W1+W4), +[`linked-groves-ui.md`](./linked-groves-ui.md) (W5) + +--- + +## 1. Executive Summary + +Today, **workstation mode** is something a user typically reaches *after* they have +already bootstrapped a machine on the command line (`scion init --machine`, built or +pulled images, configured a runtime). `scion server start` then lights up a co-located +Hub + Runtime Broker + Web UI on `127.0.0.1` with a generated dev token. It is, in +effect, a "phase two" convenience rather than an entry point. + +This document proposes treating workstation mode as a **valid — if not primary — +first entry point** into Scion: a user installs (e.g. via the Homebrew tap), runs +`scion server start`, and is met with a browser-based **onboarding experience** that +walks them through the setup that currently only exists as disconnected CLI steps: + +- Choosing which **harnesses** they want (Claude Code, Gemini, Codex, OpenCode). +- Initializing the **global directory** (`~/.scion`). +- Verifying the **container runtime** is installed and reachable. +- **Pulling images** (default registry pre-seeded by the Homebrew install) — or + **building them locally** once a runtime is confirmed. +- Setting a **username / identity** instead of the hardcoded `dev@localhost`. +- Adding **workspaces**, including **linked groves** — local directories that live + *outside* the Hub's managed path space — directly from the browser via a + server-side directory browser. + +This is a survey of the current state plus a plan for the workstreams. Two of the +workstreams (the onboarding wizard and linked groves) have their own sub-design docs, +linked above. **This doc is the single source of truth for the primary, end-to-end +sequence of work (§7); the sub-docs carry the detailed designs.** + +--- + +## 1a. Confirmed Decisions (2026-05-31) + +Decisions from the design review with the product owner. These supersede any +contradicting text in earlier drafts of the sub-docs; the sub-docs have been updated to +match. + +| # | Decision | Effect | +|---|---|---| +| D1 | **Identity is cosmetic** for local single-user. Keep the stable `DevUser` UUID for DB integrity; allow a settable display name + email, defaulting to the **OS username** instead of `dev@localhost`. | W2 stays small. No real user-record creation. | +| D2 | **Do not rename the dev token.** Keep "dev token", the `scion_dev_` format, `~/.scion/dev-token`, and `SCION_DEV_TOKEN` exactly as-is internally. **Only relabel docs/UI** to "developer token" for consistency. | W3 shrinks to a copy/label pass; no new env var, no `local auth mode`. | +| D3 | **Images: prefer prebuilt, add local build.** The Homebrew install pre-seeds `ghcr.io/homebrew-scion` as the registry, so the pull path is the default and "just works". **Add a local image-build option** in the wizard, available **after a runtime is confirmed present**. | W4 = pull (default) + build (fallback). | +| D4 | **Per-image pull progress** (queued → pulling → done/exists/error). Layer-level streaming is a later enhancement. Local build streams raw build log lines into a collapsible panel. | W4 progress fidelity. | +| D5 | **Linked-grove picker = server-side directory browser** (a custom web folder tree with a **"New folder"** button), **strictly disabled (404) when serving in production.** Not a native OS dialog (not reachable from a served web page). | W5 elevates the browser to v1. | +| D6 | **Hard-fail** when a user tries to link a directory that is inside the hub-managed path space (`~/.scion/projects/`, legacy `groves/`). | W5 validation rule. | +| D7 | **Two-step linked-grove create** (create project, then add the co-located broker as a provider with the local path), mirroring `scion hub link`. Recoverable on failure; revisit an atomic create-handler only if it proves flaky. | W5 submit flow. | +| D8 | **`scion server start` auto-opens the browser** to `/onboarding` when the machine is un-onboarded, **and always prints the URL prominently** — printed **before** the daemon backgrounds itself. | W1 launch behavior. | + +Minor implementation defaults (also confirmed): prod fence via a `Workstation` flag on +`hub.ServerConfig` (all `/system/*` + fs endpoints 404 in prod, plus loopback assertion ++ normal auth); directory browser opens at `$HOME` by default; image progress travels on +the existing `/events` SSE stream under a `system.images.` subject; first-run +detection is a server-computed status struct cached client-side in `sessionStorage` +(cleared on wizard completion); identity is written via a small `PUT /system/identity`; +exactly one co-located broker is assumed per workstation. + +--- + +## 2. Current State (Survey) + +### 2.1 How workstation mode works today + +`scion server start` is the entry point. In the absence of `--production`, it applies +workstation defaults and prints a quickstart. + +- **Defaults** — `applyWorkstationDefaults()` at `cmd/server_config.go:25-44` turns on + Hub, Runtime Broker, Web, dev-auth, and auto-provide, and binds to `127.0.0.1`. + Explicit flags always win (`cmd.Flags().Changed(...)`). +- **Command family** — `cmd/server.go:24-279` defines `server start | stop | restart | + status | install`. The start path is `cmd/server_daemon.go:33-161` + (`runServerStartOrDaemon`); the foreground runner is `cmd/server_foreground.go:56-424` + (`runServerStart`). Default behavior is a backgrounded daemon; `--foreground` runs in + the terminal. +- **Quickstart** — `printWorkstationQuickstart()` at `cmd/server_daemon.go:361-384` + prints the Web UI URL and `export SCION_DEV_TOKEN=...`. This is the *entire* current + "onboarding": a URL and a token, with no guided setup behind it. **(D8 extends this to + print the `/onboarding` URL and auto-open the browser when un-onboarded.)** +- **Mode detection / persistence** — `pkg/config/hub_config.go` (`Mode` field, + `LoadServerMode`); `mode: workstation` can be set persistently in + `~/.scion/settings.yaml`. + +**Key takeaway:** workstation mode is an *opinionated preset* that co-locates three +services. It assumes the machine is already initialized. There is no first-run gate +that detects an un-initialized machine and offers to set it up. + +### 2.2 The web UI + +- **Stack** — Lit + Vite + Shoelace + xterm.js + CodeMirror, server-rendered shell with + client hydration. Web server: `pkg/hub/web.go` (`WebServer`, default port 8080, + `/healthz`, `/assets/*`, SPA catch-all, `/events` SSE). API mounted at `/api/v1/*` + via `MountHubAPI` (`pkg/hub/web.go:518-527`). +- **Routes** — `web/src/client/main.ts:127-158`. Pages include `/`, `/login`, + `/projects`, `/projects/new`, `/agents`, `/agents/new`, `/brokers`, `/invite`, + `/github-app/installed`, and an `/admin/*` family. +- **Existing onboarding-ish flows** — GitHub App setup + (`web/src/components/pages/github-app-setup.ts`), invite redemption + (`web/src/components/pages/invite.ts`), the admin server-config editor + (`web/src/components/pages/admin-server-config.ts`), and an empty-state-friendly home + dashboard (`web/src/components/pages/home.ts`). + +**Key takeaway:** there is **no first-run onboarding wizard**. Building blocks exist but +are not stitched into a guided first-run flow. (Designed in +[`workstation-onboarding-wizard.md`](./workstation-onboarding-wizard.md).) + +### 2.3 Machine init, runtime, and images + +- **`scion init --machine`** — `cmd/init.go:21-48` → `cmd/project.go:59-116` → + `config.InitMachine()` at `pkg/config/init.go:548-620`. Creates `~/.scion`, detects + the runtime, writes `settings.yaml`, seeds harness-configs for all four built-ins, + seeds the default template, pre-generates a stable broker ID, and (Homebrew install) + pre-seeds `image_registry: ghcr.io/homebrew-scion`. +- **Runtime detection** — `pkg/config/runtime_detect.go:52-75` (`DetectLocalRuntime`, + preference podman → container[macOS] → docker). Factory: `pkg/runtime/factory.go:31-137`. +- **Image pulling** — today a *shell script*, `image-build/scripts/pull-containers.sh`. + Prebuilt public images are published at `ghcr.io/homebrew-scion/` (see the Homebrew + distribution design). W4 adds a Go-native, harness-aware pull plus a local build path. +- **Doctor** — `cmd/doctor.go:30-261` already runs git/tmux/runtime checks with + structured pass/warn/fail results (`pkg/runtime/doctor.go:15-41`) — the data source + for the wizard's system-check step. + +### 2.4 Auth, dev token, and username + +- **Dev token** — generated/resolved in `pkg/apiclient/devauth.go:27-157` + (`scion_dev_` prefix, `~/.scion/dev-token`, env `SCION_DEV_TOKEN`). Server side: + `initDevAuth()` at `cmd/server_foreground.go:678-700`; middleware at + `pkg/hub/devauth.go:53-148`. **(D2: all of this stays exactly as-is.)** +- **Unified auth** — `pkg/hub/auth.go:60-248` (`UnifiedAuthMiddleware`). +- **Hardcoded dev identity** — `pkg/hub/devauth.go:26-49`: `DevUser` is a fixed pseudo + user (UUID `be67fbc9-…`, `dev@localhost`, `Development User`, role `admin`). + **(D1: keep the UUID; make display name/email configurable, defaulting to the OS user.)** +- **Config surface** — `DevAuthConfig` (`pkg/config/hub_config.go:142-158`) and + `V1AuthConfig` (`pkg/config/settings_v1.go:383-388`) carry token fields but no + identity fields today. + +### 2.5 Groves, the managed path space, and linked groves + +(A **grove → project** rename is in flight — `.design/grove-to-project-rename.md`. +"grove" and "project" are the same concept; build against "project".) + +- **Project types** — `pkg/store/models.go:186-246` computes `ProjectType`: + `hub-native` vs `linked`. +- **Managed path space** — `hubNativeProjectPath()` at `pkg/hub/handlers.go:3736-3752` + places hub-native/shared-workspace projects under `~/.scion/projects//` + (legacy `~/.scion/groves//`). This is "the Hub's managed section of the + filesystem". +- **Linked projects already exist in the model** — `ProjectProvider` + (`pkg/store/models.go:337-379`) carries `LocalPath`/`BrokerID`/`LinkedBy`/`LinkedAt`; + link API `POST /api/v1/projects/{projectId}/providers` + (`pkg/hub/handlers.go:7980-8043`); WebDAV honors a co-located broker's `LocalPath` + (`pkg/hub/project_webdav.go:136-190`). CLI precedent: `scion hub link` + (`cmd/hub.go:215-235`, `runHubLink`). +- **The browser gap** — `web/src/components/pages/project-create.ts` only supports + git-backed / hub-native projects. There is **no UI to register an arbitrary local + directory as a linked grove.** (Closed by + [`linked-groves-ui.md`](./linked-groves-ui.md).) + +--- + +## 3. Goals and Non-Goals + +### Goals +1. `scion server start` on a fresh machine leads to a **guided, browser-based + onboarding** that can fully bootstrap Scion (and auto-opens — D8). +2. Onboarding can: pick harnesses, init `~/.scion`, verify runtime, **pull prebuilt + images or build them locally** (D3), set identity, and add at least one workspace. +3. **Cosmetic configurable identity** for local mode (D1). +4. **Relabel** dev-token references to "developer token" in docs/UI only (D2). +5. **Add linked groves from the browser** via a server-side directory browser (D5), + hard-fenced to workstation mode. + +### Non-Goals +- Multi-user / production auth changes beyond the cosmetic identity (D1). +- Replacing the existing CLI init paths (onboarding *reuses* their logic). +- Remote-broker linked-grove UX (initial focus is the co-located workstation broker). +- The grove→project rename itself (tracked separately). +- Publishing/operating the prebuilt image pipeline (owned by the Homebrew distribution + work; onboarding only *consumes* the pre-seeded registry). + +--- + +## 4. Proposed Onboarding Experience + +A first-run flow served by the workstation Web UI, gated on detecting an +un-/under-initialized machine. A wizard with skippable, resumable steps: + +1. **Welcome / identity** — set display name + username (D1; defaults to OS user). +2. **System check** — run the existing `doctor` checks; render pass/warn/fail. Block + only on hard failures (no runtime). +3. **Runtime** — confirm or switch the detected runtime; persist to `settings.yaml`. +4. **Harness selection** — choose harnesses; seed the selected harness-configs. +5. **Images** — pull from the pre-seeded registry (D3), with per-image progress (D4); + **or** build locally now that a runtime is confirmed. Skippable. +6. **First workspace** — create a hub-native project, link a git repo, *or* add a + linked grove by browsing to / creating a local directory (D5). +7. **Done** — land on the dashboard, ready to start an agent. + +State is resumable from the server-computed status struct (see the wizard doc §3/§5.4). + +--- + +## 5. Workstreams + +Detailed designs live in the sub-docs; this section is the index. The **build order +that interleaves these is §7.** + +### W1 — Onboarding wizard (web UI + supporting API) +Detailed in [`workstation-onboarding-wizard.md`](./workstation-onboarding-wizard.md). +New `/onboarding` route + Lit page; first-run detection + redirect/auto-open (D8); +`/api/v1/system/*` endpoints (`status`, `check`, `runtime`, `init`, `images/*`) that +wrap existing logic (`doctor`, `DetectLocalRuntime`, `config.InitMachine`). + +### W2 — Cosmetic identity (D1) +Add `username`/`displayName`/`email` to `DevAuthConfig` +(`pkg/config/hub_config.go:142-158`) and `V1AuthConfig` +(`pkg/config/settings_v1.go:383-388`); thread into `DevUser` +(`pkg/hub/devauth.go:26-49`) **keeping the stable UUID**; default to the OS user +(`os/user`) when unset. Written via `PUT /system/identity` from the wizard. + +### W3 — "Developer token" relabel (D2) +Docs/UI copy only: standardize on "developer token" in CLI help, quickstart output +(`cmd/server_daemon.go:361-384`), web copy, and docs. **No code/format/env-var change.** +Coordinate with `cli-modes.md`. + +### W4 — Harness-aware images: pull + local build (D3, D4) +Detailed in the wizard doc §7. Go-native pull via the runtime interface +(`pkg/runtime/interface.go` `ImageExists`/`PullImage`), defaulting to the pre-seeded +`ghcr.io/homebrew-scion` registry, with **per-image** progress on the `/events` SSE +stream (D4). Add a **local build** option (shells out to the build scripts) enabled only +after a runtime is confirmed, streaming build logs into a collapsible panel. + +### W5 — Linked groves via a directory browser (D5, D6, D7) +Detailed in [`linked-groves-ui.md`](./linked-groves-ui.md). A workstation-only +(404-in-prod) **directory browser** with a **"New folder"** button, backed by fenced +`fs/list` + `fs/mkdir` + `fs/validate-path` endpoints; **hard-fail** on managed-path +overlap (D6); **two-step** create (project + provider) (D7). + +--- + +## 6. Resolved Decisions & Remaining Risks + +The original open questions are now resolved by §1a: + +| Original question | Resolution | +|---|---| +| Q1 Filesystem access fencing | `Workstation` flag on `ServerConfig`; all `system/*`/fs endpoints 404 in prod + loopback + auth (D5; wizard §4.2, linked §4). | +| Q2 First-run detection signal | Server-computed status struct, `sessionStorage`-cached (wizard §3). | +| Q3 Bootstrap-auth window | Dev-auth auto-login session on loopback (wizard §4). | +| Q4 Rename naming | Build against "project" per the in-flight rename. | +| Q5 Image / no-registry UX | Pre-seeded `ghcr.io/homebrew-scion` is the default; local build added; step skippable (D3). | +| Q6 Resumability/idempotency | Idempotent endpoints + resume from status struct (wizard §5.4). | +| Q7 Username scope | Cosmetic only, stable UUID (D1). | + +**Remaining risks to watch during implementation:** +1. **Directory-browser blast radius.** `fs/list` + `fs/mkdir` read/create on the host + filesystem. The 404-in-prod fence, loopback assertion, path-safety helpers + (symlink-expand, managed-root checks), and auth are all mandatory — not optional. +2. **Build-path UX.** Local builds are long and can fail for environment reasons; the + wizard must stream logs and fail gracefully without dead-ending onboarding. +3. **Grove→project rename churn.** New UI/API must follow whatever naming is canonical + at implementation time. +4. **Two-step linked create orphans** (D7): a failed provider-add leaves a project with + no local path; ensure re-submit recovers and surface the state clearly. + +--- + +## 7. Primary Sequence of Work (single source of truth) + +One ordered, end-to-end plan spanning all workstreams. Each item notes its sub-doc. +Items within a phase can parallelize; phases are ordered by dependency. + +**Phase 0 — Foundations (unblock everything safely)** +1. Add `Workstation bool` to `hub.ServerConfig` (set from `!production` at + `cmd/server_foreground.go:774`), store `s.workstation`, add `requireWorkstation` + (404) + loopback assertion helpers. *(W1/W5 — wizard §4.2, linked §4)* +2. Add `GetEmbeddedBrokerID()` accessor on the server. *(W5 — linked §6)* + +**Phase 1 — Identity & labels (small, self-contained)** +3. W2: identity fields on `DevAuthConfig`/`V1AuthConfig`; thread into `DevUser` + (keep UUID); default to OS user. *(D1)* +4. W3: "developer token" relabel across CLI help, quickstart, web copy, docs. *(D2)* + +**Phase 2 — System API (thin wrappers over existing logic)** +5. Refactor `cmd/doctor.go` into a returnable `GatherDiagnostics`; add + `GET /system/check`. *(W1 — wizard §6.2)* +6. `GET`/`PUT /system/runtime` (detect + persist). *(W1 — wizard §6.3)* +7. `ComputeOnboardingStatus` + `GET /system/status`; `PUT /system/identity`. *(W1+W2)* +8. `POST /system/init` (wraps `config.InitMachine` with chosen harnesses). *(W1 — §6.4)* + +**Phase 3 — Wizard shell** +9. `web/src/components/pages/onboarding.ts` (`scion-page-onboarding`), route + + standalone shell, state machine, steps 0–4 & 6–7 behind the first-run gate; + `sessionStorage` "setup complete" cache. *(W1 — wizard §5)* +10. Daemon launch: print `/onboarding` URL **before backgrounding** and **auto-open the + browser** when un-onboarded (with `--no-browser` opt-out; skip when not a TTY/over + SSH). *(D8 — wizard §3.2)* + +**Phase 4 — Images (pull + build)** +11. `pkg/runtime/imagepull.go`: `ImagesForHarnesses`, `PullImages` (per-image events), + reusing `ImageExists`/`PullImage`. *(W4 — wizard §7.2)* +12. `POST /system/images/pull` + progress on `/events` (`system.images.`); wizard + images step renders per-image pills. *(W4 — D4, wizard §7.3–7.4)* +13. Local build option (post-runtime): `POST /system/images/build` shelling to the build + scripts, streaming log lines into a collapsible panel. *(W4 — D3)* + +**Phase 5 — Linked groves (directory browser)** +14. `pkg/hub/fs_safety.go`: `resolveAndClassifyPath` (resolve, symlink-expand, + managed-root + git + already-linked classification), reusing `hubNativeProjectPath`. + *(W5 — linked §3.3)* +15. Fenced endpoints: `POST /system/fs/validate-path`, `GET /system/fs/list`, + `POST /system/fs/mkdir` (all 404 in prod). *(W5 — linked §3, §4)* +16. `project-create.ts`: `'linked'` mode with a directory-browser modal + "New folder" + button populating the path; **hard-fail** on managed overlap (D6); **two-step** + submit (create project, then add embedded broker as provider with resolved path) + (D7). *(W5 — linked §5)* +17. Wire the workspace step (wizard step 6) to the linked-grove flow. + +**Phase 6 — Polish & docs** +18. Tests per sub-doc (fencing 404s, path classification, two-step create → + `ProjectType == "linked"`, wizard step gating). +19. Update user docs / README (remove "no prebuilt images" note; document onboarding, + "developer token" label, linked groves). + +--- + +## 8. Sub-Design Docs + +- [`workstation-onboarding-wizard.md`](./workstation-onboarding-wizard.md) — W1 + W4: + wizard UX/state machine, first-run detection, bootstrap-auth window, the `system/*` + API, and image pull/build. +- [`linked-groves-ui.md`](./linked-groves-ui.md) — W5: directory-browser UX, the fenced + `fs/*` endpoints, security model, and the two-step linked-create flow. + +W2 and W3 are small enough to land as implementation PRs without separate design docs. diff --git a/.tasks/phase-0-1-foundations-identity.md b/.tasks/phase-0-1-foundations-identity.md new file mode 100644 index 000000000..7ded597f0 --- /dev/null +++ b/.tasks/phase-0-1-foundations-identity.md @@ -0,0 +1,66 @@ +# Phase 0+1: Foundations, Identity & Labels + +**Branch:** workstation-improvements +**Design docs:** `.design/workstation-onboarding.md` §7, `.design/workstation-onboarding-wizard.md`, `.design/linked-groves-ui.md` +**Commit all changes to the current branch as you go.** + +--- + +## Phase 0 — Foundations + +### 0.1 — `Workstation bool` on `hub.ServerConfig` + +File: `pkg/hub/web.go` (or wherever `ServerConfig` is defined — search for the struct) + +- Add `Workstation bool` field to `hub.ServerConfig` +- In `cmd/server_foreground.go` around line 774, set `cfg.Workstation = !productionMode` when building the server config +- On the server struct, store it: `s.workstation bool` +- Add two helpers: + - `requireWorkstation(next http.Handler) http.Handler` — middleware that returns 404 if `!s.workstation` + - `assertLoopback(r *http.Request) error` — checks `r.RemoteAddr` is loopback (127.x or ::1) +- These will gate all `/system/*` and filesystem endpoints + +### 0.2 — `GetEmbeddedBrokerID()` accessor + +- Add a method on the server (or hub config) that returns the pre-generated embedded broker ID from `settings.yaml` +- This is used in W5 when the two-step linked-grove create needs to find the co-located broker + +--- + +## Phase 1 — Cosmetic Identity (W2) + Developer Token Relabel (W3) + +### 1.1 — Identity fields on DevAuthConfig / V1AuthConfig (W2) + +Files: +- `pkg/config/hub_config.go` — `DevAuthConfig` struct (around line 142-158): add `Username`, `DisplayName`, `Email string` fields +- `pkg/config/settings_v1.go` — `V1AuthConfig` (around line 383-388): same additions +- `pkg/hub/devauth.go` — `DevUser` construction (around line 26-49): + - **Keep the stable UUID** (`be67fbc9-...`) unchanged + - Read `Username`/`DisplayName`/`Email` from config + - If unset, default to OS user via `os/user` (`user.Current()` → `u.Username`, `u.Name`) + - Email default: `@localhost` + +Add a small `PUT /api/v1/system/identity` endpoint: +- Body: `{ "displayName": "...", "email": "..." }` +- Writes to `DevAuthConfig` in `settings.yaml` via the config save path +- Returns the updated identity +- Protected by `requireWorkstation` + +### 1.2 — "Developer token" relabel (W3) + +This is a text/copy pass only — **no code logic changes**: +- `cmd/server_daemon.go` `printWorkstationQuickstart()` (line 361-384): change "dev token" → "developer token" in the printed output +- CLI help strings referencing "dev token" in `cmd/` — update to "developer token" +- Web copy in `web/src/` — grep for "dev token" / "dev-token" in UI strings and update display text (not variable names, not `scion_dev_` format, not env var names — those stay) +- Any user-facing docs in `docs-site/` or `docs-repo/` + +--- + +## Commit Instructions + +- Commit Phase 0 work as one commit: `feat: add Workstation flag and workstation-only middleware to ServerConfig` +- Commit Phase 1 work as two commits: + - `feat: make dev identity configurable, default to OS user (W2)` + - `chore: relabel dev token as "developer token" in UI and docs (W3)` +- Run `go build ./...` and `go vet ./...` before each commit to verify no compile errors +- Do not open PRs — commit directly to the `workstation-improvements` branch diff --git a/.tasks/phase-2-system-api.md b/.tasks/phase-2-system-api.md new file mode 100644 index 000000000..496a619ea --- /dev/null +++ b/.tasks/phase-2-system-api.md @@ -0,0 +1,89 @@ +# Phase 2: System API Endpoints + +**Branch:** workstation-improvements +**Design docs:** `.design/workstation-onboarding-wizard.md` §6, `.design/workstation-onboarding.md` §7 +**Prereq:** Phase 0+1 complete (Workstation flag and requireWorkstation middleware exist) +**Commit all changes to the current branch as you go.** + +--- + +## Overview + +Add thin `GET/PUT /api/v1/system/*` API endpoints that wrap existing Go logic. All are gated by `requireWorkstation` (404 in production). All require normal auth. + +--- + +## 2.1 — Refactor doctor into a returnable function + `GET /system/check` + +- In `cmd/doctor.go` (line 30-261) or `pkg/runtime/doctor.go` (line 15-41), extract the core check logic into a function `GatherDiagnostics(ctx context.Context, cfg *config.Config) ([]DiagnosticResult, error)` that returns structured results instead of printing them. +- `DiagnosticResult` struct: `{ Name, Status ("pass"|"warn"|"fail"), Message string }` +- Add `GET /api/v1/system/check` handler: + - Calls `GatherDiagnostics` + - Returns JSON: `{ "results": [...], "ready": bool }` where `ready` = no "fail" results + +## 2.2 — `GET` and `PUT /system/runtime` + +- `GET /api/v1/system/runtime`: + - Calls `config.DetectLocalRuntime()` (`pkg/config/runtime_detect.go:52-75`) + - Returns: `{ "detected": "docker"|"podman"|"container", "configured": "...", "available": bool }` +- `PUT /api/v1/system/runtime`: + - Body: `{ "runtime": "docker"|"podman"|"container" }` + - Validates the choice, writes `active_profile` (or runtime setting) to `settings.yaml` + - Returns the updated runtime config + +## 2.3 — `ComputeOnboardingStatus` + `GET /system/status` + +Compute a struct describing the onboarding state of the machine: + +```go +type OnboardingStatus struct { + Initialized bool // ~/.scion/settings.yaml exists + IdentitySet bool // DevAuthConfig has username set (non-default) + RuntimeOK bool // a runtime is detected and reachable + HarnessesSeeded bool // at least one harness-config exists + ImagesPresent bool // at least one harness image is present (optional check) + HasWorkspace bool // at least one project exists + Complete bool // all required steps done +} +``` + +- `GET /api/v1/system/status` returns this struct as JSON +- Used by the frontend to detect first-run and resume the wizard + +Also wire up `PUT /api/v1/system/identity` here if not done in Phase 1: +- Body: `{ "displayName": "...", "email": "..." }` +- Writes to `DevAuthConfig`, returns updated identity + +## 2.4 — `POST /system/init` + +- Body: `{ "harnesses": ["claude", "gemini", "codex", "opencode"] }` (subset allowed) +- Calls `config.InitMachine()` (`pkg/config/init.go:548-620`) if not already initialized +- Seeds only the selected harness-configs (filter the full seed set) +- Returns `{ "ok": true, "initialized": true }` +- Idempotent: safe to call on an already-initialized machine (no-op or partial re-seed) + +--- + +## Route Registration + +Register all endpoints in the existing `MountHubAPI` function (`pkg/hub/web.go:518-527`), wrapped with `requireWorkstation`: + +```go +// System / onboarding endpoints (workstation only) +r.With(s.requireWorkstation).Get("/system/status", s.handleSystemStatus) +r.With(s.requireWorkstation).Get("/system/check", s.handleSystemCheck) +r.With(s.requireWorkstation).Get("/system/runtime", s.handleGetRuntime) +r.With(s.requireWorkstation).Put("/system/runtime", s.handlePutRuntime) +r.With(s.requireWorkstation).Post("/system/init", s.handleSystemInit) +r.With(s.requireWorkstation).Put("/system/identity", s.handlePutIdentity) +``` + +Put handler implementations in a new file: `pkg/hub/system_handlers.go` + +--- + +## Commit Instructions + +- One commit per logical unit is fine, or bundle as: `feat: add /system/* API endpoints for workstation onboarding (W1/W2)` +- Run `go build ./...` and `go vet ./...` before committing +- Do not open PRs — commit directly to `workstation-improvements` diff --git a/.tasks/phase-3-wizard-shell.md b/.tasks/phase-3-wizard-shell.md new file mode 100644 index 000000000..146f8ab29 --- /dev/null +++ b/.tasks/phase-3-wizard-shell.md @@ -0,0 +1,62 @@ +# Phase 3: Wizard Shell (Web UI + Launch Behavior) + +**Branch:** workstation-improvements +**Design docs:** `.design/workstation-onboarding-wizard.md` §3–5, `.design/workstation-onboarding.md` §7 +**Prereq:** Phase 2 complete (`/system/status`, `/system/check`, `/system/runtime`, `/system/init`, `/system/identity` all exist) +**Commit all changes to the current branch as you go.** + +--- + +## Overview + +Build the `/onboarding` route and Lit page with a step-by-step wizard, first-run detection, and auto-open behavior. The image and linked-grove steps are wired up in later phases; this phase builds the wizard shell and first 4 steps. + +--- + +## 3.1 — Route registration + +In `web/src/client/main.ts` (routes around line 127-158): +- Add a `/onboarding` route pointing to `scion-page-onboarding` +- First-run redirect: on app load, call `GET /api/v1/system/status`; if `!status.complete`, navigate to `/onboarding` (store result in `sessionStorage` as `onboardingStatus`) + +## 3.2 — `web/src/components/pages/onboarding.ts` + +Create a new Lit page `scion-page-onboarding`. It implements a linear wizard with these steps: + +| # | Step | Key actions | +|---|---|---| +| 0 | **Welcome / Identity** | Display name + email fields (prefilled from `GET /system/status`); `PUT /system/identity` on Next | +| 1 | **System Check** | Call `GET /system/check`; render pass/warn/fail pills; block Next on any "fail" result | +| 2 | **Runtime** | Show detected runtime from `GET /system/runtime`; allow switching; `PUT /system/runtime` on confirm | +| 3 | **Harnesses** | Checkbox list: Claude Code, Gemini, Codex, OpenCode; `POST /system/init` with selected harnesses | +| 4 | **Images** | Placeholder step (wired in Phase 4); show "coming soon" or skip button for now | +| 5 | **First Workspace** | Placeholder step (wired in Phase 5); show skip for now | +| 6 | **Done** | Mark `sessionStorage` `onboardingComplete = true`; "Go to Dashboard" button → navigate to `/` | + +State machine: +- Track `currentStep: number` in component state +- Each step has a `validate()` that must pass before advancing +- Steps 4 and 5 are skippable (show "Skip for now" button) +- Resumable: on mount, read `sessionStorage onboardingStatus`; advance past already-complete steps + +Styling: use existing Shoelace components (`sl-card`, `sl-button`, `sl-input`, `sl-checkbox`, `sl-progress-bar`). Look at `web/src/components/pages/admin-server-config.ts` and `invite.ts` for patterns. + +## 3.3 — Daemon launch behavior (D8) + +In `cmd/server_daemon.go` `printWorkstationQuickstart()` (line 361-384): +- Change the URL printed to include `/onboarding` when the machine is un-onboarded +- Print the URL **before** the process backgrounds itself (it currently already does this — verify) +- Add auto-open: after printing the URL, call `openBrowser(url)` if: stdin is a TTY (`term.IsTerminal(os.Stdin.Fd())`), and `SCION_NO_BROWSER` env var is not set +- `openBrowser` uses `exec.Command("open", url)` on macOS, `exec.Command("xdg-open", url)` on Linux, skips on other OS — no-op if command fails + +Use `GET /system/status` (or a simpler check: does `~/.scion/settings.yaml` exist?) to decide whether to point to `/onboarding` vs `/`. + +--- + +## Commit Instructions + +- `feat: add /onboarding wizard shell with steps 0-3 and done step (W1)` +- `feat: auto-open browser and print onboarding URL on server start (D8)` +- Run `go build ./...` and `go vet ./...` for the Go change before committing +- For the web changes: the project uses Vite/Lit; verify `web/` builds if a build step is available, otherwise at minimum check TypeScript compiles (`cd web && npx tsc --noEmit` or equivalent) +- Do not open PRs — commit directly to `workstation-improvements` diff --git a/.tasks/phase-4-images.md b/.tasks/phase-4-images.md new file mode 100644 index 000000000..d2c002ee1 --- /dev/null +++ b/.tasks/phase-4-images.md @@ -0,0 +1,87 @@ +# Phase 4: Harness-Aware Image Pull + Local Build (W4) + +**Branch:** workstation-improvements +**Design docs:** `.design/workstation-onboarding-wizard.md` §7, `.design/workstation-onboarding.md` §7 Phase 4 +**Prereq:** Phase 3 complete (wizard shell exists with placeholder image step) +**Commit all changes to the current branch as you go.** + +--- + +## Overview + +Add Go-native image pull (per-image progress via SSE) and a local build option. Wire the wizard Images step (step 4) to these endpoints. + +--- + +## 4.1 — `pkg/runtime/imagepull.go` + +New file. Implement: + +```go +// HarnessImages returns the image names needed for the given harness keys. +// Keys: "claude", "gemini", "codex", "opencode" +func HarnessImages(harnesses []string, registry string) []string + +// PullResult is the per-image result streamed to the caller. +type PullResult struct { + Image string + Status string // "queued" | "exists" | "pulling" | "done" | "error" + Error string +} + +// PullImages pulls the images for the given harnesses, streaming PullResult +// events to the provided callback. Uses the runtime's ImageExists / PullImage. +func PullImages(ctx context.Context, rt runtime.Runtime, harnesses []string, registry string, onEvent func(PullResult)) error +``` + +- Use `rt.ImageExists(ctx, image)` first; if true, emit `status: "exists"` and skip +- Otherwise emit `status: "pulling"`, call `rt.PullImage(ctx, image)`, emit `status: "done"` or `status: "error"` +- Pull sequentially (one at a time) to avoid overwhelming the daemon + +Look at `pkg/runtime/interface.go` for `ImageExists` and `PullImage` method signatures. + +Determine the image names per harness by looking at how `pull-containers.sh` (`image-build/scripts/pull-containers.sh`) resolves them, or at the harness-config seeds in `pkg/config/init.go`. + +## 4.2 — `POST /api/v1/system/images/pull` + +In `pkg/hub/system_handlers.go`: +- Body: `{ "harnesses": ["claude", ...] }` (subset of what was seeded in Phase 2) +- Reads `image_registry` from settings (the pre-seeded `ghcr.io/homebrew-scion` or user-configured value) +- Starts a background pull job, assigns a `jobId` (UUID) +- Streams `PullResult` events on the existing `/events` SSE stream under subject `system.images.` +- Returns immediately: `{ "jobId": "..." }` + +SSE event format (look at how other events are published in `pkg/hub/` for the pattern): +```json +{ "subject": "system.images.", "image": "...", "status": "pulling"|"done"|"exists"|"error", "error": "..." } +``` + +## 4.3 — `POST /api/v1/system/images/build` (local build option) + +- Body: `{ "harnesses": ["claude", ...] }` +- Only available after runtime is confirmed (check `GET /system/runtime` `available: true`) +- Shells out to `image-build/scripts/build-images.sh` (or the appropriate build script) with the correct flags +- Streams stdout/stderr lines as SSE events under `system.images.` with `{ "type": "log", "line": "..." }` +- Returns immediately: `{ "jobId": "..." }` + +If no build script is found or accessible from the Hub's working directory, return a clear error. + +## 4.4 — Wire up the wizard Images step (step 4) + +In `web/src/components/pages/onboarding.ts`: +- Replace the placeholder images step with real UI +- Call `GET /system/status` to know which harnesses were seeded; display them as a list +- "Pull images" button → `POST /system/images/pull`; subscribe to `/events` filtered by `system.images.` +- Render per-image status pills: queued → pulling (spinner) → done ✓ / exists ✓ / error ✗ +- "Build locally" toggle/button (shown only if runtime is available): calls `POST /system/images/build`; shows a collapsible log panel with streaming output +- "Skip for now" button always available — step is not blocking + +--- + +## Commit Instructions + +- `feat: add Go-native harness image pull with per-image SSE progress (W4)` +- `feat: add local image build option via POST /system/images/build (W4)` +- `feat: wire wizard images step to pull/build endpoints (W4)` +- Run `go build ./...` and `go vet ./...` before committing Go changes +- Do not open PRs — commit directly to `workstation-improvements` diff --git a/.tasks/phase-5-linked-groves.md b/.tasks/phase-5-linked-groves.md new file mode 100644 index 000000000..cfcfecdcf --- /dev/null +++ b/.tasks/phase-5-linked-groves.md @@ -0,0 +1,122 @@ +# Phase 5: Linked Groves via Directory Browser (W5) + +**Branch:** workstation-improvements +**Design docs:** `.design/linked-groves-ui.md`, `.design/workstation-onboarding.md` §7 Phase 5 +**Prereq:** Phase 3 complete (wizard shell exists with placeholder workspace step) +**Commit all changes to the current branch as you go.** + +--- + +## Overview + +Add a workstation-only (404-in-production) server-side directory browser and wire it into `project-create.ts` as a third project mode ("linked local directory"). Also wire the wizard's workspace step (step 5). + +--- + +## 5.1 — `pkg/hub/fs_safety.go` + +New file. Core path-safety logic: + +```go +// PathClass describes what kind of path was resolved. +type PathClass struct { + Resolved string // symlink-resolved absolute path + Exists bool + IsDir bool + IsGit bool // contains a .git dir/file + IsManaged bool // inside ~/.scion/projects/ or ~/.scion/groves/ + AlreadyLinked bool // already registered as a ProjectProvider LocalPath +} + +// ClassifyPath resolves and classifies a candidate path. +// managedRoot is computed via hubNativeProjectPath (handlers.go:3736-3752). +// It queries existing providers to detect already-linked paths. +func ClassifyPath(ctx context.Context, store Store, path, managedRoot string) (PathClass, error) +``` + +Key rules: +- Symlink-expand with `filepath.EvalSymlinks` +- `IsManaged`: resolved path has `managedRoot` as a prefix → **hard-fail** (D6) +- `AlreadyLinked`: scan `GetProjectProviders` for any provider with matching `LocalPath` + +## 5.2 — Fenced filesystem endpoints + +In `pkg/hub/system_handlers.go`, add (all wrapped with `requireWorkstation` + `assertLoopback`): + +### `GET /api/v1/system/fs/list?path=` +- If `path` is empty, default to `$HOME` +- Call `os.ReadDir(path)` after resolving the path +- Return: `{ "path": "/abs/path", "entries": [{ "name": "foo", "isDir": true, "isGit": bool }] }` +- Filter out hidden entries (starting with `.`) except `.git` (to detect git repos) +- Reject paths outside `$HOME` (safety: don't expose the whole filesystem) + +### `POST /api/v1/system/fs/mkdir` +- Body: `{ "parent": "/abs/path", "name": "new-folder" }` +- Validate parent is within `$HOME`, name has no path separators +- `os.Mkdir(filepath.Join(parent, name), 0755)` +- Returns: `{ "path": "/abs/path/new-folder" }` + +### `POST /api/v1/system/fs/validate-path` +- Body: `{ "path": "/abs/path" }` +- Calls `ClassifyPath`; returns `PathClass` as JSON +- If `IsManaged: true`, also set `"error": "This path is inside the Scion managed directory and cannot be linked"` +- Frontend uses this for pre-submit validation + +## 5.3 — `project-create.ts` changes + +File: `web/src/components/pages/project-create.ts` + +Add a third project creation mode: **"Add local directory (linked)"**. + +UI flow: +1. Mode selector gains a third tab/radio: "Local directory" +2. On selecting it, show a directory browser component (`scion-dir-browser`) that calls `GET /system/fs/list` as the user navigates +3. Directory browser features: + - Current path breadcrumb + - Entry list (folders only, clicking navigates in; files shown greyed out) + - "New folder" button → `POST /system/fs/mkdir` → refresh listing + - Selected path shown in a read-only input +4. On path selection, call `POST /system/fs/validate-path`; show inline error if managed-path overlap (D6) +5. Submit = two-step (D7): + a. `POST /api/v1/projects` to create the project (hub-native initially) + b. `POST /api/v1/projects/{id}/providers` with `{ "localPath": resolvedPath, "brokerId": embeddedBrokerID }` + - On step (b) failure: show error with "Retry" — don't delete the project (recoverable per D7) + - `embeddedBrokerID` from `GET /api/v1/system/status` (add a `embeddedBrokerID` field there) or from `GET /api/v1/brokers` filtered to the embedded one + +Add `scion-dir-browser` as a new component in `web/src/components/` — a simple Lit element that manages the navigation state and renders the listing. + +## 5.4 — Add `embeddedBrokerID` to system status + +In `pkg/hub/system_handlers.go` `handleSystemStatus`: +- Add `EmbeddedBrokerID string` to `OnboardingStatus` +- Populate it from `GetEmbeddedBrokerID()` (added in Phase 0.2) + +## 5.5 — Wire up wizard workspace step (step 5) + +In `web/src/components/pages/onboarding.ts`, replace the placeholder workspace step: +- Three cards: "Hub-native project", "Link a git repo", "Add local directory" +- Clicking "Add local directory" opens the same directory-browser flow from `project-create.ts` (reuse the `scion-dir-browser` component) +- On completion, advance to step 6 (Done) +- "Skip for now" remains available + +--- + +## Security Checklist (non-negotiable) + +- [ ] All `fs/*` and `system/*` endpoints are wrapped with `requireWorkstation` (404 in prod) +- [ ] `assertLoopback` on all `fs/*` endpoints +- [ ] `fs/list` rejects paths outside `$HOME` +- [ ] `fs/mkdir` validates no path separators in `name` +- [ ] `ClassifyPath` hard-fails on managed-path overlap +- [ ] Normal auth required (no anonymous access) + +--- + +## Commit Instructions + +- `feat: add path-safety helpers for workstation filesystem operations (W5)` +- `feat: add fenced fs/list, fs/mkdir, fs/validate-path endpoints (W5)` +- `feat: add linked-grove creation mode with directory browser to project-create (W5)` +- `feat: wire wizard workspace step to linked-grove and hub-native create flows (W5)` +- Run `go build ./...` and `go vet ./...` before committing Go changes +- Do not open PRs — commit directly to `workstation-improvements` diff --git a/.tasks/phase-6-polish.md b/.tasks/phase-6-polish.md new file mode 100644 index 000000000..354c5f993 --- /dev/null +++ b/.tasks/phase-6-polish.md @@ -0,0 +1,56 @@ +# Phase 6: Polish, Tests & Docs + +**Branch:** workstation-improvements +**Design docs:** `.design/workstation-onboarding.md` §7 Phase 6 +**Prereq:** Phases 0–5 complete +**Commit all changes to the current branch as you go.** + +--- + +## Overview + +Tests, doc updates, and any remaining polish to make the feature complete. + +--- + +## 6.1 — Go tests + +Write tests for the new Go code. Use the existing test patterns in the codebase (look at `pkg/hub/*_test.go` for handler test patterns). + +Key test cases: +- `requireWorkstation` middleware: returns 404 when `Workstation = false`, passes through when `true` +- `assertLoopback`: rejects non-loopback addresses +- `ClassifyPath` (`pkg/hub/fs_safety.go`): + - Managed path overlap → `IsManaged: true` + - Already-linked path → `AlreadyLinked: true` + - Normal path → clean classification +- `ComputeOnboardingStatus`: correctly reports each field +- `PUT /system/identity`: writes config and returns updated identity +- `POST /system/init`: idempotent; seeds only selected harnesses +- `POST /system/fs/validate-path`: returns error JSON on managed-path overlap +- `GET /system/fs/list`: rejects paths outside `$HOME` + +## 6.2 — README and docs updates + +In the repo README (`README.md`) and any relevant docs: +- Remove or update the "Sadly - not yet able to provide pre-built binaries" note (Homebrew tap exists now) +- Add a "Workstation Quick Start" section: install via brew, run `scion server start`, browser opens to `/onboarding` +- Update the Quick Start to reference the onboarding wizard +- In CLI help / `cmd/server_daemon.go` quickstart output: ensure "developer token" label is consistent + +## 6.3 — Any remaining polish + +- Verify the first-run redirect works end-to-end (un-initialized machine → `/onboarding`; initialized machine → `/`) +- Verify "Skip for now" on images and workspace steps correctly reaches Done +- Verify the two-step linked-grove create fails gracefully and shows retry UI +- Verify `scion server start` on a machine with no `~/.scion` auto-opens the browser to `/onboarding` +- Verify all fenced endpoints return 404 when `Workstation = false` (simulate prod mode) + +--- + +## Commit Instructions + +- `test: add tests for workstation onboarding API and security fencing` +- `docs: update README with workstation quick start and brew install` +- Run `go test ./pkg/hub/... ./pkg/config/... ./pkg/runtime/...` and confirm no failures +- Do not open PRs — commit directly to `workstation-improvements` From 130a9746e750570dd30246002e64f3ba13fd6b15 Mon Sep 17 00:00:00 2001 From: Scion Date: Sun, 31 May 2026 01:19:23 +0000 Subject: [PATCH 02/89] feat: add Workstation flag and workstation-only middleware to ServerConfig --- cmd/server_foreground.go | 1 + pkg/hub/server.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/cmd/server_foreground.go b/cmd/server_foreground.go index 83e70da9f..b7d43018a 100644 --- a/cmd/server_foreground.go +++ b/cmd/server_foreground.go @@ -912,6 +912,7 @@ func initHubServer(ctx context.Context, cfg *config.GlobalConfig, s store.Store, SoftDeleteRetainFiles: cfg.Hub.SoftDeleteRetainFiles, AdminMode: adminMode, MaintenanceMessage: maintenanceMessage, + Workstation: !productionMode, TelemetryDefault: cfg.TelemetryEnabled, TelemetryConfig: config.ConvertV1TelemetryToAPI(cfg.TelemetryConfig), BrokerAuthConfig: hub.DefaultBrokerAuthConfig(), diff --git a/pkg/hub/server.go b/pkg/hub/server.go index c1fa1237e..16f3e18e8 100644 --- a/pkg/hub/server.go +++ b/pkg/hub/server.go @@ -178,6 +178,9 @@ type ServerConfig struct { // TransportMinter mints transport-layer OIDC tokens for agents. // Nil when TransportMode == "none" or unset. TransportMinter TransportTokenMinter + // Workstation indicates non-production, single-user mode (e.g. local laptop). + // When true, /api/v1/system/* and other workstation-only endpoints are enabled. + Workstation bool } // MaintenanceConfig holds configuration for routine maintenance operation executors. @@ -565,6 +568,7 @@ type Server struct { hubID string // Unique hub instance ID for secret namespacing instanceID string // Unique per-process ID (uuid); affinity key for broker dispatch embeddedBrokerID string // Broker ID when running in hub+broker combo mode + workstation bool // True when running in workstation (non-production) mode scheduler *Scheduler // Unified scheduler for recurring tasks cleanupOnce sync.Once // Ensures CleanupResources runs only once @@ -659,6 +663,7 @@ func New(cfg ServerConfig, s store.Store) (*Server, error) { maintenance: NewMaintenanceState(cfg.AdminMode, cfg.MaintenanceMessage), hubID: cfg.HubID, instanceID: newInstanceID(), + workstation: cfg.Workstation, // Subsystem loggers agentLifecycleLog: logging.Subsystem("hub.agent-lifecycle"), @@ -1275,6 +1280,13 @@ func (s *Server) SetEmbeddedBrokerID(id string) { s.embeddedBrokerID = id } +// GetEmbeddedBrokerID returns the co-located broker ID, if any. +func (s *Server) GetEmbeddedBrokerID() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.embeddedBrokerID +} + // isEmbeddedBroker returns true if brokerID matches the co-located broker // running in the same process as the hub. func (s *Server) isEmbeddedBroker(brokerID string) bool { @@ -2611,6 +2623,31 @@ func (s *Server) corsMiddleware(next http.Handler) http.Handler { }) } +// requireWorkstation returns middleware that gates endpoints behind workstation mode. +// Returns 404 when the server is not running in workstation mode. +func (s *Server) requireWorkstation(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !s.workstation { + http.NotFound(w, r) + return + } + next.ServeHTTP(w, r) + }) +} + +// assertLoopback checks that the request originates from a loopback address. +func assertLoopback(r *http.Request) error { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + ip := net.ParseIP(host) + if ip == nil || !ip.IsLoopback() { + return fmt.Errorf("non-loopback request from %s", r.RemoteAddr) + } + return nil +} + // loggingMiddleware logs requests. func (s *Server) loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 43c5ac1bca38ea2eb8cde0c1dd1d2f6975f88d1c Mon Sep 17 00:00:00 2001 From: Scion Date: Sun, 31 May 2026 01:23:17 +0000 Subject: [PATCH 03/89] feat: make dev identity configurable, default to OS user (W2) --- cmd/server_foreground.go | 5 +++ pkg/config/hub_config.go | 21 ++++-------- pkg/config/settings_v1.go | 43 ++++++++++++------------ pkg/hub/auth.go | 6 ++-- pkg/hub/devauth.go | 63 +++++++++++++++++++++++++++++++---- pkg/hub/server.go | 7 +++- pkg/hub/system_identity.go | 68 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 167 insertions(+), 46 deletions(-) create mode 100644 pkg/hub/system_identity.go diff --git a/cmd/server_foreground.go b/cmd/server_foreground.go index b7d43018a..bb695d71a 100644 --- a/cmd/server_foreground.go +++ b/cmd/server_foreground.go @@ -913,6 +913,11 @@ func initHubServer(ctx context.Context, cfg *config.GlobalConfig, s store.Store, AdminMode: adminMode, MaintenanceMessage: maintenanceMessage, Workstation: !productionMode, + DevUserConfig: hub.DevUserConfig{ + Username: cfg.Auth.Username, + DisplayName: cfg.Auth.DisplayName, + Email: cfg.Auth.Email, + }, TelemetryDefault: cfg.TelemetryEnabled, TelemetryConfig: config.ConvertV1TelemetryToAPI(cfg.TelemetryConfig), BrokerAuthConfig: hub.DefaultBrokerAuthConfig(), diff --git a/pkg/config/hub_config.go b/pkg/config/hub_config.go index bff923b37..57c35d81e 100644 --- a/pkg/config/hub_config.go +++ b/pkg/config/hub_config.go @@ -210,41 +210,32 @@ type DevAuthConfig struct { // Transport holds transport-layer auth settings for agent outbound requests. // Controls which transport tokens the hub issues to agents (dispatch + refresh). Transport *TransportAuthConfig `json:"transport,omitempty" yaml:"transport,omitempty" koanf:"transport"` + // Username is the dev user's login name (defaults to OS username). + Username string `json:"username,omitempty" yaml:"username,omitempty" koanf:"username"` + // DisplayName is the dev user's display name (defaults to OS full name). + DisplayName string `json:"displayName,omitempty" yaml:"displayName,omitempty" koanf:"displayName"` + // Email is the dev user's email (defaults to @localhost). + Email string `json:"email,omitempty" yaml:"email,omitempty" koanf:"email"` } // TransportAuthConfig holds transport-layer (outer/platform) auth settings. -// This controls how agents authenticate to the platform guard (IAP or Cloud Run invoker) -// when making outbound requests to the hub. type TransportAuthConfig struct { - // Mode selects the transport auth mode: "none" (default), "cloudrun_invoker", or "iap". Mode string `json:"mode" yaml:"mode" koanf:"mode"` - // OIDCAudience is the OIDC audience for the transport token. - // For IAP: the IAP OAuth client ID. For cloudrun_invoker: the hub URL. - // Empty means derive from hub endpoint (cloudrun_invoker only). OIDCAudience string `json:"oidcAudience" yaml:"oidcAudience" koanf:"oidcAudience"` - // PlatformAuthSA is the email of the dedicated service account used for - // transport-layer auth. The hub's runtime SA must hold serviceAccountTokenCreator - // on this SA to impersonate it via the IAM Credentials API. PlatformAuthSA string `json:"platformAuthSA" yaml:"platformAuthSA" koanf:"platformAuthSA"` } // ProxyAuthConfig holds proxy authentication settings. type ProxyAuthConfig struct { - // Provider selects the proxy auth provider: "iap" or "header". Provider string `json:"provider" yaml:"provider" koanf:"provider"` - // IAP holds Google IAP-specific settings. IAP *IAPAuthConfig `json:"iap,omitempty" yaml:"iap,omitempty" koanf:"iap"` - // RequireTrustedProxyIP enables defense-in-depth IP allowlisting. RequireTrustedProxyIP bool `json:"requireTrustedProxyIP,omitempty" yaml:"requireTrustedProxyIP,omitempty" koanf:"requireTrustedProxyIP"` } // IAPAuthConfig holds Google IAP-specific settings. type IAPAuthConfig struct { - // Audience is the expected audience claim — MANDATORY for IAP. Audience string `json:"audience" yaml:"audience" koanf:"audience"` - // Issuer overrides the default IAP issuer (for testing). Issuer string `json:"issuer,omitempty" yaml:"issuer,omitempty" koanf:"issuer"` - // JWKSURL overrides the default IAP JWKS URL (for testing). JWKSURL string `json:"jwksURL,omitempty" yaml:"jwksURL,omitempty" koanf:"jwksURL"` } diff --git a/pkg/config/settings_v1.go b/pkg/config/settings_v1.go index 3ffc9215b..51b2d96c3 100644 --- a/pkg/config/settings_v1.go +++ b/pkg/config/settings_v1.go @@ -388,8 +388,6 @@ type V1DatabaseConfig struct { // V1AuthConfig holds authentication settings. type V1AuthConfig struct { - // Mode selects the exclusive human auth mode: "oauth" (default), "proxy", or "dev". - // In proxy mode, OAuth handlers are disabled; in dev mode, dev token auth is used. Mode string `json:"mode,omitempty" yaml:"mode,omitempty" koanf:"mode"` DevMode bool `json:"dev_mode,omitempty" yaml:"dev_mode,omitempty" koanf:"dev_mode"` DevToken string `json:"dev_token,omitempty" yaml:"dev_token,omitempty" koanf:"dev_token"` @@ -398,36 +396,27 @@ type V1AuthConfig struct { UserAccessMode string `json:"user_access_mode,omitempty" yaml:"user_access_mode,omitempty" koanf:"user_access_mode"` Proxy *V1ProxyConfig `json:"proxy,omitempty" yaml:"proxy,omitempty" koanf:"proxy"` Transport *V1TransportConfig `json:"transport,omitempty" yaml:"transport,omitempty" koanf:"transport"` + Username string `json:"username,omitempty" yaml:"username,omitempty" koanf:"username"` + DisplayName string `json:"display_name,omitempty" yaml:"display_name,omitempty" koanf:"display_name"` + Email string `json:"email,omitempty" yaml:"email,omitempty" koanf:"email"` } -// V1TransportConfig holds transport-layer auth settings for agent outbound requests. type V1TransportConfig struct { - // Mode selects the transport auth mode: "none" (default), "cloudrun_invoker", or "iap". - Mode string `json:"mode,omitempty" yaml:"mode,omitempty" koanf:"mode"` - // OIDCAudience is the OIDC audience for transport tokens. - OIDCAudience string `json:"oidc_audience,omitempty" yaml:"oidc_audience,omitempty" koanf:"oidc_audience"` - // PlatformAuthSA is the dedicated SA email used for transport-layer auth. + Mode string `json:"mode,omitempty" yaml:"mode,omitempty" koanf:"mode"` + OIDCAudience string `json:"oidc_audience,omitempty" yaml:"oidc_audience,omitempty" koanf:"oidc_audience"` PlatformAuthSA string `json:"platform_auth_sa,omitempty" yaml:"platform_auth_sa,omitempty" koanf:"platform_auth_sa"` } -// V1ProxyConfig holds proxy authentication settings (consulted when auth.mode == "proxy"). type V1ProxyConfig struct { - // Provider selects the proxy auth provider: "iap" or "header". - Provider string `json:"provider,omitempty" yaml:"provider,omitempty" koanf:"provider"` - // IAP holds Google IAP-specific settings. - IAP *V1IAPConfig `json:"iap,omitempty" yaml:"iap,omitempty" koanf:"iap"` - // RequireTrustedProxyIP enables defense-in-depth IP allowlisting. - RequireTrustedProxyIP bool `json:"require_trusted_proxy_ip,omitempty" yaml:"require_trusted_proxy_ip,omitempty" koanf:"require_trusted_proxy_ip"` + Provider string `json:"provider,omitempty" yaml:"provider,omitempty" koanf:"provider"` + IAP *V1IAPConfig `json:"iap,omitempty" yaml:"iap,omitempty" koanf:"iap"` + RequireTrustedProxyIP bool `json:"require_trusted_proxy_ip,omitempty" yaml:"require_trusted_proxy_ip,omitempty" koanf:"require_trusted_proxy_ip"` } -// V1IAPConfig holds Google IAP-specific settings. type V1IAPConfig struct { - // Audience is the expected audience claim — MANDATORY for IAP. Audience string `json:"audience,omitempty" yaml:"audience,omitempty" koanf:"audience"` - // Issuer overrides the default IAP issuer (for testing). - Issuer string `json:"issuer,omitempty" yaml:"issuer,omitempty" koanf:"issuer"` - // JWKSURL overrides the default IAP JWKS URL (for testing). - JWKSURL string `json:"jwks_url,omitempty" yaml:"jwks_url,omitempty" koanf:"jwks_url"` + Issuer string `json:"issuer,omitempty" yaml:"issuer,omitempty" koanf:"issuer"` + JWKSURL string `json:"jwks_url,omitempty" yaml:"jwks_url,omitempty" koanf:"jwks_url"` } // V1OAuthConfig holds OAuth provider configurations. @@ -1345,6 +1334,15 @@ func ConvertV1ServerToGlobalConfig(v1 *V1ServerConfig) *GlobalConfig { PlatformAuthSA: v1.Auth.Transport.PlatformAuthSA, } } + if v1.Auth.Username != "" { + gc.Auth.Username = v1.Auth.Username + } + if v1.Auth.DisplayName != "" { + gc.Auth.DisplayName = v1.Auth.DisplayName + } + if v1.Auth.Email != "" { + gc.Auth.Email = v1.Auth.Email + } } // OAuth config @@ -1504,6 +1502,9 @@ func ConvertGlobalToV1ServerConfig(gc *GlobalConfig) *V1ServerConfig { DevTokenFile: gc.Auth.TokenFile, AuthorizedDomains: gc.Auth.AuthorizedDomains, UserAccessMode: gc.Auth.UserAccessMode, + Username: gc.Auth.Username, + DisplayName: gc.Auth.DisplayName, + Email: gc.Auth.Email, } if gc.Auth.Proxy != nil { v1.Auth.Proxy = &V1ProxyConfig{ diff --git a/pkg/hub/auth.go b/pkg/hub/auth.go index 2011033cc..7fa96e34d 100644 --- a/pkg/hub/auth.go +++ b/pkg/hub/auth.go @@ -35,6 +35,8 @@ type AuthConfig struct { DevAuthEnabled bool // DevAuthToken is the valid development token DevAuthToken string + // DevUserCfg holds identity overrides for the development user + DevUserCfg DevUserConfig // AgentTokenSvc handles agent JWT validation AgentTokenSvc *AgentTokenService // UserTokenSvc handles user JWT validation @@ -80,6 +82,7 @@ const ( func UnifiedAuthMiddleware(cfg AuthConfig) func(http.Handler) http.Handler { // Parse trusted proxy CIDRs trustedNets := parseTrustedProxies(cfg.TrustedProxies) + devUser := NewDevUser(cfg.DevUserCfg) log := cfg.Logger if log == nil { log = slog.Default() @@ -226,7 +229,6 @@ func UnifiedAuthMiddleware(cfg AuthConfig) func(http.Handler) http.Handler { "invalid development token", nil) return } - devUser := &DevUser{id: DevUserID} ctx = context.WithValue(ctx, userContextKey{}, devUser) ctx = contextWithIdentity(ctx, devUser) ctx = contextWithAuthType(ctx, AuthTypeDevToken) @@ -257,7 +259,7 @@ func UnifiedAuthMiddleware(cfg AuthConfig) func(http.Handler) http.Handler { if cfg.UserTokenSvc == nil { // Fall back to dev auth if user tokens not configured if cfg.DevAuthEnabled && apiclient.ValidateDevToken(token, cfg.DevAuthToken) { - devUser := &DevUser{id: DevUserID} + devUser := devUser ctx = context.WithValue(ctx, userContextKey{}, devUser) ctx = contextWithIdentity(ctx, devUser) ctx = contextWithAuthType(ctx, AuthTypeDevToken) diff --git a/pkg/hub/devauth.go b/pkg/hub/devauth.go index 4168b243c..274e0c16a 100644 --- a/pkg/hub/devauth.go +++ b/pkg/hub/devauth.go @@ -16,8 +16,10 @@ package hub import ( "context" + "fmt" "log/slog" "net/http" + osuser "os/user" "strings" "github.com/GoogleCloudPlatform/scion/pkg/apiclient" @@ -27,9 +29,52 @@ import ( // Deterministic so that references in the database remain stable across restarts. const DevUserID = "be67fbc9-c869-5d43-b15d-c28ca3e8d355" +// DevUserConfig holds optional identity overrides for the development user. +type DevUserConfig struct { + Username string + DisplayName string + Email string +} + // DevUser represents the pseudo-user for development authentication. type DevUser struct { - id string + id string + username string + displayName string + email string +} + +// NewDevUser creates a DevUser with the stable UUID, applying config overrides +// and falling back to the current OS user for unset fields. +func NewDevUser(cfg DevUserConfig) *DevUser { + u := &DevUser{id: DevUserID} + + u.username = cfg.Username + u.displayName = cfg.DisplayName + u.email = cfg.Email + + if u.username == "" || u.displayName == "" { + if osUser, err := osuser.Current(); err == nil { + if u.username == "" { + u.username = osUser.Username + } + if u.displayName == "" { + u.displayName = osUser.Name + } + } + } + + if u.displayName == "" { + u.displayName = "Development User" + } + if u.username == "" { + u.username = "dev" + } + if u.email == "" { + u.email = fmt.Sprintf("%s@localhost", u.username) + } + + return u } // ID returns the user ID. @@ -38,11 +83,14 @@ func (u *DevUser) ID() string { return u.id } // Type returns the identity type ("dev"). func (u *DevUser) Type() string { return "dev" } +// Username returns the user's login name. +func (u *DevUser) Username() string { return u.username } + // Email returns the user email. -func (u *DevUser) Email() string { return "dev@localhost" } +func (u *DevUser) Email() string { return u.email } // DisplayName returns the user display name. -func (u *DevUser) DisplayName() string { return "Development User" } +func (u *DevUser) DisplayName() string { return u.displayName } // Role returns the user role. func (u *DevUser) Role() string { return "admin" } @@ -53,12 +101,13 @@ type userContextKey struct{} // DevAuthMiddleware creates middleware that validates development tokens. // If the token is valid, it adds a DevUser to the request context. // Use DevAuthMiddlewareWithDebug for verbose logging of auth failures. -func DevAuthMiddleware(validToken string) func(http.Handler) http.Handler { - return DevAuthMiddlewareWithDebug(validToken, false) +func DevAuthMiddleware(validToken string, userCfg DevUserConfig) func(http.Handler) http.Handler { + return DevAuthMiddlewareWithDebug(validToken, userCfg, false) } // DevAuthMiddlewareWithDebug creates middleware with optional debug logging. -func DevAuthMiddlewareWithDebug(validToken string, debug bool) func(http.Handler) http.Handler { +func DevAuthMiddlewareWithDebug(validToken string, userCfg DevUserConfig, debug bool) func(http.Handler) http.Handler { + devUser := NewDevUser(userCfg) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Skip auth for health endpoints @@ -141,7 +190,7 @@ func DevAuthMiddlewareWithDebug(validToken string, debug bool) func(http.Handler } // Add dev user context - ctx := context.WithValue(r.Context(), userContextKey{}, &DevUser{id: DevUserID}) + ctx := context.WithValue(r.Context(), userContextKey{}, devUser) next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/pkg/hub/server.go b/pkg/hub/server.go index 16f3e18e8..b9c57d4d7 100644 --- a/pkg/hub/server.go +++ b/pkg/hub/server.go @@ -181,6 +181,8 @@ type ServerConfig struct { // Workstation indicates non-production, single-user mode (e.g. local laptop). // When true, /api/v1/system/* and other workstation-only endpoints are enabled. Workstation bool + // DevUserConfig holds optional identity overrides for the development user. + DevUserConfig DevUserConfig } // MaintenanceConfig holds configuration for routine maintenance operation executors. @@ -875,6 +877,7 @@ func New(cfg ServerConfig, s store.Store) (*Server, error) { Mode: "production", DevAuthEnabled: cfg.DevAuthToken != "", DevAuthToken: cfg.DevAuthToken, + DevUserCfg: cfg.DevUserConfig, AgentTokenSvc: srv.agentTokenService, UserTokenSvc: srv.userTokenService, UATSvc: srv.uatService, @@ -884,7 +887,6 @@ func New(cfg ServerConfig, s store.Store) (*Server, error) { Debug: cfg.Debug, Logger: srv.authLog, } - // Wire the proxy user provisioner (wraps provisionUser with 60s cache) if cfg.ProxyAuth != nil { srv.authConfig.ProxyUserProvisioner = MakeProxyUserProvisioner(srv) } @@ -2547,6 +2549,9 @@ func (s *Server) registerRoutes() { // GitHub App webhook and setup callback (unauthenticated — uses webhook signature) s.mux.HandleFunc("/api/v1/webhooks/github", s.handleGitHubWebhook) s.mux.HandleFunc("/github-app/setup", s.handleGitHubAppSetup) + + // Workstation-only system endpoints + s.mux.Handle("/api/v1/system/identity", s.requireWorkstation(http.HandlerFunc(s.handleSystemIdentity))) } // applyMiddleware wraps the handler with middleware. diff --git a/pkg/hub/system_identity.go b/pkg/hub/system_identity.go new file mode 100644 index 000000000..9a00eef61 --- /dev/null +++ b/pkg/hub/system_identity.go @@ -0,0 +1,68 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hub + +import ( + "net/http" + + "github.com/GoogleCloudPlatform/scion/pkg/config" +) + +type systemIdentityRequest struct { + DisplayName string `json:"displayName"` + Email string `json:"email"` +} + +type systemIdentityResponse struct { + DisplayName string `json:"displayName"` + Email string `json:"email"` +} + +func (s *Server) handleSystemIdentity(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + MethodNotAllowed(w) + return + } + + if err := assertLoopback(r); err != nil { + writeError(w, http.StatusForbidden, ErrCodeForbidden, err.Error(), nil) + return + } + + var req systemIdentityRequest + if err := readJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "invalid request body", nil) + return + } + + if req.DisplayName != "" { + if err := config.UpdateSetting("", "server.auth.display_name", req.DisplayName, true); err != nil { + writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "failed to save display name", nil) + return + } + } + + if req.Email != "" { + if err := config.UpdateSetting("", "server.auth.email", req.Email, true); err != nil { + writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "failed to save email", nil) + return + } + } + + writeJSON(w, http.StatusOK, systemIdentityResponse{ + DisplayName: req.DisplayName, + Email: req.Email, + }) +} From 1a980005952855ade42486fa5a5af566619afbdb Mon Sep 17 00:00:00 2001 From: Scion Date: Sun, 31 May 2026 01:24:12 +0000 Subject: [PATCH 04/89] chore: relabel dev token as "developer token" in UI and docs (W3) --- cmd/server_daemon.go | 4 ++-- cmd/server_foreground.go | 4 ++-- docs-site/src/content/docs/hub-admin/auth.md | 2 +- docs-site/src/content/docs/hub-user/hosted-user.md | 2 +- docs-site/src/content/docs/reference/security.md | 4 ++-- web/src/components/pages/admin-server-config.ts | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/cmd/server_daemon.go b/cmd/server_daemon.go index 91bad4bad..ef12058e1 100644 --- a/cmd/server_daemon.go +++ b/cmd/server_daemon.go @@ -359,7 +359,7 @@ func runServerStatus(cmd *cobra.Command, args []string) error { } // printWorkstationQuickstart prints the first-run quickstart information -// including the dev token and web UI URL after a workstation-mode daemon starts. +// including the developer token and web UI URL after a workstation-mode daemon starts. func printWorkstationQuickstart(globalDir string, host string, wPort int, webEnabled, devAuth bool) { if webEnabled { displayHost := host @@ -376,7 +376,7 @@ func printWorkstationQuickstart(globalDir string, host string, wPort int, webEna token := strings.TrimSpace(string(data)) if token != "" { fmt.Println() - fmt.Println("Dev token (for CLI authentication):") + fmt.Println("Developer token (for CLI authentication):") fmt.Printf(" export SCION_DEV_TOKEN=%s\n", token) } } diff --git a/cmd/server_foreground.go b/cmd/server_foreground.go index bb695d71a..92ac59d69 100644 --- a/cmd/server_foreground.go +++ b/cmd/server_foreground.go @@ -424,7 +424,7 @@ func runServerStart(cmd *cobra.Command, args []string) error { log.Printf("Web UI: http://%s:%d", displayHost, webPort) } if devAuthToken != "" { - log.Printf("Dev token: export SCION_DEV_TOKEN=%s", devAuthToken) + log.Printf("Developer token: export SCION_DEV_TOKEN=%s", devAuthToken) } } @@ -792,7 +792,7 @@ func initDevAuth(cfg *config.GlobalConfig, globalDir string) (string, error) { os.Setenv("SCION_AUTH_TOKEN", devAuthToken) log.Println("WARNING: Development authentication enabled - not for production use") - log.Printf("Dev token: %s", devAuthToken) + log.Printf("Developer token: %s", devAuthToken) log.Printf("To authenticate CLI commands, run:") log.Printf(" export SCION_DEV_TOKEN=%s", devAuthToken) diff --git a/docs-site/src/content/docs/hub-admin/auth.md b/docs-site/src/content/docs/hub-admin/auth.md index ddc47dbe9..69e1da3ef 100644 --- a/docs-site/src/content/docs/hub-admin/auth.md +++ b/docs-site/src/content/docs/hub-admin/auth.md @@ -94,7 +94,7 @@ Or via environment variable: export SCION_SERVER_AUTH_DEVMODE=true ``` -### Using the Dev Token +### Using the Developer Token When the Hub starts with `devMode: true`, it writes the token to `~/.scion/dev-token`. - **CLI**: The `scion` CLI automatically looks for this file. - **Web**: The Web Dashboard automatically uses this token for the "Development User" login when `SCION_DEV_AUTH_ENABLED=true` is set. diff --git a/docs-site/src/content/docs/hub-user/hosted-user.md b/docs-site/src/content/docs/hub-user/hosted-user.md index 8f2017ea8..1260713b6 100644 --- a/docs-site/src/content/docs/hub-user/hosted-user.md +++ b/docs-site/src/content/docs/hub-user/hosted-user.md @@ -26,7 +26,7 @@ hub: ### Authentication -**Note:** Authentication is not required in workstation mode, it uses a machine specific dev-token, and is only listening on localhost. +**Note:** Authentication is not required in workstation mode, it uses a machine specific developer token, and is only listening on localhost. Once the endpoint is configured, authenticate your CLI: diff --git a/docs-site/src/content/docs/reference/security.md b/docs-site/src/content/docs/reference/security.md index c753a7f44..590db9692 100644 --- a/docs-site/src/content/docs/reference/security.md +++ b/docs-site/src/content/docs/reference/security.md @@ -17,7 +17,7 @@ Scion operates in multiple contexts, each with specific security requirements. A | **CLI (Hub Commands)** | Terminal | OAuth 2.0 + Device Flow | `~/.scion/credentials.json` | | **Agent (sciontool)** | Container | Hub-issued JWT | Env Var (`SCION_HUB_TOKEN`) | | **Runtime Broker** | Compute Node | HMAC Signature | `~/.scion/broker-credentials.json` | -| **Development** | Any | Dev Token (Bearer) | `~/.scion/dev-token` | +| **Development** | Any | Developer Token (Bearer) | `~/.scion/dev-token` | ### 1.2 User Authentication (OAuth 2.0) @@ -151,6 +151,6 @@ These are infrastructure-level secrets established during broker registration an ## 5. Development Security To facilitate local development, Scion provides a **Development Authentication** mode. -- **Dev Token**: A persistent token starting with `scion_dev_` stored in `~/.scion/dev-token`. +- **Developer Token**: A persistent token starting with `scion_dev_` stored in `~/.scion/dev-token`. - **Constraints**: Dev mode is disabled by default and requires `localhost` binding if TLS is not used. - **Warning**: The server logs clear warnings when operating in Dev Mode. diff --git a/web/src/components/pages/admin-server-config.ts b/web/src/components/pages/admin-server-config.ts index d5bd5c8de..cd4681691 100644 --- a/web/src/components/pages/admin-server-config.ts +++ b/web/src/components/pages/admin-server-config.ts @@ -1769,7 +1769,7 @@ export class ScionPageAdminServerConfig extends LitElement { Requires restart. NOT for production use.
- + ${this.authDevToken === '********' ? 'Value is masked. Clear to auto-generate.' From 0c60f3a2ef84a49ffa66bf20b70500f5a9b2ec73 Mon Sep 17 00:00:00 2001 From: Scion Date: Sun, 31 May 2026 01:32:03 +0000 Subject: [PATCH 05/89] feat: add /system/* API endpoints for workstation onboarding (W1/W2) Add workstation-only system endpoints gated by requireWorkstation: - GET /system/check: GatherDiagnostics returns structured results (git, runtime, config checks) with a ready flag - GET /system/runtime: detect available runtime, return configured profile - PUT /system/runtime: validate and persist runtime choice - GET /system/status: ComputeOnboardingStatus for first-run wizard - POST /system/init: call InitMachine with user-selected harnesses - PUT /system/identity: already wired in Phase 1 All endpoints require loopback origin and return JSON. --- pkg/hub/server.go | 4 + pkg/hub/system_handlers.go | 340 +++++++++++++++++++++++++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 pkg/hub/system_handlers.go diff --git a/pkg/hub/server.go b/pkg/hub/server.go index b9c57d4d7..1e1eb47f2 100644 --- a/pkg/hub/server.go +++ b/pkg/hub/server.go @@ -2552,6 +2552,10 @@ func (s *Server) registerRoutes() { // Workstation-only system endpoints s.mux.Handle("/api/v1/system/identity", s.requireWorkstation(http.HandlerFunc(s.handleSystemIdentity))) + s.mux.Handle("/api/v1/system/status", s.requireWorkstation(http.HandlerFunc(s.handleSystemStatus))) + s.mux.Handle("/api/v1/system/check", s.requireWorkstation(http.HandlerFunc(s.handleSystemCheck))) + s.mux.Handle("/api/v1/system/runtime", s.requireWorkstation(http.HandlerFunc(s.handleSystemRuntime))) + s.mux.Handle("/api/v1/system/init", s.requireWorkstation(http.HandlerFunc(s.handleSystemInit))) } // applyMiddleware wraps the handler with middleware. diff --git a/pkg/hub/system_handlers.go b/pkg/hub/system_handlers.go new file mode 100644 index 000000000..27cd120ff --- /dev/null +++ b/pkg/hub/system_handlers.go @@ -0,0 +1,340 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hub + +import ( + "context" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + + "github.com/GoogleCloudPlatform/scion/pkg/api" + "github.com/GoogleCloudPlatform/scion/pkg/config" + "github.com/GoogleCloudPlatform/scion/pkg/harness" + "github.com/GoogleCloudPlatform/scion/pkg/store" +) + +// --- 2.1: System Check (Doctor) --- + +type DiagnosticResult struct { + Name string `json:"name"` + Status string `json:"status"` // "pass", "warn", "fail" + Message string `json:"message"` +} + +type systemCheckResponse struct { + Results []DiagnosticResult `json:"results"` + Ready bool `json:"ready"` +} + +func GatherDiagnostics(ctx context.Context, cfg *config.VersionedSettings) []DiagnosticResult { + var results []DiagnosticResult + + // Check git + if _, err := exec.LookPath("git"); err != nil { + results = append(results, DiagnosticResult{Name: "git", Status: "fail", Message: "git not found in PATH"}) + } else if out, err := exec.CommandContext(ctx, "git", "--version").Output(); err != nil { + results = append(results, DiagnosticResult{Name: "git", Status: "warn", Message: "git found but version check failed"}) + } else { + results = append(results, DiagnosticResult{Name: "git", Status: "pass", Message: trimOutput(string(out))}) + } + + // Check runtime detection + detected, err := config.DetectLocalRuntime() + if err != nil { + results = append(results, DiagnosticResult{Name: "runtime", Status: "fail", Message: err.Error()}) + } else { + results = append(results, DiagnosticResult{Name: "runtime", Status: "pass", Message: fmt.Sprintf("detected runtime: %s", detected)}) + } + + // Check global dir exists + globalDir, err := config.GetGlobalDir() + if err != nil { + results = append(results, DiagnosticResult{Name: "config", Status: "fail", Message: "cannot determine global config directory"}) + } else if _, err := os.Stat(filepath.Join(globalDir, "settings.yaml")); os.IsNotExist(err) { + results = append(results, DiagnosticResult{Name: "config", Status: "warn", Message: "settings.yaml not found — run init"}) + } else { + results = append(results, DiagnosticResult{Name: "config", Status: "pass", Message: "settings.yaml found"}) + } + + return results +} + +func (s *Server) handleSystemCheck(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + MethodNotAllowed(w) + return + } + + if err := assertLoopback(r); err != nil { + writeError(w, http.StatusForbidden, ErrCodeForbidden, err.Error(), nil) + return + } + + results := GatherDiagnostics(r.Context(), nil) + + ready := true + for _, res := range results { + if res.Status == "fail" { + ready = false + break + } + } + + writeJSON(w, http.StatusOK, systemCheckResponse{ + Results: results, + Ready: ready, + }) +} + +// --- 2.2: Runtime GET/PUT --- + +type systemRuntimeResponse struct { + Detected string `json:"detected"` + Configured string `json:"configured"` + Available bool `json:"available"` +} + +type putRuntimeRequest struct { + Runtime string `json:"runtime"` +} + +var validRuntimes = map[string]bool{ + "docker": true, + "podman": true, + "container": true, +} + +func (s *Server) handleSystemRuntime(w http.ResponseWriter, r *http.Request) { + if err := assertLoopback(r); err != nil { + writeError(w, http.StatusForbidden, ErrCodeForbidden, err.Error(), nil) + return + } + + switch r.Method { + case http.MethodGet: + s.handleGetRuntime(w, r) + case http.MethodPut: + s.handlePutRuntime(w, r) + default: + MethodNotAllowed(w) + } +} + +func (s *Server) handleGetRuntime(w http.ResponseWriter, r *http.Request) { + detected, detectErr := config.DetectLocalRuntime() + available := detectErr == nil + + var configured string + globalDir, err := config.GetGlobalDir() + if err == nil { + if vs, loadErr := config.LoadSingleFileVersioned(globalDir); loadErr == nil && vs != nil { + configured = vs.ActiveProfile + if vs.Profiles != nil { + if profile, ok := vs.Profiles[vs.ActiveProfile]; ok { + configured = profile.Runtime + } + } + } + } + + writeJSON(w, http.StatusOK, systemRuntimeResponse{ + Detected: detected, + Configured: configured, + Available: available, + }) +} + +func (s *Server) handlePutRuntime(w http.ResponseWriter, r *http.Request) { + var req putRuntimeRequest + if err := readJSON(r, &req); err != nil { + BadRequest(w, "invalid request body") + return + } + + if !validRuntimes[req.Runtime] { + ValidationError(w, fmt.Sprintf("invalid runtime %q: must be docker, podman, or container", req.Runtime), nil) + return + } + + if err := config.UpdateSetting("", "active_profile", req.Runtime, true); err != nil { + writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "failed to save runtime setting", nil) + return + } + + writeJSON(w, http.StatusOK, systemRuntimeResponse{ + Detected: req.Runtime, + Configured: req.Runtime, + Available: true, + }) +} + +// --- 2.3: Onboarding Status --- + +type OnboardingStatus struct { + Initialized bool `json:"initialized"` + IdentitySet bool `json:"identitySet"` + RuntimeOK bool `json:"runtimeOK"` + HarnessesSeeded bool `json:"harnessesSeeded"` + ImagesPresent bool `json:"imagesPresent"` + HasWorkspace bool `json:"hasWorkspace"` + Complete bool `json:"complete"` +} + +func (s *Server) computeOnboardingStatus(ctx context.Context) OnboardingStatus { + var status OnboardingStatus + + globalDir, err := config.GetGlobalDir() + if err != nil { + return status + } + + // Initialized: settings.yaml exists + settingsPath := config.GetSettingsPath(globalDir) + status.Initialized = settingsPath != "" + + // IdentitySet: dev auth has a non-default username + if status.Initialized { + if vs, loadErr := config.LoadSingleFileVersioned(globalDir); loadErr == nil && vs != nil { + if vs.Server != nil && vs.Server.Auth != nil { + auth := vs.Server.Auth + status.IdentitySet = auth.DisplayName != "" || auth.Email != "" || auth.Username != "" + } + } + } + + // RuntimeOK: a runtime is detected and reachable + _, detectErr := config.DetectLocalRuntime() + status.RuntimeOK = detectErr == nil + + // HarnessesSeeded: at least one harness-config exists + harnessConfigsDir := filepath.Join(globalDir, "harness-configs") + if entries, err := os.ReadDir(harnessConfigsDir); err == nil { + for _, e := range entries { + if e.IsDir() { + status.HarnessesSeeded = true + break + } + } + } + + // ImagesPresent: best-effort check — skip for now (optional per spec) + status.ImagesPresent = false + + // HasWorkspace: at least one project in the store + if s.store != nil { + result, err := s.store.ListProjects(ctx, store.ProjectFilter{}, store.ListOptions{Limit: 1}) + if err == nil && result != nil && len(result.Items) > 0 { + status.HasWorkspace = true + } + } + + // Complete: all required steps done (ImagesPresent is optional) + status.Complete = status.Initialized && status.IdentitySet && status.RuntimeOK && status.HarnessesSeeded + + return status +} + +func (s *Server) handleSystemStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + MethodNotAllowed(w) + return + } + + if err := assertLoopback(r); err != nil { + writeError(w, http.StatusForbidden, ErrCodeForbidden, err.Error(), nil) + return + } + + status := s.computeOnboardingStatus(r.Context()) + writeJSON(w, http.StatusOK, status) +} + +// --- 2.4: System Init --- + +type systemInitRequest struct { + Harnesses []string `json:"harnesses"` +} + +type systemInitResponse struct { + OK bool `json:"ok"` + Initialized bool `json:"initialized"` +} + +func (s *Server) handleSystemInit(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + MethodNotAllowed(w) + return + } + + if err := assertLoopback(r); err != nil { + writeError(w, http.StatusForbidden, ErrCodeForbidden, err.Error(), nil) + return + } + + var req systemInitRequest + if err := readJSON(r, &req); err != nil { + BadRequest(w, "invalid request body") + return + } + + allowed := map[string]bool{ + "claude": true, + "gemini": true, + "codex": true, + "opencode": true, + } + + var selected []string + for _, name := range req.Harnesses { + if !allowed[name] { + ValidationError(w, fmt.Sprintf("unknown harness %q", name), nil) + return + } + selected = append(selected, name) + } + + if len(selected) == 0 { + ValidationError(w, "at least one harness must be specified", nil) + return + } + + // Build harness instances for selected names + var harnessInstances []api.Harness + for _, name := range selected { + harnessInstances = append(harnessInstances, harness.New(name)) + } + + if err := config.InitMachine(harnessInstances); err != nil { + writeError(w, http.StatusInternalServerError, ErrCodeInternalError, + fmt.Sprintf("initialization failed: %s", err.Error()), nil) + return + } + + writeJSON(w, http.StatusOK, systemInitResponse{ + OK: true, + Initialized: true, + }) +} + +// trimOutput removes a trailing newline from command output. +func trimOutput(s string) string { + if len(s) > 0 && s[len(s)-1] == '\n' { + return s[:len(s)-1] + } + return s +} From 9cef05e33c0bed959fb1d7b4c059cb9f68a4f258 Mon Sep 17 00:00:00 2001 From: Scion Date: Sun, 31 May 2026 01:38:35 +0000 Subject: [PATCH 06/89] feat: add /onboarding wizard shell with steps 0-3 and done step (W1) --- web/src/client/main.ts | 20 +- web/src/components/pages/onboarding.ts | 710 +++++++++++++++++++++++++ 2 files changed, 729 insertions(+), 1 deletion(-) create mode 100644 web/src/components/pages/onboarding.ts diff --git a/web/src/client/main.ts b/web/src/client/main.ts index afc0892e3..1b7aeefac 100644 --- a/web/src/client/main.ts +++ b/web/src/client/main.ts @@ -128,6 +128,7 @@ interface RouteConfig { const ROUTES: RouteConfig[] = [ { pattern: /^\/login$/, tag: 'scion-login-page', load: () => import('../components/pages/login.js') }, { pattern: /^\/invite$/, tag: 'scion-page-invite', load: () => import('../components/pages/invite.js') }, + { pattern: /^\/onboarding$/, tag: 'scion-page-onboarding', load: () => import('../components/pages/onboarding.js') }, { pattern: /^\/$/, tag: 'scion-page-home', load: () => import('../components/pages/home.js') }, { pattern: /^\/projects$/, tag: 'scion-page-projects', load: () => import('../components/pages/projects.js') }, { pattern: /^\/agents$/, tag: 'scion-page-agents', load: () => import('../components/pages/agents.js') }, @@ -165,7 +166,7 @@ const ROUTES: RouteConfig[] = [ /** * Routes that render without the app shell (full-page layout) */ -const STANDALONE_ROUTES = new Set(['scion-login-page', 'scion-page-invite']); +const STANDALONE_ROUTES = new Set(['scion-login-page', 'scion-page-invite', 'scion-page-onboarding']); /** * Routes that render inside the profile shell instead of the main app shell @@ -222,6 +223,23 @@ async function init(): Promise { console.info('[Scion] Components defined, setting up router...'); + // First-run redirect: if the system hasn't completed onboarding, navigate to /onboarding + const skipRedirectPaths = ['/onboarding', '/login', '/invite']; + if (!skipRedirectPaths.includes(window.location.pathname)) { + try { + const statusRes = await fetch('/api/v1/system/status', { credentials: 'include' }); + if (statusRes.ok) { + const status = await statusRes.json(); + if (!status.complete) { + sessionStorage.setItem('onboardingStatus', JSON.stringify(status)); + window.history.replaceState({}, '', '/onboarding'); + } + } + } catch { + // System status endpoint unavailable (non-workstation mode) — skip redirect + } + } + // Render the initial page based on current URL await renderRoute(window.location.pathname); diff --git a/web/src/components/pages/onboarding.ts b/web/src/components/pages/onboarding.ts new file mode 100644 index 000000000..32ac5ce31 --- /dev/null +++ b/web/src/components/pages/onboarding.ts @@ -0,0 +1,710 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { LitElement, html, css, nothing } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; + +import { apiFetch, extractApiError } from '../../client/api.js'; + +const ONBOARDING_STATUS_KEY = 'onboardingStatus'; +const TOTAL_STEPS = 6; + +interface OnboardingStatus { + initialized: boolean; + identitySet: boolean; + runtimeOK: boolean; + harnessesSeeded: boolean; + imagesPresent: boolean; + hasWorkspace: boolean; + complete: boolean; +} + +interface DiagnosticResult { + name: string; + status: 'pass' | 'warn' | 'fail'; + message: string; +} + +interface SystemCheckResponse { + results: DiagnosticResult[]; + ready: boolean; +} + +interface RuntimeResponse { + detected: string; + configured: string; + available: boolean; +} + +@customElement('scion-page-onboarding') +export class ScionPageOnboarding extends LitElement { + @state() private currentStep = 0; + @state() private loading = true; + @state() private stepLoading = false; + @state() private error: string | null = null; + + // Step 0: Identity + @state() private displayName = ''; + @state() private email = ''; + + // Step 1: System Check + @state() private checkResults: DiagnosticResult[] = []; + @state() private checkReady = false; + + // Step 2: Runtime + @state() private detectedRuntime = ''; + @state() private configuredRuntime = ''; + @state() private selectedRuntime = ''; + + // Step 3: Harnesses + @state() private selectedHarnesses = new Set(); + + static override styles = css` + :host { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: var(--scion-bg, #f8fafc); + font-family: var(--scion-font, system-ui, -apple-system, sans-serif); + } + + .wizard { + background: var(--scion-surface, #ffffff); + border: 1px solid var(--scion-border, #e2e8f0); + border-radius: var(--scion-radius-lg, 0.75rem); + padding: 2.5rem; + max-width: 36rem; + width: 100%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + } + + .progress { + margin-bottom: 2rem; + } + + .step-label { + font-size: 0.8rem; + color: var(--scion-text-muted, #64748b); + margin-bottom: 0.5rem; + } + + h1 { + font-size: 1.5rem; + font-weight: 700; + color: var(--scion-text, #1e293b); + margin: 0 0 0.5rem 0; + } + + h2 { + font-size: 1.25rem; + font-weight: 600; + color: var(--scion-text, #1e293b); + margin: 0 0 0.25rem 0; + } + + p { + color: var(--scion-text-muted, #64748b); + margin: 0 0 1.5rem 0; + line-height: 1.5; + } + + .form-group { + margin-bottom: 1.25rem; + } + + .form-group label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: var(--scion-text, #1e293b); + margin-bottom: 0.375rem; + } + + .footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid var(--scion-border, #e2e8f0); + } + + .footer-right { + display: flex; + gap: 0.5rem; + } + + .error-banner { + background: var(--sl-color-danger-50, #fef2f2); + color: var(--sl-color-danger-700, #b91c1c); + border: 1px solid var(--sl-color-danger-200, #fecaca); + border-radius: var(--scion-radius, 0.5rem); + padding: 0.75rem 1rem; + margin-bottom: 1rem; + font-size: 0.875rem; + } + + .check-results { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1rem; + } + + .check-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-radius: var(--scion-radius, 0.5rem); + border: 1px solid var(--scion-border, #e2e8f0); + } + + .check-item .name { + font-weight: 500; + color: var(--scion-text, #1e293b); + min-width: 5rem; + } + + .check-item .message { + color: var(--scion-text-muted, #64748b); + font-size: 0.875rem; + flex: 1; + } + + .pill { + display: inline-block; + font-size: 0.75rem; + font-weight: 600; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + text-transform: uppercase; + letter-spacing: 0.025em; + } + + .pill.pass { + background: var(--sl-color-success-100, #dcfce7); + color: var(--sl-color-success-700, #15803d); + } + + .pill.warn { + background: var(--sl-color-warning-100, #fef9c3); + color: var(--sl-color-warning-700, #a16207); + } + + .pill.fail { + background: var(--sl-color-danger-100, #fee2e2); + color: var(--sl-color-danger-700, #b91c1c); + } + + .runtime-info { + padding: 1rem; + border-radius: var(--scion-radius, 0.5rem); + border: 1px solid var(--scion-border, #e2e8f0); + margin-bottom: 1.25rem; + } + + .runtime-detected { + font-size: 0.875rem; + color: var(--scion-text-muted, #64748b); + margin-bottom: 0.25rem; + } + + .runtime-detected strong { + color: var(--scion-text, #1e293b); + } + + .harness-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1rem; + } + + .harness-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-radius: var(--scion-radius, 0.5rem); + border: 1px solid var(--scion-border, #e2e8f0); + } + + .harness-item .harness-name { + font-weight: 500; + color: var(--scion-text, #1e293b); + } + + .placeholder-content { + text-align: center; + padding: 2rem 1rem; + } + + .placeholder-content sl-icon { + font-size: 2.5rem; + color: var(--scion-text-muted, #64748b); + margin-bottom: 1rem; + } + + .done-content { + text-align: center; + padding: 1rem 0; + } + + .done-content sl-icon { + font-size: 3rem; + color: var(--sl-color-success-500, #22c55e); + margin-bottom: 1rem; + } + + .loading-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 2rem 0; + } + + .loading-state sl-spinner { + font-size: 2rem; + } + `; + + override connectedCallback(): void { + super.connectedCallback(); + void this.initialize(); + } + + private async initialize(): Promise { + try { + const stored = sessionStorage.getItem(ONBOARDING_STATUS_KEY); + let status: OnboardingStatus | null = null; + + if (stored) { + try { + status = JSON.parse(stored) as OnboardingStatus; + } catch { /* ignore parse errors */ } + } + + if (!status) { + const res = await apiFetch('/api/v1/system/status'); + if (res.ok) { + status = (await res.json()) as OnboardingStatus; + sessionStorage.setItem(ONBOARDING_STATUS_KEY, JSON.stringify(status)); + } + } + + // Resume: advance past completed steps + if (status) { + if (status.identitySet && this.currentStep === 0) this.currentStep = 1; + if (status.runtimeOK && this.currentStep <= 2) this.currentStep = Math.max(this.currentStep, 3); + if (status.harnessesSeeded && this.currentStep <= 3) this.currentStep = Math.max(this.currentStep, 4); + } + + // Prefill identity from current user + try { + const meRes = await apiFetch('/api/v1/auth/me'); + if (meRes.ok) { + const me = (await meRes.json()) as { displayName?: string; email?: string }; + if (me.displayName) this.displayName = me.displayName; + if (me.email) this.email = me.email; + } + } catch { /* ignore */ } + } finally { + this.loading = false; + } + } + + override render() { + if (this.loading) { + return html` +
+
+ +

Loading...

+
+
+ `; + } + + return html` +
+ ${this.currentStep < TOTAL_STEPS ? html` +
+
Step ${this.currentStep + 1} of ${TOTAL_STEPS}
+ +
+ ` : nothing} + + ${this.error ? html`
${this.error}
` : nothing} + + ${this.renderStep()} +
+ `; + } + + private renderStep() { + switch (this.currentStep) { + case 0: return this.renderIdentity(); + case 1: return this.renderSystemCheck(); + case 2: return this.renderRuntime(); + case 3: return this.renderHarnesses(); + case 4: return this.renderImagesPlaceholder(); + case 5: return this.renderWorkspacePlaceholder(); + case 6: return this.renderDone(); + default: return nothing; + } + } + + // ── Step 0: Welcome / Identity ── + + private renderIdentity() { + return html` +

Welcome to Scion

+

Let's get your workstation set up. First, tell us who you are.

+ +
+ + { this.displayName = (e.target as HTMLInputElement).value; }} + > +
+ +
+ + { this.email = (e.target as HTMLInputElement).value; }} + > +
+ + + `; + } + + private async handleIdentityNext(): Promise { + if (!this.displayName.trim() && !this.email.trim()) { + this.error = 'Please enter at least a display name or email.'; + return; + } + + this.error = null; + this.stepLoading = true; + try { + const res = await apiFetch('/api/v1/system/identity', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ displayName: this.displayName.trim(), email: this.email.trim() }), + }); + if (!res.ok) { + this.error = await extractApiError(res, 'Failed to save identity'); + return; + } + this.currentStep = 1; + void this.loadSystemCheck(); + } finally { + this.stepLoading = false; + } + } + + // ── Step 1: System Check ── + + private renderSystemCheck() { + return html` +

System Check

+

Verifying your environment is ready.

+ + ${this.stepLoading ? html` +
+ +

Running checks...

+
+ ` : html` +
+ ${this.checkResults.map(r => html` +
+ ${r.status} + ${r.name} + ${r.message} +
+ `)} +
+ `} + + + `; + } + + private async loadSystemCheck(): Promise { + this.stepLoading = true; + this.error = null; + try { + const res = await apiFetch('/api/v1/system/check'); + if (!res.ok) { + this.error = await extractApiError(res, 'System check failed'); + return; + } + const data = (await res.json()) as SystemCheckResponse; + this.checkResults = data.results; + this.checkReady = data.ready; + } catch { + this.error = 'Failed to connect to the server.'; + } finally { + this.stepLoading = false; + } + } + + // ── Step 2: Runtime ── + + private renderRuntime() { + return html` +

Container Runtime

+

Select the container runtime for your workstation.

+ + ${this.stepLoading ? html` +
+ +

Detecting runtime...

+
+ ` : html` +
+
+ Detected: ${this.detectedRuntime || 'none'} +
+ ${this.configuredRuntime ? html` +
+ Currently configured: ${this.configuredRuntime} +
+ ` : nothing} +
+ +
+ + { this.selectedRuntime = (e.target as HTMLSelectElement).value; }} + > + Docker + Podman + Container (generic) + +
+ `} + + + `; + } + + private async loadRuntime(): Promise { + this.stepLoading = true; + this.error = null; + try { + const res = await apiFetch('/api/v1/system/runtime'); + if (!res.ok) { + this.error = await extractApiError(res, 'Failed to load runtime info'); + return; + } + const data = (await res.json()) as RuntimeResponse; + this.detectedRuntime = data.detected; + this.configuredRuntime = data.configured; + this.selectedRuntime = data.configured || data.detected || 'docker'; + } catch { + this.error = 'Failed to connect to the server.'; + } finally { + this.stepLoading = false; + } + } + + private async handleRuntimeNext(): Promise { + this.error = null; + this.stepLoading = true; + try { + const res = await apiFetch('/api/v1/system/runtime', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ runtime: this.selectedRuntime }), + }); + if (!res.ok) { + this.error = await extractApiError(res, 'Failed to save runtime'); + return; + } + this.currentStep = 3; + } finally { + this.stepLoading = false; + } + } + + // ── Step 3: Harnesses ── + + private renderHarnesses() { + const harnesses = [ + { id: 'claude', label: 'Claude Code' }, + { id: 'gemini', label: 'Gemini' }, + { id: 'codex', label: 'Codex' }, + { id: 'opencode', label: 'OpenCode' }, + ]; + + return html` +

AI Harnesses

+

Select which AI coding harnesses to configure.

+ +
+ ${harnesses.map(h => html` +
+ { + const checked = (e.target as HTMLInputElement).checked; + const next = new Set(this.selectedHarnesses); + if (checked) { next.add(h.id); } else { next.delete(h.id); } + this.selectedHarnesses = next; + }} + >${h.label} +
+ `)} +
+ + + `; + } + + private async handleHarnessesNext(): Promise { + this.error = null; + this.stepLoading = true; + try { + const res = await apiFetch('/api/v1/system/init', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ harnesses: [...this.selectedHarnesses] }), + }); + if (!res.ok) { + this.error = await extractApiError(res, 'Failed to initialize harnesses'); + return; + } + this.currentStep = 4; + } finally { + this.stepLoading = false; + } + } + + // ── Step 4: Images (placeholder) ── + + private renderImagesPlaceholder() { + return html` +

Container Images

+
+ +

Image management will be available in a future update.

+
+ + + `; + } + + // ── Step 5: First Workspace (placeholder) ── + + private renderWorkspacePlaceholder() { + return html` +

First Workspace

+
+ +

Workspace creation will be available in a future update.

+
+ + + `; + } + + // ── Step 6: Done ── + + private renderDone() { + sessionStorage.setItem('onboardingComplete', 'true'); + sessionStorage.removeItem(ONBOARDING_STATUS_KEY); + + return html` +
+ +

You're All Set

+

Your workstation is configured and ready to use.

+ { window.location.href = '/'; }}> + Go to Dashboard + +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'scion-page-onboarding': ScionPageOnboarding; + } +} From d67b84c4617c0032f720c6fbc9cf65189690889d Mon Sep 17 00:00:00 2001 From: Scion Date: Sun, 31 May 2026 01:40:05 +0000 Subject: [PATCH 07/89] feat: auto-open browser and print onboarding URL on server start (D8) --- cmd/server_daemon.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/cmd/server_daemon.go b/cmd/server_daemon.go index ef12058e1..9a7f99f24 100644 --- a/cmd/server_daemon.go +++ b/cmd/server_daemon.go @@ -25,6 +25,7 @@ import ( "github.com/GoogleCloudPlatform/scion/pkg/config" "github.com/GoogleCloudPlatform/scion/pkg/daemon" + "github.com/GoogleCloudPlatform/scion/pkg/util" "github.com/spf13/cobra" ) @@ -360,13 +361,27 @@ func runServerStatus(cmd *cobra.Command, args []string) error { // printWorkstationQuickstart prints the first-run quickstart information // including the developer token and web UI URL after a workstation-mode daemon starts. +// When the machine hasn't been onboarded yet, it prints and opens the /onboarding URL. func printWorkstationQuickstart(globalDir string, host string, wPort int, webEnabled, devAuth bool) { if webEnabled { displayHost := host if displayHost == "0.0.0.0" || displayHost == "" { displayHost = "127.0.0.1" } - fmt.Printf("Web UI: http://%s:%d\n", displayHost, wPort) + + // Point to /onboarding when the machine hasn't been set up yet + path := "" + if config.GetSettingsPath(globalDir) == "" { + path = "/onboarding" + } + + url := fmt.Sprintf("http://%s:%d%s", displayHost, wPort, path) + fmt.Printf("Web UI: %s\n", url) + + // Auto-open the browser in interactive terminals + if os.Getenv("SCION_NO_BROWSER") == "" && util.IsTerminal() && !util.IsHeadlessEnvironment() { + _ = util.OpenBrowser(url) + } } if devAuth { From 9d7c25761d9f4a8e04f536dc2e5f6e816c8b9fb8 Mon Sep 17 00:00:00 2001 From: Scion Date: Sun, 31 May 2026 01:46:24 +0000 Subject: [PATCH 08/89] feat: add Go-native harness image pull with per-image SSE progress (W4) --- pkg/hub/events.go | 15 ++++--- pkg/hub/server.go | 1 + pkg/hub/system_handlers.go | 62 +++++++++++++++++++++++++++ pkg/runtime/imagepull.go | 85 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 8 deletions(-) create mode 100644 pkg/runtime/imagepull.go diff --git a/pkg/hub/events.go b/pkg/hub/events.go index 2d644ce09..92cad5e36 100644 --- a/pkg/hub/events.go +++ b/pkg/hub/events.go @@ -40,16 +40,9 @@ type EventPublisher interface { PublishUserMessage(ctx context.Context, msg *store.Message) PublishAllowListChanged(ctx context.Context, action string, email string) PublishInviteChanged(ctx context.Context, action string, inviteID string, codePrefix string) - // PublishDispatchDone emits a slim completion event on - // broker.dispatch..done so the originator's subscription wakes - // and reads the result from the dispatch row (design §6.3). PublishDispatchDone(ctx context.Context, dispatchID string) - // Subscribe returns a channel that receives events matching the given - // subject patterns, along with an unsubscribe function. Patterns use - // NATS-style wildcards: '*' matches a single token, '>' matches the - // remainder. The returned channel is buffered; implementations may drop - // events on a full buffer (backpressure). Subscribe(patterns ...string) (<-chan Event, func()) + PublishRaw(subject string, data interface{}) Close() } @@ -71,6 +64,7 @@ func (noopEventPublisher) PublishUserMessage(_ context.Context, _ *store.Message func (noopEventPublisher) PublishAllowListChanged(_ context.Context, _, _ string) {} func (noopEventPublisher) PublishInviteChanged(_ context.Context, _, _, _ string) {} func (noopEventPublisher) PublishDispatchDone(_ context.Context, _ string) {} +func (noopEventPublisher) PublishRaw(_ string, _ interface{}) {} func (noopEventPublisher) Close() {} // Subscribe on the no-op publisher returns a nil channel (which blocks forever @@ -313,6 +307,11 @@ func (p *ChannelEventPublisher) publish(subject string, event interface{}) { } } +// PublishRaw publishes an arbitrary event on the given subject. +func (p *ChannelEventPublisher) PublishRaw(subject string, data interface{}) { + p.publish(subject, data) +} + // Close marks the publisher as closed and closes all subscriber channels. func (p *ChannelEventPublisher) Close() { p.mu.Lock() diff --git a/pkg/hub/server.go b/pkg/hub/server.go index 1e1eb47f2..4323640b4 100644 --- a/pkg/hub/server.go +++ b/pkg/hub/server.go @@ -2556,6 +2556,7 @@ func (s *Server) registerRoutes() { s.mux.Handle("/api/v1/system/check", s.requireWorkstation(http.HandlerFunc(s.handleSystemCheck))) s.mux.Handle("/api/v1/system/runtime", s.requireWorkstation(http.HandlerFunc(s.handleSystemRuntime))) s.mux.Handle("/api/v1/system/init", s.requireWorkstation(http.HandlerFunc(s.handleSystemInit))) + s.mux.Handle("/api/v1/system/images/pull", s.requireWorkstation(http.HandlerFunc(s.handleSystemImagesPull))) } // applyMiddleware wraps the handler with middleware. diff --git a/pkg/hub/system_handlers.go b/pkg/hub/system_handlers.go index 27cd120ff..4467a0ad6 100644 --- a/pkg/hub/system_handlers.go +++ b/pkg/hub/system_handlers.go @@ -25,6 +25,7 @@ import ( "github.com/GoogleCloudPlatform/scion/pkg/api" "github.com/GoogleCloudPlatform/scion/pkg/config" "github.com/GoogleCloudPlatform/scion/pkg/harness" + "github.com/GoogleCloudPlatform/scion/pkg/runtime" "github.com/GoogleCloudPlatform/scion/pkg/store" ) @@ -331,6 +332,67 @@ func (s *Server) handleSystemInit(w http.ResponseWriter, r *http.Request) { }) } +// --- 4.1: Image Pull --- + +type imagePullRequest struct { + Harnesses []string `json:"harnesses"` +} + +type imagePullResponse struct { + JobID string `json:"jobId"` +} + +func (s *Server) handleSystemImagesPull(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + MethodNotAllowed(w) + return + } + + if err := assertLoopback(r); err != nil { + writeError(w, http.StatusForbidden, ErrCodeForbidden, err.Error(), nil) + return + } + + var req imagePullRequest + if err := readJSON(r, &req); err != nil { + BadRequest(w, "invalid request body") + return + } + + if len(req.Harnesses) == 0 { + ValidationError(w, "at least one harness must be specified", nil) + return + } + + allowed := map[string]bool{"claude": true, "gemini": true, "codex": true, "opencode": true} + for _, h := range req.Harnesses { + if !allowed[h] { + ValidationError(w, fmt.Sprintf("unknown harness %q", h), nil) + return + } + } + + var registry string + globalDir, err := config.GetGlobalDir() + if err == nil { + if vs, loadErr := config.LoadSingleFileVersioned(globalDir); loadErr == nil && vs != nil { + registry = vs.ResolveImageRegistry("") + } + } + + jobID := api.NewUUID() + + rt := runtime.GetRuntime("", "") + + go func() { + _ = runtime.PullImages(context.Background(), rt, req.Harnesses, registry, func(pr runtime.PullResult) { + s.events.PublishRaw("system.images."+jobID, pr) + }) + }() + + writeJSON(w, http.StatusOK, imagePullResponse{JobID: jobID}) +} + // trimOutput removes a trailing newline from command output. func trimOutput(s string) string { if len(s) > 0 && s[len(s)-1] == '\n' { diff --git a/pkg/runtime/imagepull.go b/pkg/runtime/imagepull.go new file mode 100644 index 000000000..a7c80ed19 --- /dev/null +++ b/pkg/runtime/imagepull.go @@ -0,0 +1,85 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import ( + "context" + "fmt" +) + +var harnessImageMap = map[string]string{ + "claude": "scion-claude:latest", + "gemini": "scion-gemini:latest", + "codex": "scion-codex:latest", + "opencode": "scion-opencode:latest", +} + +// HarnessImages returns the fully qualified image names needed for the given harness keys. +func HarnessImages(harnesses []string, registry string) []string { + var images []string + for _, h := range harnesses { + base, ok := harnessImageMap[h] + if !ok { + continue + } + if registry != "" { + images = append(images, registry+"/"+base) + } else { + images = append(images, base) + } + } + return images +} + +// PullResult is the per-image result streamed to the caller. +type PullResult struct { + Image string `json:"image"` + Status string `json:"status"` // "queued" | "exists" | "pulling" | "done" | "error" + Error string `json:"error,omitempty"` +} + +// PullImages pulls the images for the given harnesses, streaming PullResult +// events to the provided callback. Images are pulled sequentially. +func PullImages(ctx context.Context, rt Runtime, harnesses []string, registry string, onEvent func(PullResult)) error { + images := HarnessImages(harnesses, registry) + if len(images) == 0 { + return fmt.Errorf("no valid harness images to pull") + } + + for _, img := range images { + onEvent(PullResult{Image: img, Status: "queued"}) + } + + for _, img := range images { + exists, err := rt.ImageExists(ctx, img) + if err != nil { + onEvent(PullResult{Image: img, Status: "error", Error: err.Error()}) + continue + } + if exists { + onEvent(PullResult{Image: img, Status: "exists"}) + continue + } + + onEvent(PullResult{Image: img, Status: "pulling"}) + if err := rt.PullImage(ctx, img); err != nil { + onEvent(PullResult{Image: img, Status: "error", Error: err.Error()}) + continue + } + onEvent(PullResult{Image: img, Status: "done"}) + } + + return nil +} From 54ddcfdabaaa77abd0f660fcad3a9bb40232629b Mon Sep 17 00:00:00 2001 From: Scion Date: Sun, 31 May 2026 01:47:13 +0000 Subject: [PATCH 09/89] feat: add local image build option via POST /system/images/build (W4) --- pkg/hub/server.go | 1 + pkg/hub/system_handlers.go | 97 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/pkg/hub/server.go b/pkg/hub/server.go index 4323640b4..49d638e84 100644 --- a/pkg/hub/server.go +++ b/pkg/hub/server.go @@ -2557,6 +2557,7 @@ func (s *Server) registerRoutes() { s.mux.Handle("/api/v1/system/runtime", s.requireWorkstation(http.HandlerFunc(s.handleSystemRuntime))) s.mux.Handle("/api/v1/system/init", s.requireWorkstation(http.HandlerFunc(s.handleSystemInit))) s.mux.Handle("/api/v1/system/images/pull", s.requireWorkstation(http.HandlerFunc(s.handleSystemImagesPull))) + s.mux.Handle("/api/v1/system/images/build", s.requireWorkstation(http.HandlerFunc(s.handleSystemImagesBuild))) } // applyMiddleware wraps the handler with middleware. diff --git a/pkg/hub/system_handlers.go b/pkg/hub/system_handlers.go index 4467a0ad6..e50e4c0ff 100644 --- a/pkg/hub/system_handlers.go +++ b/pkg/hub/system_handlers.go @@ -15,8 +15,10 @@ package hub import ( + "bufio" "context" "fmt" + "log/slog" "net/http" "os" "os/exec" @@ -393,6 +395,101 @@ func (s *Server) handleSystemImagesPull(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, imagePullResponse{JobID: jobID}) } +// --- 4.2: Image Build --- + +type imageBuildRequest struct { + Harnesses []string `json:"harnesses"` +} + +type imageBuildLogEvent struct { + Type string `json:"type"` // "log" + Line string `json:"line"` +} + +func (s *Server) handleSystemImagesBuild(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + MethodNotAllowed(w) + return + } + + if err := assertLoopback(r); err != nil { + writeError(w, http.StatusForbidden, ErrCodeForbidden, err.Error(), nil) + return + } + + _, detectErr := config.DetectLocalRuntime() + if detectErr != nil { + writeError(w, http.StatusServiceUnavailable, ErrCodeInternalError, "no container runtime available", nil) + return + } + + var req imageBuildRequest + if err := readJSON(r, &req); err != nil { + BadRequest(w, "invalid request body") + return + } + + if len(req.Harnesses) == 0 { + ValidationError(w, "at least one harness must be specified", nil) + return + } + + allowed := map[string]bool{"claude": true, "gemini": true, "codex": true, "opencode": true} + for _, h := range req.Harnesses { + if !allowed[h] { + ValidationError(w, fmt.Sprintf("unknown harness %q", h), nil) + return + } + } + + wd, err := os.Getwd() + if err != nil { + writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "cannot determine working directory", nil) + return + } + + buildScript := filepath.Join(wd, "image-build", "scripts", "build-images.sh") + if _, err := os.Stat(buildScript); err != nil { + writeError(w, http.StatusNotFound, ErrCodeNotFound, "build script not found at "+buildScript, nil) + return + } + + jobID := api.NewUUID() + subject := "system.images." + jobID + + go func() { + cmd := exec.CommandContext(context.Background(), buildScript, "--target", "harnesses") + cmd.Dir = wd + + stdout, err := cmd.StdoutPipe() + if err != nil { + slog.Error("build: stdout pipe failed", "error", err) + s.events.PublishRaw(subject, imageBuildLogEvent{Type: "log", Line: "error: " + err.Error()}) + return + } + cmd.Stderr = cmd.Stdout + + if err := cmd.Start(); err != nil { + slog.Error("build: start failed", "error", err) + s.events.PublishRaw(subject, imageBuildLogEvent{Type: "log", Line: "error: " + err.Error()}) + return + } + + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + s.events.PublishRaw(subject, imageBuildLogEvent{Type: "log", Line: scanner.Text()}) + } + + if err := cmd.Wait(); err != nil { + s.events.PublishRaw(subject, imageBuildLogEvent{Type: "log", Line: "build failed: " + err.Error()}) + } else { + s.events.PublishRaw(subject, imageBuildLogEvent{Type: "log", Line: "build complete"}) + } + }() + + writeJSON(w, http.StatusOK, imagePullResponse{JobID: jobID}) +} + // trimOutput removes a trailing newline from command output. func trimOutput(s string) string { if len(s) > 0 && s[len(s)-1] == '\n' { From 646d7e3f627ebae57769dad7b839fddd24840a45 Mon Sep 17 00:00:00 2001 From: Scion Date: Sun, 31 May 2026 01:48:55 +0000 Subject: [PATCH 10/89] feat: wire wizard images step to pull/build endpoints (W4) --- web/src/components/pages/onboarding.ts | 340 ++++++++++++++++++++++++- 1 file changed, 333 insertions(+), 7 deletions(-) diff --git a/web/src/components/pages/onboarding.ts b/web/src/components/pages/onboarding.ts index 32ac5ce31..825af3680 100644 --- a/web/src/components/pages/onboarding.ts +++ b/web/src/components/pages/onboarding.ts @@ -72,6 +72,15 @@ export class ScionPageOnboarding extends LitElement { // Step 3: Harnesses @state() private selectedHarnesses = new Set(); + // Step 4: Images + @state() private imageStatuses = new Map(); + @state() private imagePulling = false; + @state() private imageBuilding = false; + @state() private buildLogs: string[] = []; + @state() private buildExpanded = false; + @state() private runtimeAvailable = false; + private imageEventSource: EventSource | null = null; + static override styles = css` :host { display: flex; @@ -282,6 +291,103 @@ export class ScionPageOnboarding extends LitElement { .loading-state sl-spinner { font-size: 2rem; } + + .image-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1.25rem; + } + + .image-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.625rem 1rem; + border-radius: var(--scion-radius, 0.5rem); + border: 1px solid var(--scion-border, #e2e8f0); + font-size: 0.875rem; + } + + .image-item .image-name { + flex: 1; + font-family: monospace; + color: var(--scion-text, #1e293b); + } + + .image-status { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.75rem; + font-weight: 600; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + text-transform: uppercase; + letter-spacing: 0.025em; + } + + .image-status.queued { + background: var(--sl-color-neutral-100, #f1f5f9); + color: var(--sl-color-neutral-600, #475569); + } + + .image-status.pulling { + background: var(--sl-color-primary-100, #dbeafe); + color: var(--sl-color-primary-700, #1d4ed8); + } + + .image-status.done, + .image-status.exists { + background: var(--sl-color-success-100, #dcfce7); + color: var(--sl-color-success-700, #15803d); + } + + .image-status.error { + background: var(--sl-color-danger-100, #fee2e2); + color: var(--sl-color-danger-700, #b91c1c); + } + + .image-status sl-spinner { + font-size: 0.75rem; + } + + .build-section { + margin-top: 1.25rem; + border-top: 1px solid var(--scion-border, #e2e8f0); + padding-top: 1rem; + } + + .build-log-toggle { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-size: 0.8rem; + color: var(--scion-text-muted, #64748b); + margin-top: 0.75rem; + } + + .build-log { + margin-top: 0.5rem; + max-height: 16rem; + overflow-y: auto; + background: var(--sl-color-neutral-50, #f8fafc); + border: 1px solid var(--scion-border, #e2e8f0); + border-radius: var(--scion-radius, 0.5rem); + padding: 0.75rem; + font-family: monospace; + font-size: 0.75rem; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-all; + } + + .image-actions { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + } `; override connectedCallback(): void { @@ -289,6 +395,11 @@ export class ScionPageOnboarding extends LitElement { void this.initialize(); } + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.cleanupImageEvents(); + } + private async initialize(): Promise { try { const stored = sessionStorage.getItem(ONBOARDING_STATUS_KEY); @@ -363,7 +474,7 @@ export class ScionPageOnboarding extends LitElement { case 1: return this.renderSystemCheck(); case 2: return this.renderRuntime(); case 3: return this.renderHarnesses(); - case 4: return this.renderImagesPlaceholder(); + case 4: return this.renderImages(); case 5: return this.renderWorkspacePlaceholder(); case 6: return this.renderDone(); default: return nothing; @@ -641,30 +752,245 @@ export class ScionPageOnboarding extends LitElement { return; } this.currentStep = 4; + void this.loadImagesStep(); } finally { this.stepLoading = false; } } - // ── Step 4: Images (placeholder) ── + // ── Step 4: Images ── + + private renderImages() { + const harnesses = [...this.selectedHarnesses]; + if (harnesses.length === 0) { + return html` +

Container Images

+

No harnesses selected. You can go back to select harnesses or skip this step.

+ + `; + } + + const allDone = harnesses.length > 0 && harnesses.every(h => { + const s = this.imageStatuses.get(h); + return s && (s.status === 'done' || s.status === 'exists'); + }); - private renderImagesPlaceholder() { return html`

Container Images

-
- -

Image management will be available in a future update.

+

Pull or build the container images for your selected harnesses.

+ +
+ ${harnesses.map(h => { + const s = this.imageStatuses.get(h); + const status = s?.status ?? 'pending'; + return html` +
+ scion-${h}:latest + ${status === 'pending' ? nothing : html` + + ${status === 'pulling' ? html`` : nothing} + ${status === 'done' || status === 'exists' ? '✓' : nothing} + ${status === 'error' ? '✗' : nothing} + ${status} + + `} +
+ `; + })} +
+ +
+ Pull images + + ${this.runtimeAvailable ? html` + Build locally + ` : nothing}
+ ${this.buildLogs.length > 0 ? html` +
+
{ this.buildExpanded = !this.buildExpanded; }}> + + Build output (${this.buildLogs.length} lines) +
+ ${this.buildExpanded ? html` +
${this.buildLogs.join('\n')}
+ ` : nothing} +
+ ` : nothing} + `; } + private async handlePullImages(): Promise { + this.error = null; + this.imagePulling = true; + const harnesses = [...this.selectedHarnesses]; + + const statuses = new Map(this.imageStatuses); + for (const h of harnesses) { + statuses.set(h, { status: 'queued' }); + } + this.imageStatuses = statuses; + + try { + const res = await apiFetch('/api/v1/system/images/pull', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ harnesses }), + }); + if (!res.ok) { + this.error = await extractApiError(res, 'Failed to start image pull'); + this.imagePulling = false; + return; + } + const data = (await res.json()) as { jobId: string }; + this.subscribeToImageJob(data.jobId, 'pull'); + } catch { + this.error = 'Failed to connect to the server.'; + this.imagePulling = false; + } + } + + private async handleBuildImages(): Promise { + this.error = null; + this.imageBuilding = true; + this.buildLogs = []; + this.buildExpanded = true; + + try { + const res = await apiFetch('/api/v1/system/images/build', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ harnesses: [...this.selectedHarnesses] }), + }); + if (!res.ok) { + this.error = await extractApiError(res, 'Failed to start image build'); + this.imageBuilding = false; + return; + } + const data = (await res.json()) as { jobId: string }; + this.subscribeToImageJob(data.jobId, 'build'); + } catch { + this.error = 'Failed to connect to the server.'; + this.imageBuilding = false; + } + } + + private subscribeToImageJob(jobId: string, mode: 'pull' | 'build'): void { + this.cleanupImageEvents(); + + const url = `/events?sub=${encodeURIComponent('system.images.' + jobId)}`; + const es = new EventSource(url); + this.imageEventSource = es; + + let doneCount = 0; + const totalImages = this.selectedHarnesses.size; + + es.addEventListener('update', (event: Event) => { + try { + const wrapper = JSON.parse((event as MessageEvent).data) as { subject: string; data: Record }; + const d = wrapper.data; + + if (mode === 'pull' && d['image']) { + const image = d['image'] as string; + const status = d['status'] as string; + const error = d['error'] as string | undefined; + + const harness = this.imageNameToHarness(image); + if (harness) { + const next = new Map(this.imageStatuses); + const entry: { status: string; error?: string } = { status }; + if (error) entry.error = error; + next.set(harness, entry); + this.imageStatuses = next; + } + + if (status === 'done' || status === 'exists' || status === 'error') { + doneCount++; + if (doneCount >= totalImages) { + this.imagePulling = false; + this.cleanupImageEvents(); + } + } + } + + if (mode === 'build' && d['type'] === 'log') { + const line = d['line'] as string; + this.buildLogs = [...this.buildLogs, line]; + + if (line === 'build complete' || line.startsWith('build failed:')) { + this.imageBuilding = false; + this.cleanupImageEvents(); + } + } + } catch (err) { + console.error('[Onboarding] Failed to parse image event:', err); + } + }); + + es.onerror = () => { + if (mode === 'pull') this.imagePulling = false; + if (mode === 'build') this.imageBuilding = false; + this.cleanupImageEvents(); + }; + } + + private imageNameToHarness(image: string): string | null { + const harnessNames = ['claude', 'gemini', 'codex', 'opencode']; + for (const h of harnessNames) { + if (image.includes(`scion-${h}`)) return h; + } + return null; + } + + private cleanupImageEvents(): void { + if (this.imageEventSource) { + this.imageEventSource.close(); + this.imageEventSource = null; + } + } + + private async loadImagesStep(): Promise { + try { + const res = await apiFetch('/api/v1/system/runtime'); + if (res.ok) { + const data = (await res.json()) as RuntimeResponse; + this.runtimeAvailable = data.available; + } + } catch { /* ignore */ } + } + // ── Step 5: First Workspace (placeholder) ── private renderWorkspacePlaceholder() { From e7477071fe090e9b8be1fc68802afa50eb688427 Mon Sep 17 00:00:00 2001 From: Scion Date: Sun, 31 May 2026 01:53:29 +0000 Subject: [PATCH 11/89] feat: add path-safety helpers for workstation filesystem operations (W5) --- pkg/hub/fs_safety.go | 134 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 pkg/hub/fs_safety.go diff --git a/pkg/hub/fs_safety.go b/pkg/hub/fs_safety.go new file mode 100644 index 000000000..daba21f75 --- /dev/null +++ b/pkg/hub/fs_safety.go @@ -0,0 +1,134 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hub + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/GoogleCloudPlatform/scion/pkg/config" + "github.com/GoogleCloudPlatform/scion/pkg/store" +) + +// PathClass describes what kind of path was resolved. +type PathClass struct { + Resolved string `json:"resolved"` + Exists bool `json:"exists"` + IsDir bool `json:"isDir"` + IsGit bool `json:"isGit"` + IsManaged bool `json:"isManaged"` + AlreadyLinked bool `json:"alreadyLinked"` +} + +// ClassifyPath resolves and classifies a candidate path. +// managedRoot is the hub-managed project directory (e.g. ~/.scion/projects/). +// It queries existing providers to detect already-linked paths. +func ClassifyPath(ctx context.Context, s store.Store, path, managedRoot string) (PathClass, error) { + var pc PathClass + + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + resolved = filepath.Clean(path) + if !filepath.IsAbs(resolved) { + return pc, err + } + pc.Resolved = resolved + pc.Exists = false + return pc, nil + } + + resolved = filepath.Clean(resolved) + if !filepath.IsAbs(resolved) { + abs, err := filepath.Abs(resolved) + if err != nil { + return pc, err + } + resolved = abs + } + pc.Resolved = resolved + + info, err := os.Stat(resolved) + if err != nil { + if os.IsNotExist(err) { + pc.Exists = false + return pc, nil + } + return pc, err + } + pc.Exists = true + pc.IsDir = info.IsDir() + + if pc.IsDir { + gitPath := filepath.Join(resolved, ".git") + if _, err := os.Stat(gitPath); err == nil { + pc.IsGit = true + } + } + + if managedRoot != "" { + cleanManaged := filepath.Clean(managedRoot) + if strings.HasPrefix(resolved, cleanManaged+string(filepath.Separator)) || resolved == cleanManaged { + pc.IsManaged = true + } + // Also check legacy groves path + legacyRoot := strings.Replace(cleanManaged, string(filepath.Separator)+"projects", string(filepath.Separator)+"groves", 1) + if legacyRoot != cleanManaged { + if strings.HasPrefix(resolved, legacyRoot+string(filepath.Separator)) || resolved == legacyRoot { + pc.IsManaged = true + } + } + } + + if s != nil && pc.IsDir { + result, err := s.ListProjects(ctx, store.ProjectFilter{}, store.ListOptions{Limit: 500}) + if err == nil && result != nil { + for _, proj := range result.Items { + providers, err := s.GetProjectProviders(ctx, proj.ID) + if err != nil { + continue + } + for _, p := range providers { + if p.LocalPath == "" { + continue + } + provResolved, err := filepath.EvalSymlinks(p.LocalPath) + if err != nil { + provResolved = filepath.Clean(p.LocalPath) + } + if provResolved == resolved { + pc.AlreadyLinked = true + break + } + } + if pc.AlreadyLinked { + break + } + } + } + } + + return pc, nil +} + +// managedProjectRoot returns the hub-managed project directory (e.g. ~/.scion/projects/). +func managedProjectRoot() (string, error) { + globalDir, err := config.GetGlobalDir() + if err != nil { + return "", err + } + return filepath.Join(globalDir, "projects"), nil +} From d679fd8e1bc81ce6634e4d8abeff8f86cb188662 Mon Sep 17 00:00:00 2001 From: Scion Date: Sun, 31 May 2026 01:55:06 +0000 Subject: [PATCH 12/89] feat: add fenced fs/list, fs/mkdir, fs/validate-path endpoints (W5) --- pkg/hub/server.go | 5 + pkg/hub/system_handlers.go | 226 +++++++++++++++++++++++++++++++++++-- 2 files changed, 224 insertions(+), 7 deletions(-) diff --git a/pkg/hub/server.go b/pkg/hub/server.go index 49d638e84..0a6d5f546 100644 --- a/pkg/hub/server.go +++ b/pkg/hub/server.go @@ -2558,6 +2558,11 @@ func (s *Server) registerRoutes() { s.mux.Handle("/api/v1/system/init", s.requireWorkstation(http.HandlerFunc(s.handleSystemInit))) s.mux.Handle("/api/v1/system/images/pull", s.requireWorkstation(http.HandlerFunc(s.handleSystemImagesPull))) s.mux.Handle("/api/v1/system/images/build", s.requireWorkstation(http.HandlerFunc(s.handleSystemImagesBuild))) + + // Workstation-only filesystem endpoints + s.mux.Handle("/api/v1/system/fs/list", s.requireWorkstation(http.HandlerFunc(s.handleFSList))) + s.mux.Handle("/api/v1/system/fs/mkdir", s.requireWorkstation(http.HandlerFunc(s.handleFSMkdir))) + s.mux.Handle("/api/v1/system/fs/validate-path", s.requireWorkstation(http.HandlerFunc(s.handleFSValidatePath))) } // applyMiddleware wraps the handler with middleware. diff --git a/pkg/hub/system_handlers.go b/pkg/hub/system_handlers.go index e50e4c0ff..2c1bc1324 100644 --- a/pkg/hub/system_handlers.go +++ b/pkg/hub/system_handlers.go @@ -23,6 +23,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "github.com/GoogleCloudPlatform/scion/pkg/api" "github.com/GoogleCloudPlatform/scion/pkg/config" @@ -189,13 +190,14 @@ func (s *Server) handlePutRuntime(w http.ResponseWriter, r *http.Request) { // --- 2.3: Onboarding Status --- type OnboardingStatus struct { - Initialized bool `json:"initialized"` - IdentitySet bool `json:"identitySet"` - RuntimeOK bool `json:"runtimeOK"` - HarnessesSeeded bool `json:"harnessesSeeded"` - ImagesPresent bool `json:"imagesPresent"` - HasWorkspace bool `json:"hasWorkspace"` - Complete bool `json:"complete"` + Initialized bool `json:"initialized"` + IdentitySet bool `json:"identitySet"` + RuntimeOK bool `json:"runtimeOK"` + HarnessesSeeded bool `json:"harnessesSeeded"` + ImagesPresent bool `json:"imagesPresent"` + HasWorkspace bool `json:"hasWorkspace"` + Complete bool `json:"complete"` + EmbeddedBrokerID string `json:"embeddedBrokerID,omitempty"` } func (s *Server) computeOnboardingStatus(ctx context.Context) OnboardingStatus { @@ -249,6 +251,8 @@ func (s *Server) computeOnboardingStatus(ctx context.Context) OnboardingStatus { // Complete: all required steps done (ImagesPresent is optional) status.Complete = status.Initialized && status.IdentitySet && status.RuntimeOK && status.HarnessesSeeded + status.EmbeddedBrokerID = s.GetEmbeddedBrokerID() + return status } @@ -490,6 +494,214 @@ func (s *Server) handleSystemImagesBuild(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, imagePullResponse{JobID: jobID}) } +// --- 5.2: Filesystem Endpoints --- + +type fsListEntry struct { + Name string `json:"name"` + IsDir bool `json:"isDir"` + IsGit bool `json:"isGit,omitempty"` +} + +type fsListResponse struct { + Path string `json:"path"` + Entries []fsListEntry `json:"entries"` +} + +func (s *Server) handleFSList(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + MethodNotAllowed(w) + return + } + + if err := assertLoopback(r); err != nil { + writeError(w, http.StatusForbidden, ErrCodeForbidden, err.Error(), nil) + return + } + + dirPath := r.URL.Query().Get("path") + if dirPath == "" { + home, err := os.UserHomeDir() + if err != nil { + writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "cannot determine home directory", nil) + return + } + dirPath = home + } + + resolved, err := filepath.EvalSymlinks(dirPath) + if err != nil { + resolved = filepath.Clean(dirPath) + } else { + resolved = filepath.Clean(resolved) + } + + home, err := os.UserHomeDir() + if err != nil { + writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "cannot determine home directory", nil) + return + } + if !strings.HasPrefix(resolved, home) { + writeError(w, http.StatusForbidden, ErrCodeForbidden, "path must be within the home directory", nil) + return + } + + rawEntries, err := os.ReadDir(resolved) + if err != nil { + if os.IsNotExist(err) { + writeError(w, http.StatusNotFound, ErrCodeNotFound, "directory not found", nil) + return + } + writeError(w, http.StatusForbidden, ErrCodeForbidden, "cannot read directory", nil) + return + } + + var entries []fsListEntry + for _, e := range rawEntries { + name := e.Name() + if strings.HasPrefix(name, ".") { + continue + } + entry := fsListEntry{ + Name: name, + IsDir: e.IsDir(), + } + if e.IsDir() { + gitPath := filepath.Join(resolved, name, ".git") + if _, err := os.Stat(gitPath); err == nil { + entry.IsGit = true + } + } + entries = append(entries, entry) + } + + writeJSON(w, http.StatusOK, fsListResponse{ + Path: resolved, + Entries: entries, + }) +} + +type fsMkdirRequest struct { + Parent string `json:"parent"` + Name string `json:"name"` +} + +type fsMkdirResponse struct { + Path string `json:"path"` +} + +func (s *Server) handleFSMkdir(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + MethodNotAllowed(w) + return + } + + if err := assertLoopback(r); err != nil { + writeError(w, http.StatusForbidden, ErrCodeForbidden, err.Error(), nil) + return + } + + var req fsMkdirRequest + if err := readJSON(r, &req); err != nil { + BadRequest(w, "invalid request body") + return + } + + if req.Parent == "" || req.Name == "" { + ValidationError(w, "parent and name are required", nil) + return + } + + if strings.ContainsAny(req.Name, "/\\") || req.Name == "." || req.Name == ".." { + ValidationError(w, "name must not contain path separators or be . or ..", nil) + return + } + + resolved, err := filepath.EvalSymlinks(req.Parent) + if err != nil { + writeError(w, http.StatusBadRequest, ErrCodeValidationError, "parent directory does not exist", nil) + return + } + resolved = filepath.Clean(resolved) + + home, err := os.UserHomeDir() + if err != nil { + writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "cannot determine home directory", nil) + return + } + if !strings.HasPrefix(resolved, home) { + writeError(w, http.StatusForbidden, ErrCodeForbidden, "parent must be within the home directory", nil) + return + } + + managedRoot, _ := managedProjectRoot() + if managedRoot != "" { + cleanManaged := filepath.Clean(managedRoot) + if strings.HasPrefix(resolved, cleanManaged+string(filepath.Separator)) || resolved == cleanManaged { + ValidationError(w, "cannot create directories inside the Scion managed directory", nil) + return + } + } + + newPath := filepath.Join(resolved, req.Name) + if err := os.Mkdir(newPath, 0755); err != nil { + if os.IsExist(err) { + writeError(w, http.StatusConflict, ErrCodeConflict, "directory already exists", nil) + return + } + writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "failed to create directory", nil) + return + } + + writeJSON(w, http.StatusOK, fsMkdirResponse{Path: newPath}) +} + +type fsValidatePathRequest struct { + Path string `json:"path"` +} + +type fsValidatePathResponse struct { + PathClass + Error string `json:"error,omitempty"` +} + +func (s *Server) handleFSValidatePath(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + MethodNotAllowed(w) + return + } + + if err := assertLoopback(r); err != nil { + writeError(w, http.StatusForbidden, ErrCodeForbidden, err.Error(), nil) + return + } + + var req fsValidatePathRequest + if err := readJSON(r, &req); err != nil { + BadRequest(w, "invalid request body") + return + } + + if req.Path == "" { + ValidationError(w, "path is required", nil) + return + } + + managedRoot, _ := managedProjectRoot() + + pc, err := ClassifyPath(r.Context(), s.store, req.Path, managedRoot) + if err != nil { + slog.Warn("fs/validate-path: classify error", "path", req.Path, "error", err) + } + + resp := fsValidatePathResponse{PathClass: pc} + + if pc.IsManaged { + resp.Error = "This path is inside the Scion managed directory and cannot be linked" + } + + writeJSON(w, http.StatusOK, resp) +} + // trimOutput removes a trailing newline from command output. func trimOutput(s string) string { if len(s) > 0 && s[len(s)-1] == '\n' { From 707c20af058a0192a22108702be1b7dbb8491a84 Mon Sep 17 00:00:00 2001 From: Scion Date: Sun, 31 May 2026 01:57:43 +0000 Subject: [PATCH 13/89] feat: add linked-grove creation mode with directory browser to project-create (W5) --- web/src/components/pages/project-create.ts | 239 ++++++++++++++- web/src/components/shared/dir-browser.ts | 340 +++++++++++++++++++++ 2 files changed, 575 insertions(+), 4 deletions(-) create mode 100644 web/src/components/shared/dir-browser.ts diff --git a/web/src/components/pages/project-create.ts b/web/src/components/pages/project-create.ts index 28610bbd9..8c45d2265 100644 --- a/web/src/components/pages/project-create.ts +++ b/web/src/components/pages/project-create.ts @@ -23,12 +23,23 @@ import { LitElement, html, css, nothing } from 'lit'; import { customElement, state } from 'lit/decorators.js'; -import { extractApiError } from '../../client/api.js'; +import { apiFetch, extractApiError } from '../../client/api.js'; import '../shared/status-badge.js'; +import '../shared/dir-browser.js'; -type ProjectMode = 'git' | 'hub'; +type ProjectMode = 'git' | 'hub' | 'linked'; type GitWorkspaceMode = 'per-agent' | 'worktree-per-agent' | 'shared'; +interface ValidatePathResponse { + resolved: string; + exists: boolean; + isDir: boolean; + isGit: boolean; + isManaged: boolean; + alreadyLinked: boolean; + error?: string; +} + @customElement('scion-page-project-create') export class ScionPageProjectCreate extends LitElement { @state() @@ -75,9 +86,28 @@ export class ScionPageProjectCreate extends LitElement { @state() private githubAppUrl: string | null = null; + // Linked-mode state + @state() + private localPath = ''; + + @state() + private pathValidation: ValidatePathResponse | null = null; + + @state() + private validatingPath = false; + + @state() + private browseDialogOpen = false; + + @state() + private embeddedBrokerID = ''; + + private pathCheckTimer: ReturnType | null = null; + override connectedCallback(): void { super.connectedCallback(); this.checkGitHubApp(); + void this.loadEmbeddedBrokerID(); } private async checkGitHubApp(): Promise { @@ -93,6 +123,57 @@ export class ScionPageProjectCreate extends LitElement { } } + private async loadEmbeddedBrokerID(): Promise { + try { + const res = await apiFetch('/api/v1/system/status'); + if (!res.ok) return; + const data = (await res.json()) as { embeddedBrokerID?: string }; + if (data.embeddedBrokerID) { + this.embeddedBrokerID = data.embeddedBrokerID; + } + } catch { + // Non-fatal + } + } + + private onLocalPathInput(e: Event): void { + this.localPath = (e.target as HTMLElement & { value: string }).value; + + if (this.pathCheckTimer) { + clearTimeout(this.pathCheckTimer); + } + const path = this.localPath.trim(); + if (path.length > 1) { + this.pathCheckTimer = setTimeout(() => void this.validateLocalPath(path), 500); + } else { + this.pathValidation = null; + } + } + + private async validateLocalPath(path: string): Promise { + this.validatingPath = true; + this.pathValidation = null; + try { + const res = await apiFetch('/api/v1/system/fs/validate-path', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }), + }); + if (!res.ok) return; + this.pathValidation = (await res.json()) as ValidatePathResponse; + } catch { + // Best-effort + } finally { + this.validatingPath = false; + } + } + + private onDirBrowserPathSelected(e: CustomEvent<{ path: string }>): void { + this.localPath = e.detail.path; + this.browseDialogOpen = false; + void this.validateLocalPath(e.detail.path); + } + override updated(changedProperties: Map): void { super.updated(changedProperties); if (changedProperties.has('error') && this.error) { @@ -260,6 +341,45 @@ export class ScionPageProjectCreate extends LitElement { font-size: 0.925rem; color: var(--scion-text, #1e293b); } + + .path-input-row { + display: flex; + gap: 0.5rem; + align-items: flex-start; + } + + .path-input-row sl-input { + flex: 1; + } + + .path-input-row sl-button { + margin-top: 0; + } + + .validation-result { + font-size: 0.8125rem; + margin-top: 0.375rem; + padding: 0.5rem 0.75rem; + border-radius: var(--scion-radius, 0.5rem); + } + + .validation-result.valid { + background: var(--sl-color-success-50, #f0fdf4); + border: 1px solid var(--sl-color-success-200, #bbf7d0); + color: var(--sl-color-success-700, #15803d); + } + + .validation-result.warning { + background: var(--sl-color-warning-50, #fefce8); + border: 1px solid var(--sl-color-warning-200, #fef08a); + color: var(--sl-color-warning-700, #a16207); + } + + .validation-result.error { + background: var(--sl-color-danger-50, #fef2f2); + border: 1px solid var(--sl-color-danger-200, #fecaca); + color: var(--sl-color-danger-700, #b91c1c); + } `; private slugify(text: string): string { @@ -364,6 +484,21 @@ export class ScionPageProjectCreate extends LitElement { return; } + if (this.mode === 'linked') { + if (!this.localPath.trim()) { + this.error = 'Local directory path is required.'; + return; + } + if (!this.pathValidation || this.pathValidation.error || !this.pathValidation.exists || !this.pathValidation.isDir) { + this.error = 'Please select a valid directory path.'; + return; + } + if (!this.embeddedBrokerID) { + this.error = 'No embedded broker available. Ensure the server is running in workstation mode.'; + return; + } + } + this.submitting = true; this.error = null; @@ -424,11 +559,29 @@ export class ScionPageProjectCreate extends LitElement { } // Backend returns 200 for an existing project, 201 for newly created - if (response.status === 200) { + if (response.status === 200 && this.mode !== 'linked') { this.existingProjectId = projectId; return; } + // Two-step linked create: add provider after project creation + if (this.mode === 'linked') { + const providerRes = await fetch(`/api/v1/projects/${projectId}/providers`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + brokerId: this.embeddedBrokerID, + localPath: this.pathValidation!.resolved, + }), + }); + if (!providerRes.ok) { + this.error = await extractApiError(providerRes, 'Project created but failed to link directory. You can retry.'); + this.submitting = false; + return; + } + } + // Navigate to the newly created project this.navigateToProject(projectId); } catch (err) { @@ -474,11 +627,14 @@ export class ScionPageProjectCreate extends LitElement { > Hub-managed Workspace Git Repository + Local Directory (linked)
${this.mode === 'hub' ? 'A workspace managed by the Hub. No git repository required.' - : 'Link to an existing git repository for source-controlled workspaces.'} + : this.mode === 'linked' + ? 'Link a local directory. The directory stays where it is and is operated on in place.' + : 'Link to an existing git repository for source-controlled workspaces.'}
@@ -578,6 +734,70 @@ export class ScionPageProjectCreate extends LitElement { ` : nothing} + ${this.mode === 'linked' + ? html` +
+ +
+ this.onLocalPathInput(e)} + > + { this.browseDialogOpen = true; }}> + Browse… + +
+
+ Absolute path to a local directory. The directory is operated on in place. +
+
+ + ${this.validatingPath + ? html`
+ Validating path… +
` + : this.pathValidation + ? html` + ${this.pathValidation.error + ? html`
+ + ${this.pathValidation.error} +
` + : !this.pathValidation.exists + ? html`
+ + Path does not exist. +
` + : !this.pathValidation.isDir + ? html`
+ + Path is not a directory. +
` + : html` +
+ + Path resolved to: ${this.pathValidation.resolved} +
+ ${this.pathValidation.isGit + ? html`
+ + This is a git repository. Agents will operate on the working tree. +
` + : nothing} + ${this.pathValidation.alreadyLinked + ? html`
+ + This directory is already linked to another project. +
` + : nothing} + `} + ` + : nothing} + ` + : nothing} +
+ { this.browseDialogOpen = false; }} + style="--width: 36rem;" + > + ) => this.onDirBrowserPathSelected(e)} + > + + { + this.loading = true; + this.error = null; + this.newFolderMode = false; + + try { + const params = path ? `?path=${encodeURIComponent(path)}` : ''; + const res = await apiFetch(`/api/v1/system/fs/list${params}`); + if (!res.ok) { + this.error = await extractApiError(res, 'Failed to list directory'); + return; + } + const data = (await res.json()) as DirListResponse; + this.currentPath = data.path; + this.entries = data.entries ?? []; + } catch { + this.error = 'Failed to connect to the server.'; + } finally { + this.loading = false; + } + } + + private onEntryClick(entry: DirEntry): void { + if (!entry.isDir) return; + const newPath = this.currentPath + '/' + entry.name; + void this.navigate(newPath); + } + + private navigateUp(): void { + const parent = this.currentPath.substring(0, this.currentPath.lastIndexOf('/')); + if (parent) { + void this.navigate(parent); + } + } + + private navigateToBreadcrumb(index: number): void { + const segments = this.currentPath.split('/').filter(Boolean); + const path = '/' + segments.slice(0, index + 1).join('/'); + void this.navigate(path); + } + + private selectCurrentPath(): void { + this.selectedPath = this.currentPath; + this.dispatchEvent(new CustomEvent('path-selected', { + detail: { path: this.currentPath }, + bubbles: true, + composed: true, + })); + } + + private async handleNewFolder(): Promise { + const name = this.newFolderName.trim(); + if (!name) { + this.newFolderError = 'Folder name is required.'; + return; + } + + this.creatingFolder = true; + this.newFolderError = null; + + try { + const res = await apiFetch('/api/v1/system/fs/mkdir', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ parent: this.currentPath, name }), + }); + if (!res.ok) { + this.newFolderError = await extractApiError(res, 'Failed to create folder'); + return; + } + this.newFolderMode = false; + this.newFolderName = ''; + void this.navigate(this.currentPath); + } catch { + this.newFolderError = 'Failed to connect to the server.'; + } finally { + this.creatingFolder = false; + } + } + + override render() { + const segments = this.currentPath.split('/').filter(Boolean); + + return html` +
+ + + ${this.loading ? html` +
+ ` : this.error ? html` +
${this.error}
+ ` : this.entries.length === 0 ? html` +
Empty directory
+ ` : html` +
+ ${segments.length > 0 ? html` +
this.navigateUp()}> + + .. +
+ ` : nothing} + ${this.entries.map(e => html` +
this.onEntryClick(e)}> + + ${e.name} + ${e.isGit ? html`git` : nothing} +
+ `)} +
+ `} + + ${this.newFolderMode ? html` +
+ { this.newFolderName = (e.target as HTMLInputElement).value; }} + @keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter') void this.handleNewFolder(); }} + > + void this.handleNewFolder()}> + Create + + { this.newFolderMode = false; }}> + Cancel + +
+ ${this.newFolderError ? html`
${this.newFolderError}
` : nothing} + ` : nothing} + +
+ { this.newFolderMode = true; this.newFolderName = ''; this.newFolderError = null; }}> + + New folder + +
+ this.selectCurrentPath()}> + Select this folder + +
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'scion-dir-browser': ScionDirBrowser; + } +} From 4bc13fc03cb444316eaf251cfddd949b61107e65 Mon Sep 17 00:00:00 2001 From: Scion Date: Sun, 31 May 2026 01:59:08 +0000 Subject: [PATCH 14/89] feat: wire wizard workspace step to linked-grove and hub-native create flows (W5) --- web/src/components/pages/onboarding.ts | 294 ++++++++++++++++++++++++- 1 file changed, 290 insertions(+), 4 deletions(-) diff --git a/web/src/components/pages/onboarding.ts b/web/src/components/pages/onboarding.ts index 825af3680..d0417a744 100644 --- a/web/src/components/pages/onboarding.ts +++ b/web/src/components/pages/onboarding.ts @@ -18,6 +18,7 @@ import { LitElement, html, css, nothing } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import { apiFetch, extractApiError } from '../../client/api.js'; +import '../shared/dir-browser.js'; const ONBOARDING_STATUS_KEY = 'onboardingStatus'; const TOTAL_STEPS = 6; @@ -81,6 +82,15 @@ export class ScionPageOnboarding extends LitElement { @state() private runtimeAvailable = false; private imageEventSource: EventSource | null = null; + // Step 5: Workspace + @state() private workspaceMode: 'choose' | 'hub' | 'linked' = 'choose'; + @state() private wsProjectName = ''; + @state() private wsLocalPath = ''; + @state() private wsPathValidation: { resolved: string; exists: boolean; isDir: boolean; isGit: boolean; isManaged: boolean; alreadyLinked: boolean; error?: string } | null = null; + @state() private wsValidatingPath = false; + @state() private wsCreating = false; + @state() private wsEmbeddedBrokerID = ''; + static override styles = css` :host { display: flex; @@ -388,6 +398,75 @@ export class ScionPageOnboarding extends LitElement { gap: 0.5rem; margin-bottom: 1rem; } + + .ws-cards { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1.25rem; + } + + .ws-card { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + border-radius: var(--scion-radius, 0.5rem); + border: 1px solid var(--scion-border, #e2e8f0); + cursor: pointer; + transition: border-color 0.15s; + } + + .ws-card:hover { + border-color: var(--scion-primary, #3b82f6); + } + + .ws-card sl-icon { + font-size: 1.5rem; + color: var(--scion-primary, #3b82f6); + flex-shrink: 0; + } + + .ws-card .ws-card-text { + flex: 1; + } + + .ws-card .ws-card-title { + font-weight: 600; + color: var(--scion-text, #1e293b); + font-size: 0.9375rem; + } + + .ws-card .ws-card-desc { + font-size: 0.8125rem; + color: var(--scion-text-muted, #64748b); + margin-top: 0.125rem; + } + + .ws-validation { + font-size: 0.8125rem; + margin-top: 0.375rem; + padding: 0.5rem 0.75rem; + border-radius: var(--scion-radius, 0.5rem); + } + + .ws-validation.valid { + background: var(--sl-color-success-50, #f0fdf4); + border: 1px solid var(--sl-color-success-200, #bbf7d0); + color: var(--sl-color-success-700, #15803d); + } + + .ws-validation.warning { + background: var(--sl-color-warning-50, #fefce8); + border: 1px solid var(--sl-color-warning-200, #fef08a); + color: var(--sl-color-warning-700, #a16207); + } + + .ws-validation.error { + background: var(--sl-color-danger-50, #fef2f2); + border: 1px solid var(--sl-color-danger-200, #fecaca); + color: var(--sl-color-danger-700, #b91c1c); + } `; override connectedCallback(): void { @@ -991,14 +1070,41 @@ export class ScionPageOnboarding extends LitElement { } catch { /* ignore */ } } - // ── Step 5: First Workspace (placeholder) ── + // ── Step 5: First Workspace ── private renderWorkspacePlaceholder() { + if (this.workspaceMode === 'hub') return this.renderWsHub(); + if (this.workspaceMode === 'linked') return this.renderWsLinked(); + return this.renderWsChoose(); + } + + private renderWsChoose() { return html`

First Workspace

-
- -

Workspace creation will be available in a future update.

+

Create your first project to get started.

+ +
+
{ this.workspaceMode = 'hub'; }}> + +
+
Hub-native project
+
A workspace managed by the Hub. No git repository required.
+
+
+
{ window.location.href = '/projects/new'; }}> + +
+
Link a git repo
+
Connect to an existing git repository for source-controlled workspaces.
+
+
+
{ this.workspaceMode = 'linked'; void this.loadWsBrokerID(); }}> + +
+
Add local directory
+
Link a local directory. It stays where it is and is operated on in place.
+
+