From da49e14dbfa37e53df8fcde7ffae2572e7f3141a Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Mon, 15 Jun 2026 21:04:48 -0700 Subject: [PATCH 1/9] Workstation onboarding (#434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: add implementation task briefs and finalized design docs (phases 0-6) * feat: add Workstation flag and workstation-only middleware to ServerConfig * feat: make dev identity configurable, default to OS user (W2) * chore: relabel dev token as "developer token" in UI and docs (W3) * 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. * 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) * 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) * 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) * test: add tests for workstation onboarding API and security fencing Adds comprehensive tests for the workstation onboarding endpoints and security primitives introduced in phases 0-5: - requireWorkstation middleware: 404 when disabled, pass-through when enabled - assertLoopback: table-driven IPv4/IPv6 loopback validation - ClassifyPath: managed path detection, legacy groves path, AlreadyLinked via store - POST /system/init: valid harnesses, unknown harness rejection, empty list rejection - PUT /system/identity: writes and echoes display name and email - POST /system/fs/validate-path: managed-path overlap error, normal path classification - GET /system/fs/list: home directory listing, hidden file filtering, outside-home rejection Also adds missing server.auth.display_name, server.auth.email, and server.auth.username key handling in UpdateVersionedSetting, which the identity endpoint depends on. * docs: update README with workstation quick start and brew install Replace the "not yet able to provide pre-built binaries" note with a Homebrew quick start path. Lead with `brew install scion` + `scion server start` which opens the onboarding wizard, then keep the existing go install path as "Install from Source". Add a tip noting that the wizard handles machine init automatically. * chore: add round-1 review fix brief * fix: correct home-directory boundary check in fs/list and fs/mkdir (M1) * fix: write runtime to active profile runtime field not active_profile key (M2) * fix: document intentional unfenced validate-path + assertLoopback (M3) * fix: address minor review findings (m1-m5) * chore: add round-2 review fix brief * fix: use server-lifetime context in image pull/build goroutines (m4) * fix: remove devUser self-shadow in auth.go (m5) and fix empty-ActiveProfile GET/PUT inconsistency (N1) * chore: add ci-full fix task brief * chore: add PR #264 feedback fix brief * fix: handle top-level pull error events to prevent infinite spinner (H1) * fix: normalize Windows path separators and drive letter breadcrumbs in dir-browser (H2, H3, M1) * fix: check scanner.Err after build log scan loop (M2) * fix: check context cancellation in image pull loop (M3) * fix: add concurrency guard for concurrent image build requests (M4) * fix: apply gofmt formatting to all Go source files Fixes fmt-check failures across multiple packages including telegram plugin, agent-viz, chat-app, hub, messages, and runtimebroker. * fix: address golangci-lint findings in hub package - Use type conversion instead of struct literal for identical systemIdentityRequest/Response types (staticcheck S1016) - Check error return from srv.Shutdown in test cleanup (errcheck) * fix: add SCION_PROJECT_ID to env var cleanup in tests and use ephemeral ports SCION_PROJECT_ID was added to IsHubContext() but tests clearing hub env vars were not updated, causing false hub context detection and test failures in pkg/config, pkg/hubsync, and cmd packages. Also switch telemetry pipeline tests from hardcoded ports to port 0 (OS-assigned ephemeral ports) to eliminate flaky port-conflict failures from TCP TIME_WAIT between sequential tests. * fix: resolve post-rebase build errors (unused storage import, productionMode→hostedMode) * fix: suppress dev-auth warning in workstation mode (#72) * chore: add phantom daemon fix task brief * chore: add onboarding bugs 3-4-5 fix brief * feat: detect port conflicts on server start to catch phantom daemons * feat: add scion server stop --force to kill phantom processes by port * fix: only resume wizard progress if user has previously started onboarding (bug 3) * fix: display full registry-qualified image names in wizard step 5 (bug 4) * fix: add registry and buildAvailable to system/status; hide build option for brew installs (bug 5) * fix: store imageRegistry as component state; use it in initial and queued image name display (bug 4 complete) * fix: scope git version check to worktree-using commands only (scion start/run) * fix: capture needsOnboarding before daemon start to prevent /onboarding URL race (bug 6) fix: add git version check to /system/status; show as non-blocking warning in wizard step 2 (bug 7) * fix: fail fast on empty image_registry in pull handler; show registry-missing error in wizard step 5 (bug 8) * fix: identity persists immediately after onboarding step 0 (bug 10); fix page title and hub-native terminology (bugs 9, 11) - seedDevUser: reads DevUserConfig instead of hardcoding defaults - identity PUT handler: updates DB user record so name is visible without restart - web.go dev auto-login: reads display name/email from store instead of hardcoding - app-shell.ts: add /onboarding to PAGE_TITLES as 'Setup' - onboarding.ts: 'Hub-native project' -> 'Hub-managed project' * chore: add rebase task brief for workstation-improvements * fix: resolve post-rebase build/test errors after upstream main rebase - pkg/hub/events.go: add PublishRaw to eventBuilder so the upstream-added PostgresEventPublisher satisfies the EventPublisher interface (our W4 image-pull feature added PublishRaw to the interface). - pkg/hub/system_handlers_test.go, fs_safety_test.go: migrate from the removed sqlite.New/SQLiteStore API to the upstream newTestStore helper; use valid UUIDs and BrokerName now required by the ent-backed store. - cmd/server_workstation_test.go: update printWorkstationQuickstart calls for the needsOnboarding parameter. - web onboarding.ts: surface runtimeAvailable in the images step (fixes a noUnusedLocals typecheck error) with a no-runtime warning banner. * fix: show local build without registry and report per-harness build status The onboarding image step hid all image options when image_registry was unset, even though local builds don't require a registry. Now the build option is shown when buildAvailable is true regardless of registry config, and pull is only shown when a registry is configured. The build handler validated requested harnesses but always built all harnesses and emitted no per-harness status events. Now it publishes image status events for each requested harness after a successful build so the UI marks them complete. * fix: resolve CI failures (fmt, lint, compat-literals, tests) - Fix gofmt issues in cmd/root.go and pkg/messages/types.go - Fix errcheck lint error in pkg/daemon/ports.go - Remove ineffectual assignment in cmd/root.go usesWorktrees - Add fs_safety.go, fs_safety_test.go, system_handlers.go to compat-literals allowlist - Update broadcast tests to use new BroadcastMessage endpoint - Update set[] error message expectations to group[] * fix: address PR #434 reviewer findings (HIGH + MEDIUM) HIGH fixes: - Fix navigateUp to handle root-level paths (e.g. /home → /) - Replace syscall.Kill/SIGTERM/SIGKILL with cross-platform os.Process methods (os.Interrupt, process.Kill) - Use case-insensitive path comparison helpers (pathEqual, pathHasPrefix) for security-sensitive path prefix checks in fs_safety.go and system_handlers.go MEDIUM fixes: - Replace http.Error with writeError for consistent JSON responses - Convert EvalSymlinks results to absolute paths before comparing - Add imagePullActive atomic guard to prevent concurrent pulls - Replace doneCount counter with Set to prevent double-counting on retry events in onboarding image pull - Add defensive null check for wrapper.data in SSE handler --------- Co-authored-by: Scion Co-authored-by: Scion --- .design/linked-groves-ui.md | 447 ++++++ .design/workstation-onboarding-wizard.md | 633 ++++++++ .design/workstation-onboarding.md | 343 ++++ .tasks/ci-full-fix.md | 39 + .tasks/onboarding-bugs-3-4-5.md | 111 ++ .tasks/phantom-daemon-fix.md | 55 + .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 + .tasks/pr-264-feedback.md | 158 ++ .tasks/rebase-workstation-improvements.md | 83 + .tasks/review-fixes-round-1.md | 100 ++ .tasks/review-fixes-round-2.md | 52 + README.md | 19 +- cmd/message_test.go | 102 +- cmd/root.go | 15 +- cmd/server.go | 4 + cmd/server_daemon.go | 105 +- cmd/server_foreground.go | 23 +- cmd/server_workstation_test.go | 6 +- docs-site/src/content/docs/hub-admin/auth.md | 2 +- .../src/content/docs/hub-user/hosted-user.md | 2 +- .../src/content/docs/reference/security.md | 4 +- hack/check-project-compat-literals.sh | 3 + pkg/config/hub_config.go | 6 + pkg/config/init_test.go | 2 +- pkg/config/paths_test.go | 2 + pkg/config/project_discovery_test.go | 24 +- pkg/config/project_marker_test.go | 1 + pkg/config/settings_v1.go | 41 + pkg/daemon/daemon.go | 2 +- pkg/daemon/ports.go | 97 ++ pkg/hub/auth.go | 5 +- pkg/hub/devauth.go | 63 +- pkg/hub/events.go | 14 + pkg/hub/fs_safety.go | 147 ++ pkg/hub/fs_safety_test.go | 216 +++ pkg/hub/seed.go | 7 +- pkg/hub/server.go | 88 +- pkg/hub/system_handlers.go | 830 ++++++++++ pkg/hub/system_handlers_test.go | 406 +++++ pkg/hub/system_identity.go | 93 ++ pkg/hub/template_bootstrap.go | 249 ++- pkg/hub/web.go | 19 +- pkg/hubsync/sync_test.go | 4 +- pkg/runtime/imagepull.go | 88 + pkg/sciontool/telemetry/pipeline_test.go | 65 +- web/src/client/main.ts | 20 +- web/src/components/app-shell.ts | 1 + .../components/pages/admin-server-config.ts | 2 +- web/src/components/pages/onboarding.ts | 1410 +++++++++++++++++ web/src/components/pages/project-create.ts | 246 ++- web/src/components/shared/dir-browser.ts | 349 ++++ 56 files changed, 7067 insertions(+), 218 deletions(-) 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/ci-full-fix.md create mode 100644 .tasks/onboarding-bugs-3-4-5.md create mode 100644 .tasks/phantom-daemon-fix.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 create mode 100644 .tasks/pr-264-feedback.md create mode 100644 .tasks/rebase-workstation-improvements.md create mode 100644 .tasks/review-fixes-round-1.md create mode 100644 .tasks/review-fixes-round-2.md create mode 100644 pkg/daemon/ports.go create mode 100644 pkg/hub/fs_safety.go create mode 100644 pkg/hub/fs_safety_test.go create mode 100644 pkg/hub/system_handlers.go create mode 100644 pkg/hub/system_handlers_test.go create mode 100644 pkg/hub/system_identity.go create mode 100644 pkg/runtime/imagepull.go create mode 100644 web/src/components/pages/onboarding.ts create mode 100644 web/src/components/shared/dir-browser.ts 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/ci-full-fix.md b/.tasks/ci-full-fix.md new file mode 100644 index 000000000..3aa1999f1 --- /dev/null +++ b/.tasks/ci-full-fix.md @@ -0,0 +1,39 @@ +# Run ci-full and Fix All Issues + +**Branch:** workstation-improvements +**Goal:** Run `make ci-full` and fix every failure, including pre-existing ones unrelated to our changes. + +--- + +## Steps + +1. Run `make ci-full` and capture all failures +2. Fix each failure category in turn: + - `fmt-check` failures → run `make fmt` then re-check + - `web` (Vite build) failures → fix TypeScript/JS build errors + - `web-typecheck` failures → fix TypeScript type errors + - `lint` failures → fix lint errors (go vet, staticcheck, etc.) + - `golangci-lint` failures → fix golangci-lint findings + - `test-fast` failures → fix failing tests (including pre-existing ones) + - `build` failures → fix compile errors + +3. After fixing each category, re-run that specific step to confirm it passes before moving on +4. Run full `make ci-full` at the end to confirm everything passes together + +## Known pre-existing test failures (from earlier run) + +These tests in `pkg/config` were failing before our changes — fix them too: +- `TestIsInsideProject` +- `TestRequireProjectPath_NoProjectError` +- `TestFindProjectRoot_HubContextNoScion_Disabled` +- `TestDiscoverProjects_GitProjectWithExternalConfigUsesWorkspaceMarkerProjectID` +- `TestIsHubContext` + +Investigate each failure message and fix the root cause. + +## Commit instructions + +- Commit fixes in logical groups (e.g. one commit for fmt fixes, one for test fixes, etc.) +- Use clear commit messages describing what was broken and what was fixed +- Run `make ci-full` one final time to confirm all green before the last commit +- Do not open PRs — commit directly to `workstation-improvements` diff --git a/.tasks/onboarding-bugs-3-4-5.md b/.tasks/onboarding-bugs-3-4-5.md new file mode 100644 index 000000000..03a69c5b7 --- /dev/null +++ b/.tasks/onboarding-bugs-3-4-5.md @@ -0,0 +1,111 @@ +# Fix: Onboarding Bugs 3, 4, 5 + +**Branch:** workstation-improvements +**Commit all changes to the current branch.** + +--- + +## Bug 3 — Wizard skips to step 4 on fresh install + +**File:** `web/src/components/pages/onboarding.ts` + +**Problem:** `initialize()` auto-advances `currentStep` based on backend status flags (`runtimeOK`, `harnessesSeeded`). After `scion init --machine`, both are already true, so the wizard jumps to step 4 (Images) on first launch, skipping Identity, System Check, Runtime, and Harness steps. + +**Fix:** The resume logic must only fire if the user has previously progressed through the wizard. Add a `previouslyStarted` check — use `sessionStorage.getItem('onboardingStarted')` (set it when the user clicks "Next" for the first time). Only run the resume auto-advance if `previouslyStarted` is true: + +```typescript +async initialize() { + const status = await this.fetchStatus(); + const previouslyStarted = sessionStorage.getItem('onboardingStarted') === 'true'; + + if (previouslyStarted) { + // Resume logic: advance past already-complete steps + 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); + } + // Always start at step 0 on fresh install regardless of backend status +} +``` + +Set `sessionStorage.setItem('onboardingStarted', 'true')` when the user advances from step 0 for the first time (i.e. in the "Next" handler for step 0, or on any step advance). + +Clear `sessionStorage.removeItem('onboardingStarted')` when the wizard completes (step "Done"). + +--- + +## Bug 4 — Image names missing registry prefix on step 5 + +**Files:** `web/src/components/pages/onboarding.ts` and `pkg/hub/system_handlers.go` + +### Frontend fix + +**Problem:** The event handler extracts only the harness name via `imageNameToHarness()` and reconstructs a partial name for display. The full registry-qualified image name is in the SSE event as `d['image']` but is discarded. + +**Fix:** Store and display the full image name from the event data. In the image status map, use the full image name as the key (not the harness name), OR store both: + +```typescript +// In the SSE event handler (around line 1004-1016): +if (d['image']) { + const fullImageName = d['image'] as string; // e.g. "ghcr.io/homebrew-scion/scion-claude:latest" + const status = d['status'] as string; + // Store by full image name + const next = new Map(this.imageStatuses); + next.set(fullImageName, { status, error: d['error'] as string | undefined }); + this.imageStatuses = next; +} +``` + +In the render template (around line 872), display the full image name: +```typescript +// Show: "ghcr.io/homebrew-scion/scion-claude:latest" instead of "scion-claude:latest" +${[...this.imageStatuses.entries()].map(([image, info]) => html` +
+ ${image} + ${info.status} + ${info.error ? html`${info.error}` : ''} +
+`)} +``` + +### Backend: add registry to /system/status + +**Problem:** The frontend has no way to verify what registry is configured. + +**Fix:** Add `ImageRegistry string` to `OnboardingStatus` in `pkg/hub/system_handlers.go` and populate it by reading `image_registry` from settings (via `config.LoadSettings("")` → `settings.ImageRegistry` or however it's stored). The frontend can display this in the images step header: "Pulling from: ghcr.io/homebrew-scion". + +--- + +## Bug 5 — "Build locally" fails for Homebrew installs + +**File:** `pkg/hub/system_handlers.go` (handleSystemImagesBuild, ~line 492-504) + +**Problem:** Build script is not present in Homebrew installations. The handler falls back to a CWD/binary-relative path that doesn't exist. + +**Fix:** Add an availability check before the build option is shown or invoked: + +In `handleSystemImagesBuild`: +```go +// Check build script exists before attempting +buildScript := resolveBuildScript() // existing resolution logic +if buildScript == "" { + http.Error(w, `{"error":"local builds require a source checkout; use image pull instead","buildUnavailable":true}`, http.StatusUnprocessableEntity) + return +} +``` + +In `GET /system/status`, add `BuildAvailable bool` that returns `true` only if the build script can be resolved. Frontend uses this to: +- Hide the "Build locally" button when `!status.buildAvailable` +- Show an explanatory note: "Pre-built images are available from ghcr.io/homebrew-scion. Local builds require a source checkout." + +This ensures that for Homebrew installs the user only sees the pull path, while developer checkouts still get the build option. + +--- + +## Commit Instructions + +- `fix: only resume wizard progress if user has previously started onboarding (bug 3)` +- `fix: display full registry-qualified image names in wizard step 5 (bug 4)` +- `fix: add registry and buildAvailable to system/status; hide build option for brew installs (bug 5)` +- Run `go build ./...` and `go vet ./...` before committing Go changes +- Do not open PRs — commit directly to `workstation-improvements` diff --git a/.tasks/phantom-daemon-fix.md b/.tasks/phantom-daemon-fix.md new file mode 100644 index 000000000..d88bc44e1 --- /dev/null +++ b/.tasks/phantom-daemon-fix.md @@ -0,0 +1,55 @@ +# Fix: Phantom Daemon After Reinstall + +**Branch:** workstation-improvements +**Key files:** `pkg/daemon/daemon.go`, `cmd/server_daemon.go` +**Commit all changes to the current branch.** + +## Fix 1 — Port conflict detection in `scion server start` + +In `cmd/server_daemon.go` `runServerStartOrDaemon()` (around line 34), after the existing `StatusComponent()` check, add a port probe: + +```go +// Check for phantom processes holding server ports even without a PID file +if phantomPorts := detectOccupiedPorts(cfg); len(phantomPorts) > 0 { + fmt.Fprintf(os.Stderr, "Error: the following ports are already in use: %v\n", phantomPorts) + fmt.Fprintf(os.Stderr, "A previous server process may be running without a PID file.\n") + fmt.Fprintf(os.Stderr, "Run 'scion server stop --force' to kill any process on these ports.\n") + return fmt.Errorf("port conflict: ports %v are occupied", phantomPorts) +} +``` + +Implement `detectOccupiedPorts(cfg)` in `pkg/daemon/daemon.go` or a new `pkg/daemon/ports.go`: +- Try to bind (and immediately release) each server port (default: 8080 for web/hub, 9800 for broker, 9810 for broker gRPC or whatever ports the config uses) +- If binding fails → port is occupied +- Return the list of occupied ports + +Look at how the server resolves its ports from `cfg` (search for `cfg.Hub.Port`, `cfg.RuntimeBroker.Port` etc.) to know which ports to probe. + +Use `net.Listen("tcp", fmt.Sprintf(":%d", port))` — if it succeeds, close it immediately and mark as free; if it fails with EADDRINUSE, mark as occupied. + +## Fix 2 — `scion server stop --force` + +In `cmd/server_daemon.go` `runServerStop()` (around line 165), add a `--force` flag: + +``` +--force Kill any process listening on the server ports, even without a PID file +``` + +When `--force` is set: +1. Probe the server ports (reuse `detectOccupiedPorts`) +2. For each occupied port, find the PID of the process holding it using `lsof -ti :` (macOS/Linux) or `ss -tlnp` (Linux fallback) +3. Kill the PID with SIGTERM, wait up to 3s, then SIGKILL if still running +4. Report what was killed + +Simple implementation using `exec.Command("lsof", "-ti", fmt.Sprintf(":%d", port))` — parse the output as a PID, then `syscall.Kill(pid, syscall.SIGTERM)`. + +If no PID file and no occupied ports, print "No running server found." + +Add `--force` to the `stop` command's flags in `cmd/server_daemon.go`. + +## Commit Instructions + +- `feat: detect port conflicts on server start to catch phantom daemons` +- `feat: add scion server stop --force to kill phantom processes by port` +- Run `go build ./...` and `go vet ./...` before committing +- Do not open PRs — commit directly to `workstation-improvements` 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` diff --git a/.tasks/pr-264-feedback.md b/.tasks/pr-264-feedback.md new file mode 100644 index 000000000..e2ab8105e --- /dev/null +++ b/.tasks/pr-264-feedback.md @@ -0,0 +1,158 @@ +# PR #264 Feedback — Address All Review Comments + +**Branch:** workstation-improvements +**PR:** https://github.com/GoogleCloudPlatform/scion/pull/264 +**Commit all fixes to the current branch.** + +Address all feedback from the Gemini code review on PR #264. Fix each issue exactly as suggested. + +--- + +## HIGH Priority + +### H1 — Infinite spinner on early image pull failure +**File:** `web/src/components/pages/onboarding.ts` (~line 1025) + +The SSE event listener only handles events with an `image` key. Top-level errors from `PullImages` (e.g. when the registry is unreachable before any image starts) publish `{ status: "error", error: "..." }` without an `image` key — these are silently ignored, leaving the spinner running forever. + +**Fix:** Add an else-if branch to handle top-level error events in the pull mode: + +```typescript +if (mode === 'pull') { + if (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(); + } + } + } else if (d['status'] === 'error') { + this.error = (d['error'] as string) || 'An error occurred during image pull.'; + this.imagePulling = false; + this.cleanupImageEvents(); + } +} +``` + +### H2 — Windows path separator not normalized in dir-browser +**File:** `web/src/components/shared/dir-browser.ts` (~line 197) + +Windows backends return `\\` separators; the frontend splits on `/`, breaking breadcrumbs and navigation on Windows. + +**Fix:** Normalize path separators when receiving from API: +```typescript +this.currentPath = data.path.replace(/\\/g, '/'); +this.entries = data.entries ?? []; +``` + +### H3 — Windows drive letter breadcrumb produces invalid path +**File:** `web/src/components/shared/dir-browser.ts` (~line 222) + +`navigateToBreadcrumb` prepends `/` to all paths, producing `/C:` (invalid) on Windows. + +**Fix:** Replace `navigateToBreadcrumb` with: +```typescript +private navigateToBreadcrumb(index: number): void { + const segments = this.currentPath.split('/').filter(Boolean); + const subSegments = segments.slice(0, index + 1); + let path = ''; + if (subSegments[0] && /^[a-zA-Z]:$/.test(subSegments[0])) { + path = subSegments.join('/'); + if (subSegments.length === 1) { + path += '/'; + } + } else { + path = '/' + subSegments.join('/'); + } + void this.navigate(path); +} +``` + +--- + +## MEDIUM Priority + +### M1 — Windows drive root shows invalid `..` entry +**File:** `web/src/components/shared/dir-browser.ts` (~line 291) + +At the root of a Windows drive (e.g. `C:/`), segments.length is 1, so a `..` entry is shown that navigates to invalid `C:`. + +**Fix:** Update the condition guarding the `..` entry to also exclude Windows drive roots: +``` +!(segments.length === 0 || (segments.length === 1 && /^[a-zA-Z]:$/.test(segments[0]))) +``` +(i.e. don't render the `..` entry when at home root OR at a Windows drive root) + +### M2 — `bufio.Scanner` error not checked after scan loop +**File:** `pkg/hub/system_handlers.go` (~line 519) in `handleSystemImagesBuild` + +If a log line exceeds 64KB, `scanner.Scan()` returns false with `scanner.Err() == bufio.ErrTooLong`. This is currently silently swallowed. + +**Fix:** After the scan loop, check and publish the error: +```go +scanner := bufio.NewScanner(stdout) +for scanner.Scan() { + s.events.PublishRaw(subject, imageBuildLogEvent{Type: "log", Line: scanner.Text()}) +} +if err := scanner.Err(); err != nil { + s.events.PublishRaw(subject, imageBuildLogEvent{Type: "log", Line: "error reading build log: " + err.Error()}) +} +``` + +### M3 — Image pull loop doesn't check context cancellation +**File:** `pkg/runtime/imagepull.go` (~line 66) + +When the server shuts down and `ctx` is cancelled, the pull loop still iterates all remaining images and fires error events for each. + +**Fix:** Check `ctx.Err()` at the top of each iteration: +```go +for _, img := range images { + if err := ctx.Err(); err != nil { + return err + } + exists, err := rt.ImageExists(ctx, img) + // ... +} +``` + +### M4 — No concurrency control for local image builds +**File:** `pkg/hub/system_handlers.go` (~line 445) in `handleSystemImagesBuild` + +Multiple concurrent POST requests spawn multiple `build-images.sh` processes simultaneously, overwhelming the workstation. + +**Fix:** Add an atomic boolean to `Server` struct to track active build: +```go +// on Server struct: +imageBuildActive atomic.Bool + +// in handleSystemImagesBuild: +if !s.imageBuildActive.CompareAndSwap(false, true) { + http.Error(w, "a build is already in progress", http.StatusConflict) + return +} +defer s.imageBuildActive.Store(false) +``` + +--- + +## Commit Instructions + +- `fix: handle top-level pull error events to prevent infinite spinner (H1)` +- `fix: normalize Windows path separators and drive letter breadcrumbs in dir-browser (H2, H3, M1)` +- `fix: check scanner.Err after build log scan loop (M2)` +- `fix: check context cancellation in image pull loop (M3)` +- `fix: add concurrency guard for concurrent image build requests (M4)` +- Run `go build ./...` and `go vet ./...` before committing Go changes +- Do not open PRs — commit directly to `workstation-improvements` diff --git a/.tasks/rebase-workstation-improvements.md b/.tasks/rebase-workstation-improvements.md new file mode 100644 index 000000000..36673e909 --- /dev/null +++ b/.tasks/rebase-workstation-improvements.md @@ -0,0 +1,83 @@ +# Rebase workstation-improvements onto latest upstream main + +## Goal +Rebase `ptone/scion:workstation-improvements` onto the latest `GoogleCloudPlatform/scion:main`, resolve all conflicts carefully, verify the build, and force-push. + +## Setup +```bash +git remote -v # verify remotes; 'origin' = GoogleCloudPlatform/scion, 'ptone' = ptone/scion +git fetch origin +git fetch ptone +git checkout workstation-improvements # or: git checkout ptone/workstation-improvements -b workstation-improvements +git rebase origin/main +``` + +## What's on the workstation-improvements branch (preserve all of this) + +The branch adds a complete **workstation onboarding experience** on top of upstream main. Key additions: + +### New files (ours, no upstream equivalent — no conflict expected) +- `pkg/daemon/ports.go` — detectOccupiedPorts() for phantom daemon detection +- `pkg/hub/system_handlers.go` — all `/system/*` API endpoints (status, check, runtime, init, identity, images, fs/*) +- `pkg/hub/system_identity.go` — PUT /system/identity handler +- `pkg/hub/fs_safety.go` — ClassifyPath() for linked grove path validation +- `pkg/runtime/imagepull.go` — Go-native image pull +- `web/src/components/pages/onboarding.ts` — the full wizard (7 steps) +- `web/src/components/shared/dir-browser.ts` — directory browser component +- `.tasks/` directory — task briefs (safe to keep or drop) +- `.design/workstation-onboarding*.md`, `.design/linked-groves-ui.md` — design docs + +### Modified files (likely conflict zones) + +**`cmd/root.go`** — We added `usesWorktrees(cmd)` helper and wrapped the git version check so it only fires for `start`/`run` commands. Look for the `if util.IsGitRepo() && usesWorktrees(cmd)` block and the `usesWorktrees` function near the bottom. If upstream touched this file, resolve by keeping both upstream changes AND our `usesWorktrees` guard. + +**`cmd/server_daemon.go`** — We added: +- `needsOnboarding` captured BEFORE `daemon.StartComponent()` (race condition fix) +- `printWorkstationQuickstart` signature change: takes `needsOnboarding bool` + `globalDir string` instead of just `globalDir string` +- Port conflict detection: calls `detectOccupiedPorts(cfg)` before starting +If upstream touched server_daemon.go, carefully merge keeping both sets of changes. + +**`cmd/server_foreground.go`** — We changed: +- `productionMode` → `hostedMode` in the `Workstation: !hostedMode` line +- Added `if hostedMode { log.Println("WARNING...") }` (dev-auth warning only in hosted mode) +- Removed the unconditional "WARNING: Development authentication enabled" log line +If upstream refactored this file, resolve: keep upstream structure + our Workstation flag assignment + our warning suppression. + +**`cmd/server.go`** — We added `--force` flag to the `stop` command (calls `detectOccupiedPorts` and kills by port). + +**`pkg/hub/server.go`** — We added `Workstation bool` to `ServerConfig`, `s.workstation bool` field, `requireWorkstation` and `assertLoopback` helpers, `GetEmbeddedBrokerID()`, server-lifetime context (`s.ctx`, `s.ctxCancel`). Also `seedDevUser` call now passes `cfg.DevUserConfig`. + +**`pkg/hub/web.go`** — We changed dev auto-login (around line 1142) to read display name/email from `ws.store.GetUser(DevUserID)` instead of hardcoding "Development User"/"dev@localhost". + +**`pkg/hub/seed.go`** — We changed `seedDevUser` signature to accept `DevUserConfig` and use `NewDevUser(cfg)` for initial values instead of hardcoding. + +**`pkg/hub/auth.go`** — We removed a `devUser := devUser` self-shadow line. + +**`pkg/config/settings_v1.go`**, **`pkg/config/hub_config.go`** — We added `Username`, `DisplayName`, `Email` fields to `DevAuthConfig`/`V1AuthConfig`. + +**`pkg/hub/devauth.go`** — We updated `DevUser` construction to use config values and OS user fallback. + +**`web/src/components/app-shell.ts`** — We added `'/onboarding': 'Setup'` to PAGE_TITLES. + +**`web/src/components/pages/project-create.ts`** — We added the "linked local directory" third mode with `scion-dir-browser`. + +## Conflict resolution principles + +1. **Always keep our new files** — if upstream added similar functionality, compare carefully, but our `/system/*` handlers are unique. +2. **For modified files**: keep ALL upstream changes (bug fixes, new features) AND ALL our changes. Don't drop either side. +3. **When upstream reformatted/refactored a file we also modified**: apply our logical changes to the new upstream structure. +4. **After resolving each file**: run `go build ./pkg/...` for Go files, check TypeScript compiles for web files. +5. **After all conflicts resolved**: `go build ./... && go vet ./...` must pass before pushing. + +## Push +```bash +git push ptone workstation-improvements --force +``` + +The `ptone` remote should already be configured (https://github.com/ptone/scion.git). If not, add it: +```bash +git remote add ptone https://github.com/ptone/scion.git +``` + +## Done +Report the commits rebased, any conflicts resolved, and confirm build passes. diff --git a/.tasks/review-fixes-round-1.md b/.tasks/review-fixes-round-1.md new file mode 100644 index 000000000..839c20e83 --- /dev/null +++ b/.tasks/review-fixes-round-1.md @@ -0,0 +1,100 @@ +# Review Round 1 — Fix MAJOR Issues + +**Branch:** workstation-improvements +**Review doc:** `.scratch/review-round-1.md` +**Commit all fixes to the current branch.** + +Fix the 3 MAJOR issues and the 5 MINOR issues from the code review. + +--- + +## M1 — Home-directory fence: sibling-prefix bypass (MUST FIX) + +**File:** `pkg/hub/system_handlers.go` — `handleFSList` (~L470) and `handleFSMkdir` (~L560) + +**Problem:** `strings.HasPrefix(resolved, home)` allows `/home/alice-backup` when `home=/home/alice`. + +**Fix:** Replace the home-boundary check in both handlers with: +```go +sep := string(filepath.Separator) +if resolved != home && !strings.HasPrefix(resolved, home+sep) { + http.Error(w, "path must be within the home directory", http.StatusForbidden) + return +} +``` + +Also add a sibling-prefix test case to `TestFSList_OutsideHome` in the test file. + +--- + +## M2 — `PUT /system/runtime` writes runtime into `active_profile` (MUST FIX) + +**File:** `pkg/hub/system_handlers.go` — `handlePutRuntime` (~L165) + +**Problem:** `config.UpdateSetting("", "active_profile", req.Runtime, true)` sets a profile *name* to a runtime value like `"docker"` — wrong field, inconsistent with GET. + +**Fix:** Instead of overwriting `active_profile`, update the runtime field of the *current active profile*. Look at how `V1Settings.Profiles` is structured in `pkg/config/settings_v1.go`. The correct approach is: +1. Load the current settings +2. Get the active profile name (`vs.ActiveProfile`) +3. Set `vs.Profiles[activeProfile].Runtime = req.Runtime` (or the equivalent field name) +4. Save settings + +If the profile's runtime field has a different path/key in UpdateSetting, use the correct dotted path like `"runtimes..type"` or whatever the schema uses. Look at the existing settings YAML structure to find the right key. + +Also ensure `handleGetRuntime` reads from the same location so GET and PUT are consistent. + +--- + +## M3 — `fs/validate-path` has no home fence (MUST FIX) + +**File:** `pkg/hub/system_handlers.go` — `handleFSValidatePath` + +**Context:** The design explicitly requires linked groves to work with arbitrary paths (outside `$HOME` even), since users may have projects on external drives. However, the asymmetry with `fs/list` and `fs/mkdir` is surprising and should be explicit. + +**Fix:** Add a comment in `handleFSValidatePath` explicitly documenting that this endpoint intentionally has no home-boundary fence, since linked groves can be anywhere on disk. Also add `assertLoopback` to this handler if it's not already there (verify). The managed-path overlap check in `ClassifyPath` is sufficient for its safety guarantee. + +--- + +## Minor fixes (also implement these) + +### m1 — Build script path from CWD +**File:** `pkg/hub/system_handlers.go` — `handleSystemImagesBuild` + +Replace `os.Getwd()` with the binary's executable path: +```go +exe, err := os.Executable() +if err != nil { ... } +buildScript := filepath.Join(filepath.Dir(exe), "..", "image-build", "scripts", "build-images.sh") +``` +Or if that's not appropriate for the install layout, check `SCION_ROOT` env var first, then fall back to a documented path. At minimum, emit a clear error message if the script is not found (not a silent 404). + +### m2 — ClassifyPath project cap +**File:** `pkg/hub/fs_safety.go` + +Change the hard `Limit: 500` to a larger value (e.g. 10000) or add a comment acknowledging the cap. This is low-priority but document the limitation. + +### m3 — Use `apiFetch` in project-create.ts +**File:** `web/src/components/pages/project-create.ts` (~L198) + +Replace the bare `fetch()` call for the providers POST with `apiFetch()` (or whatever the project's authenticated fetch wrapper is — look at how other pages make API calls). + +### m4 — Fire-and-forget goroutines +**File:** `pkg/hub/system_handlers.go` (images/pull ~L300, images/build ~L420) + +Replace `context.Background()` with a server-lifetime context (store one on the server struct, or use `r.Context()` passed through). For the pull goroutine, emit a terminal SSE event on overall failure (e.g. `{ "status": "error", "error": "top-level failure message" }`). + +### m5 — Cosmetic +**File:** `pkg/hub/devauth.go` (~L207) + +Remove `devUser := devUser` self-shadow. + +--- + +## Commit Instructions + +- `fix: correct home-directory boundary check in fs/list and fs/mkdir (M1)` +- `fix: write runtime to active profile runtime field not active_profile key (M2)` +- `fix: document intentional unfenced validate-path + assertLoopback (M3)` +- `fix: address minor review findings (m1-m5)` +- Run `go build ./...` and `go vet ./...` before committing +- Do not open PRs — commit directly to `workstation-improvements` diff --git a/.tasks/review-fixes-round-2.md b/.tasks/review-fixes-round-2.md new file mode 100644 index 000000000..19e947bca --- /dev/null +++ b/.tasks/review-fixes-round-2.md @@ -0,0 +1,52 @@ +# Review Round 2 — Fix Remaining Minor Issues + +**Branch:** workstation-improvements +**Review doc:** `.scratch/review-round-2.md` +**Commit all fixes to the current branch.** + +Fix these 3 remaining minor issues that were either not applied or partially applied in round 1: + +--- + +## m4 — Replace `context.Background()` with server-lifetime context (PARTIAL FIX NEEDED) + +**File:** `pkg/hub/system_handlers.go` — pull goroutine (~L418) and build goroutine (~L496) + +Both goroutines still use `context.Background()`, meaning they cannot be cancelled on server shutdown. + +**Fix:** The server struct likely has a context or done channel. Look for a `ctx context.Context` field on the server struct, or a `shutdownCtx`. Use that instead of `context.Background()` in both goroutines. If no server-lifetime context exists, use `context.WithCancel` tied to the server's `Close()`/`Shutdown()` method — store the cancel func on the server struct. + +--- + +## m5 — Remove `devUser := devUser` self-shadow + +**File:** `pkg/hub/auth.go` line 207 (approximately) + +Search for `devUser := devUser` in `pkg/hub/auth.go` and remove the redundant self-assignment. The variable is already in scope. + +--- + +## N1 — M2 empty-ActiveProfile edge case in `handlePutRuntime` + +**File:** `pkg/hub/system_handlers.go` — `handlePutRuntime` (~L189-191) + +When `vs.ActiveProfile == ""`, the handler falls back to writing to `"default"` profile but doesn't set `vs.ActiveProfile`. Then `handleGetRuntime` reads `vs.Profiles[""]` (empty key, not found) and returns `configured == ""` — inconsistent with the PUT. + +**Fix:** Apply the same `"default"` fallback in `handleGetRuntime` when `vs.ActiveProfile == ""`: +```go +activeProfile := vs.ActiveProfile +if activeProfile == "" { + activeProfile = "default" +} +profile := vs.Profiles[activeProfile] +``` +This makes GET and PUT use the same fallback key consistently. + +--- + +## Commit Instructions + +- `fix: use server-lifetime context in image pull/build goroutines (m4)` +- `fix: remove devUser self-shadow in auth.go (m5) and fix empty-ActiveProfile GET/PUT inconsistency (N1)` +- Run `go build ./...` and `go vet ./...` before committing +- Do not open PRs — commit directly to `workstation-improvements` diff --git a/README.md b/README.md index 6d12f1fd8..25039e114 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,18 @@ The visualization above replays the actual telemetry collected from messages and ## Quick Start -Sadly - as an open source project we are not yet able to provide pre-built binaries or containers. You will need to [build images](https://googlecloudplatform.github.io/scion/getting-started/install/#build-container-images) first. +### Workstation Quick Start (Homebrew) -### Install +```bash +brew install scion +scion server start +``` + +Your browser will open to the onboarding wizard at `http://127.0.0.1:9810/onboarding`, which walks you through machine setup, runtime detection, harness selection, and creating your first project. -See the full [Installation Guide](https://googlecloudplatform.github.io/scion/getting-started/install/), or install from source, requires golang: +### Install from Source + +See the full [Installation Guide](https://googlecloudplatform.github.io/scion/getting-started/install/), or install from source (requires Go 1.22+): ```bash go install github.com/GoogleCloudPlatform/scion/cmd/scion@latest @@ -34,7 +41,9 @@ go install github.com/GoogleCloudPlatform/scion/cmd/scion@latest ### Initialize your machine and a Project (project) -Navigate to your project and create a Scion project (the `.scion` directory that holds agent config) - use the registry where you built images: +> **Tip:** If you used `scion server start` above, the onboarding wizard handles machine initialization automatically — you can skip this section. + +Navigate to your project and create a Scion project (the `.scion` directory that holds agent config): ```bash scion init --machine @@ -44,7 +53,7 @@ scion init > **Tip:** Add `.scion/agents` to your `.gitignore` to avoid issues with nested git worktrees. -Scion auto-detects your OS and configures the default runtime (Docker on Linux/Windows, Container on macOS). Override this in `.scion/settings.json`. +Scion auto-detects your OS and configures the default runtime (Docker on Linux/Windows, Container on macOS). Override this in `.scion/settings.yaml`. **NOTE** Currently this project is early and experimental. Most of the concepts are settled in, but many features may not be fully implemented, anything might break or change and the future is not set. Local use is relatively stable, Hub based workflows now highly usable, Kubernetes runtime support still has rough edges. diff --git a/cmd/message_test.go b/cmd/message_test.go index 7ced8b21e..50e893613 100644 --- a/cmd/message_test.go +++ b/cmd/message_test.go @@ -75,6 +75,32 @@ func newMessageMockHubServer(t *testing.T, projectID string, runningAgents []hub "agents": runningAgents, }) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/projects/"+projectID+"/broadcast": + var body struct { + StructuredMessage *messages.StructuredMessage `json:"structured_message"` + Interrupt bool `json:"interrupt"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + for _, a := range runningAgents { + sm := sentMessage{ + AgentName: a.Name, + StructuredMsg: body.StructuredMessage, + Interrupt: body.Interrupt, + } + if body.StructuredMessage != nil { + sm.Message = body.StructuredMessage.Msg + } + mu.Lock() + sent = append(sent, sm) + mu.Unlock() + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "accepted", + "total": len(runningAgents), + "targeted": len(runningAgents), + "skipped": 0, + }) + case r.Method == http.MethodPost: // Extract agent name from path: /api/v1/projects//agents//message // or /api/v1/groves//agents//message (legacy) @@ -387,59 +413,23 @@ func TestSendMessageViaHub_BroadcastPartialFailure(t *testing.T) { defer orig.restore() projectID := "grove-msg-partial" - agents := []hubclient.Agent{ - {Name: "good-agent", Status: "running"}, - {Name: "bad-agent", Status: "running"}, - } - var sent []sentMessage - var mu sync.Mutex - // Server that succeeds for good-agent but fails for bad-agent server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.URL.Path == "/healthz": json.NewEncoder(w).Encode(map[string]interface{}{"status": "ok"}) - case r.Method == http.MethodGet: - json.NewEncoder(w).Encode(map[string]interface{}{"agents": agents}) - case r.Method == http.MethodPost: - projectPrefix := "/api/v1/projects/" + projectID + "/agents/" - grovePrefix := "/api/v1/groves/" + projectID + "/agents/" - var agentName string - path := r.URL.Path - if len(path) > len(projectPrefix) && path[:len(projectPrefix)] == projectPrefix { - rest := path[len(projectPrefix):] - agentName = rest[:len(rest)-len("/message")] - } else if len(path) > len(grovePrefix) && path[:len(grovePrefix)] == grovePrefix { - rest := path[len(grovePrefix):] - agentName = rest[:len(rest)-len("/message")] - } - - if agentName == "bad-agent" { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]interface{}{ - "error": map[string]interface{}{"code": "internal", "message": "error"}, - }) - return - } - - var body struct { - StructuredMessage *messages.StructuredMessage `json:"structured_message"` - Message string `json:"message"` - Interrupt bool `json:"interrupt"` - } - json.NewDecoder(r.Body).Decode(&body) - msg := body.Message - if body.StructuredMessage != nil { - msg = body.StructuredMessage.Msg - } - mu.Lock() - sent = append(sent, sentMessage{AgentName: agentName, Message: msg}) - mu.Unlock() - - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]interface{}{"status": "ok"}) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/projects/"+projectID+"/broadcast": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "accepted", + "total": 2, + "targeted": 1, + "skipped": 1, + "skipped_breakdown": map[string]int{ + "stopped": 1, + }, + }) default: w.WriteHeader(http.StatusNotFound) } @@ -455,13 +445,9 @@ func TestSendMessageViaHub_BroadcastPartialFailure(t *testing.T) { ProjectID: projectID, } - // Broadcast should not return an error on partial failure + // Broadcast should not return an error on partial delivery err = sendMessageViaHub(hubCtx, "", "test", false, true, false, false, false) require.NoError(t, err) - - // Only the good agent should have received the message - assert.Len(t, sent, 1) - assert.Equal(t, "good-agent", sent[0].AgentName) } func TestResolveSenderIdentity_AgentContext(t *testing.T) { @@ -743,31 +729,31 @@ func TestSetRecipientFlagValidation(t *testing.T) { name: "set with raw not allowed", args: []string{"set[agent:a,agent:b]", "hello"}, raw: true, - wantErr: "--raw cannot be used with set[] recipients", + wantErr: "--raw cannot be used with group[] recipients", }, { name: "set with broadcast not allowed", args: []string{"set[agent:a,agent:b]", "hello"}, broadcast: true, - wantErr: "set[] recipients cannot be combined with --broadcast or --all", + wantErr: "group[] recipients cannot be combined with --broadcast or --all", }, { name: "set with all not allowed", args: []string{"set[agent:a,agent:b]", "hello"}, all: true, - wantErr: "set[] recipients cannot be combined with --broadcast or --all", + wantErr: "group[] recipients cannot be combined with --broadcast or --all", }, { name: "set with in not allowed", args: []string{"set[agent:a,agent:b]", "hello"}, in: "30m", - wantErr: "--in/--at cannot be used with set[] recipients", + wantErr: "--in/--at cannot be used with group[] recipients", }, { name: "set with notify not allowed", args: []string{"set[agent:a,agent:b]", "hello"}, notify: true, - wantErr: "--notify cannot be used with set[] recipients", + wantErr: "--notify cannot be used with group[] recipients", }, { name: "invalid set", @@ -918,14 +904,14 @@ func TestSendGroupMessageViaHub_RequiresHub(t *testing.T) { orig := saveMessageTestState() defer orig.restore() - // set[] without Hub should fail at the RunE level, not get to sendGroupMessageViaHub + // group[] without Hub should fail at the RunE level, not get to sendGroupMessageViaHub origBroadcast, origAll := msgBroadcast, msgAll defer func() { msgBroadcast = origBroadcast; msgAll = origAll }() msgBroadcast = false msgAll = false err := messageCmd.RunE(messageCmd, []string{"set[agent:a,agent:b]", "hello"}) - // When Hub is not configured, this should fail with "set[] recipients require Hub mode". + // When Hub is not configured, this should fail with "group[] recipients require Hub mode". // When Hub is configured but test agents don't exist, delivery fails. // Either way, an error must be returned — never silent nil. require.Error(t, err) diff --git a/cmd/root.go b/cmd/root.go index 1b9c9b746..8df369ca7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -65,7 +65,9 @@ return an error instead of blocking.`, projectPath = "global" } - if util.IsGitRepo() { + // Only check git version for commands that create worktrees (agent-related). + // Server, config, hub, and info commands never use worktrees. + if util.IsGitRepo() && usesWorktrees(cmd) { if err := util.CheckGitVersion(); err != nil { return fmt.Errorf("git check failed: %w", err) } @@ -429,6 +431,17 @@ func checkAgentContainerContext(cmd *cobra.Command) error { ) } +// usesWorktrees returns true if the given command (or its parent) creates git +// worktrees — i.e. agent launch commands. Server, config, hub, and info +// commands never create worktrees and should not be blocked by a git version check. +func usesWorktrees(cmd *cobra.Command) bool { + switch cmd.Name() { + case "start", "run": // agent launch + return true + } + return false +} + // isLocalEndpoint returns true if the given endpoint URL points to a local address // (localhost, 127.0.0.1, ::1, or 0.0.0.0). func isLocalEndpoint(endpoint string) bool { diff --git a/cmd/server.go b/cmd/server.go index 5fd5072f7..f3f5e90c8 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -58,6 +58,7 @@ var ( // Server daemon flags serverStartForeground bool + stopForce bool // Hosted mode flag (replaces former "production" mode) hostedMode bool @@ -277,6 +278,9 @@ func init() { // Admin bootstrap flags serverStartCmd.Flags().StringVar(&adminEmails, "admin-emails", "", "Comma-separated list of email addresses to auto-promote to admin role") + // Stop flags + serverStopCmd.Flags().BoolVar(&stopForce, "force", false, "Kill any process listening on the server ports, even without a PID file") + // Status flags serverStatusCmd.Flags().BoolVar(&serverStatusJSON, "json", false, "Output in JSON format") diff --git a/cmd/server_daemon.go b/cmd/server_daemon.go index 91bad4bad..a2e2bb137 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" ) @@ -48,6 +49,15 @@ func runServerStartOrDaemon(cmd *cobra.Command, args []string) error { pid, daemon.GetLogPathComponent(serverDaemonComponent, globalDir)) } + // Check for phantom processes holding server ports even without a PID file + serverPorts := collectServerPorts(cmd) + if phantomPorts := daemon.DetectOccupiedPorts(serverPorts); len(phantomPorts) > 0 { + fmt.Fprintf(os.Stderr, "Error: the following ports are already in use: %v\n", phantomPorts) + fmt.Fprintf(os.Stderr, "A previous server process may be running without a PID file.\n") + fmt.Fprintf(os.Stderr, "Run 'scion server stop --force' to kill any process on these ports.\n") + return fmt.Errorf("port conflict: ports %v are occupied", phantomPorts) + } + // Check if hosted mode is set in config (settings.yaml server.mode). // LoadServerMode() normalizes the legacy "production" value to "hosted". if !cmd.Flags().Changed("hosted") && !cmd.Flags().Changed("production") { @@ -123,6 +133,11 @@ func runServerStartOrDaemon(cmd *cobra.Command, args []string) error { daemonArgs = append(daemonArgs, "--global") } + // Capture onboarding state BEFORE starting the daemon — the child process + // calls InitGlobal() on startup which creates settings.yaml, so checking + // afterwards would always see the file as present. + needsOnboarding := !hostedMode && config.GetSettingsPath(globalDir) == "" + // Start daemon mode := "workstation" if hostedMode { @@ -152,7 +167,7 @@ func runServerStartOrDaemon(cmd *cobra.Command, args []string) error { // Print quickstart info for workstation mode if !hostedMode { - printWorkstationQuickstart(globalDir, hubHost, webPort, enableWeb, enableDevAuth) + printWorkstationQuickstart(needsOnboarding, globalDir, hubHost, webPort, enableWeb, enableDevAuth) } fmt.Println("Use 'scion server stop' to stop the daemon.") @@ -168,6 +183,11 @@ func runServerStop(cmd *cobra.Command, args []string) error { } running, pid, _ := daemon.StatusComponent(serverDaemonComponent, globalDir) + + if stopForce { + return runServerStopForce(globalDir, running, pid) + } + if !running { return fmt.Errorf("server daemon is not running") } @@ -189,6 +209,49 @@ func runServerStop(cmd *cobra.Command, args []string) error { return nil } +func runServerStopForce(globalDir string, pidRunning bool, pid int) error { + killed := false + + // If PID file exists and process is running, stop it normally first. + if pidRunning { + fmt.Printf("Stopping server daemon (PID: %d)...\n", pid) + if err := daemon.StopComponent(serverDaemonComponent, globalDir); err == nil { + time.Sleep(500 * time.Millisecond) + killed = true + } + } + + // Probe default server ports and kill any process holding them. + ports := []int{8080, 9800, 9810} + occupied := daemon.DetectOccupiedPorts(ports) + if len(occupied) == 0 && !killed { + fmt.Println("No running server found.") + return nil + } + + for _, port := range occupied { + killedPID, err := daemon.ForceKillPort(port) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to kill process on port %d: %v\n", port, err) + continue + } + if killedPID > 0 { + fmt.Printf("Killed process %d on port %d\n", killedPID, port) + killed = true + } + } + + // Clean up stale PID file + _ = daemon.RemovePIDComponent(serverDaemonComponent, globalDir) + + if killed { + fmt.Println("Server stopped (forced).") + } else { + fmt.Println("No running server found.") + } + return nil +} + func runServerRestart(cmd *cobra.Command, args []string) error { globalDir, err := config.GetGlobalDir() if err != nil { @@ -359,14 +422,29 @@ 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. -func printWorkstationQuickstart(globalDir string, host string, wPort int, webEnabled, devAuth bool) { +// 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(needsOnboarding bool, 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 hadn't been set up before daemon start. + // This state is captured before the daemon launches (which auto-creates settings.yaml). + path := "" + if needsOnboarding { + 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 { @@ -376,10 +454,27 @@ 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) } } } fmt.Println() } + +// collectServerPorts returns the list of TCP ports the server would bind based +// on the flags the user passed (or their defaults). +func collectServerPorts(cmd *cobra.Command) []int { + seen := map[int]bool{} + var ports []int + add := func(p int) { + if !seen[p] { + seen[p] = true + ports = append(ports, p) + } + } + add(webPort) + add(hubPort) + add(runtimeBrokerPort) + return ports +} diff --git a/cmd/server_foreground.go b/cmd/server_foreground.go index 25d952229..e7644e5b6 100644 --- a/cmd/server_foreground.go +++ b/cmd/server_foreground.go @@ -28,7 +28,6 @@ import ( "path/filepath" "strings" "sync" - "syscall" "time" "github.com/google/uuid" @@ -158,7 +157,7 @@ func runServerStart(cmd *cobra.Command, args []string) error { defer cancel() sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + signal.Notify(sigCh, os.Interrupt) go func() { sig := <-sigCh @@ -198,6 +197,9 @@ func runServerStart(cmd *cobra.Command, args []string) error { if err != nil { return err } + if hostedMode { + log.Println("WARNING: Development authentication enabled - not for production use") + } } // 10. Resolve hub endpoint @@ -476,7 +478,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) } } @@ -843,8 +845,7 @@ func initDevAuth(cfg *config.GlobalConfig, globalDir string) (string, error) { os.Setenv("SCION_DEV_TOKEN", devAuthToken) 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) @@ -964,9 +965,15 @@ func initHubServer(ctx context.Context, cfg *config.GlobalConfig, s store.Store, SoftDeleteRetainFiles: cfg.Hub.SoftDeleteRetainFiles, AdminMode: adminMode, MaintenanceMessage: maintenanceMessage, - TelemetryDefault: cfg.TelemetryEnabled, - TelemetryConfig: config.ConvertV1TelemetryToAPI(cfg.TelemetryConfig), - BrokerAuthConfig: hub.DefaultBrokerAuthConfig(), + Workstation: !hostedMode, + 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(), GitHubAppConfig: hub.GitHubAppServerConfig{ AppID: cfg.GitHubApp.AppID, PrivateKeyPath: cfg.GitHubApp.PrivateKeyPath, diff --git a/cmd/server_workstation_test.go b/cmd/server_workstation_test.go index 54cd1d80f..074a818d4 100644 --- a/cmd/server_workstation_test.go +++ b/cmd/server_workstation_test.go @@ -229,7 +229,7 @@ func TestPrintWorkstationQuickstart(t *testing.T) { r, w, _ := os.Pipe() os.Stdout = w - printWorkstationQuickstart(dir, "127.0.0.1", 8080, true, true) + printWorkstationQuickstart(false, dir, "127.0.0.1", 8080, true, true) w.Close() os.Stdout = old @@ -249,7 +249,7 @@ func TestPrintWorkstationQuickstart_NoWeb(t *testing.T) { r, w, _ := os.Pipe() os.Stdout = w - printWorkstationQuickstart(dir, "127.0.0.1", 8080, false, false) + printWorkstationQuickstart(false, dir, "127.0.0.1", 8080, false, false) w.Close() os.Stdout = old @@ -269,7 +269,7 @@ func TestPrintWorkstationQuickstart_WildcardHost(t *testing.T) { r, w, _ := os.Pipe() os.Stdout = w - printWorkstationQuickstart(dir, "0.0.0.0", 9090, true, false) + printWorkstationQuickstart(false, dir, "0.0.0.0", 9090, true, false) w.Close() os.Stdout = old 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/hack/check-project-compat-literals.sh b/hack/check-project-compat-literals.sh index 2c4b4cd10..ed6cd1759 100755 --- a/hack/check-project-compat-literals.sh +++ b/hack/check-project-compat-literals.sh @@ -86,6 +86,7 @@ allowed_paths=( "^pkg/config/v7_fixes_test.go$" "^pkg/hub/capability_marshal_test.go$" "^pkg/hub/events_postgres_test.go$" + "^pkg/hub/fs_safety_test.go$" "^pkg/hub/handlers_broker_inbound_test.go$" "^pkg/hub/handlers_project_test.go$" "^pkg/hub/heartbeat_legacy_test.go$" @@ -160,6 +161,7 @@ allowed_paths=( "^pkg/config/templates.go$" "^pkg/hub/events.go$" "^pkg/hub/events_postgres.go$" + "^pkg/hub/fs_safety.go$" "^pkg/hub/handlers.go$" "^pkg/hub/handlers_auth.go$" "^pkg/hub/handlers_broker_inbound.go$" @@ -169,6 +171,7 @@ allowed_paths=( "^pkg/hub/project_webdav.go$" "^pkg/hub/response_types.go$" "^pkg/hub/server.go$" + "^pkg/hub/system_handlers.go$" "^pkg/hub/template_handlers.go$" "^pkg/hub/web.go$" "^pkg/hubclient/agents.go$" diff --git a/pkg/config/hub_config.go b/pkg/config/hub_config.go index bff923b37..6cf68d8de 100644 --- a/pkg/config/hub_config.go +++ b/pkg/config/hub_config.go @@ -210,6 +210,12 @@ 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. diff --git a/pkg/config/init_test.go b/pkg/config/init_test.go index 640223ec7..2c4544dc1 100644 --- a/pkg/config/init_test.go +++ b/pkg/config/init_test.go @@ -95,7 +95,7 @@ func TestGenerateProjectIDForDir_NoGitRepo(t *testing.T) { func TestIsInsideProject(t *testing.T) { // Unset Hub context to avoid synthetic project root detection - for _, e := range []string{"SCION_HUB_ENDPOINT", "SCION_HUB_URL", "SCION_GROVE_ID"} { + for _, e := range []string{"SCION_HUB_ENDPOINT", "SCION_HUB_URL", "SCION_GROVE_ID", "SCION_PROJECT_ID"} { if val, ok := os.LookupEnv(e); ok { os.Unsetenv(e) defer os.Setenv(e, val) diff --git a/pkg/config/paths_test.go b/pkg/config/paths_test.go index 4cbb922a6..ddd39c3af 100644 --- a/pkg/config/paths_test.go +++ b/pkg/config/paths_test.go @@ -188,6 +188,7 @@ func TestRequireProjectPath_NoProjectError(t *testing.T) { t.Setenv("SCION_HUB_ENDPOINT", "") t.Setenv("SCION_HUB_URL", "") t.Setenv("SCION_GROVE_ID", "") + t.Setenv("SCION_PROJECT_ID", "") if err := os.Chdir(tmpDir); err != nil { t.Fatal(err) @@ -273,6 +274,7 @@ func TestFindProjectRoot_HubContextNoScion_Disabled(t *testing.T) { t.Setenv("SCION_HUB_ENDPOINT", "") t.Setenv("SCION_HUB_URL", "") t.Setenv("SCION_GROVE_ID", "") + t.Setenv("SCION_PROJECT_ID", "") if err := os.Chdir(tmpDir); err != nil { t.Fatal(err) diff --git a/pkg/config/project_discovery_test.go b/pkg/config/project_discovery_test.go index 1c069fc4a..f925b60f1 100644 --- a/pkg/config/project_discovery_test.go +++ b/pkg/config/project_discovery_test.go @@ -64,7 +64,7 @@ func TestDiscoverProjects_GlobalOnly(t *testing.T) { func TestDiscoverProjects_ExternalProject(t *testing.T) { // Unset Hub environment variables to avoid pollution - for _, e := range []string{"SCION_HUB_ENDPOINT", "SCION_HUB_URL", "SCION_HUB_TOKEN", "SCION_GROVE_ID", "SCION_HUB_GROVE_ID", "SCION_OTEL_ENDPOINT", "SCION_OTEL_PROTOCOL"} { + for _, e := range []string{"SCION_HUB_ENDPOINT", "SCION_HUB_URL", "SCION_HUB_TOKEN", "SCION_GROVE_ID", "SCION_HUB_GROVE_ID", "SCION_OTEL_ENDPOINT", "SCION_OTEL_PROTOCOL", "SCION_PROJECT_ID"} { if val, ok := os.LookupEnv(e); ok { os.Unsetenv(e) defer os.Setenv(e, val) @@ -129,7 +129,7 @@ func TestDiscoverProjects_ExternalProject(t *testing.T) { func TestDiscoverProjects_OrphanedExternal(t *testing.T) { // Unset Hub environment variables to avoid pollution - for _, e := range []string{"SCION_HUB_ENDPOINT", "SCION_HUB_URL", "SCION_HUB_TOKEN", "SCION_GROVE_ID", "SCION_HUB_GROVE_ID", "SCION_OTEL_ENDPOINT", "SCION_OTEL_PROTOCOL"} { + for _, e := range []string{"SCION_HUB_ENDPOINT", "SCION_HUB_URL", "SCION_HUB_TOKEN", "SCION_GROVE_ID", "SCION_HUB_GROVE_ID", "SCION_OTEL_ENDPOINT", "SCION_OTEL_PROTOCOL", "SCION_PROJECT_ID"} { if val, ok := os.LookupEnv(e); ok { os.Unsetenv(e) defer os.Setenv(e, val) @@ -350,7 +350,7 @@ func TestProjectInfo_AgentsDir(t *testing.T) { func TestDiscoverProjects_StaleExternalAfterMarkerRecreate(t *testing.T) { // Unset Hub environment variables to avoid pollution - for _, e := range []string{"SCION_HUB_ENDPOINT", "SCION_HUB_URL", "SCION_HUB_TOKEN", "SCION_GROVE_ID", "SCION_HUB_GROVE_ID", "SCION_OTEL_ENDPOINT", "SCION_OTEL_PROTOCOL"} { + for _, e := range []string{"SCION_HUB_ENDPOINT", "SCION_HUB_URL", "SCION_HUB_TOKEN", "SCION_GROVE_ID", "SCION_HUB_GROVE_ID", "SCION_OTEL_ENDPOINT", "SCION_OTEL_PROTOCOL", "SCION_PROJECT_ID"} { if val, ok := os.LookupEnv(e); ok { os.Unsetenv(e) defer os.Setenv(e, val) @@ -540,15 +540,17 @@ func TestDiscoverProjects_GitProjectWithExternalConfigUsesWorkspaceMarkerProject t.Fatalf("mkdir .scion: %v", err) } - if projectID, ok := os.LookupEnv("SCION_GROVE_ID"); ok { - if err := os.Unsetenv("SCION_GROVE_ID"); err != nil { - t.Fatalf("Unsetenv SCION_GROVE_ID failed: %v", err) - } - defer func() { - if err := os.Setenv("SCION_GROVE_ID", projectID); err != nil { - t.Fatalf("restore SCION_GROVE_ID failed: %v", err) + for _, envName := range []string{"SCION_GROVE_ID", "SCION_PROJECT_ID"} { + if val, ok := os.LookupEnv(envName); ok { + if err := os.Unsetenv(envName); err != nil { + t.Fatalf("Unsetenv %s failed: %v", envName, err) } - }() + defer func(name, v string) { + if err := os.Setenv(name, v); err != nil { + t.Fatalf("restore %s failed: %v", name, err) + } + }(envName, val) + } } projectDir := filepath.Join(tmpHome, ".scion", "project-configs", "newrepo__ccdd1122") diff --git a/pkg/config/project_marker_test.go b/pkg/config/project_marker_test.go index 2639386fe..1a69b921a 100644 --- a/pkg/config/project_marker_test.go +++ b/pkg/config/project_marker_test.go @@ -486,6 +486,7 @@ func TestIsHubContext(t *testing.T) { t.Setenv("SCION_HUB_ENDPOINT", "") t.Setenv("SCION_HUB_URL", "") t.Setenv("SCION_GROVE_ID", "") + t.Setenv("SCION_PROJECT_ID", "") if IsHubContext() { t.Error("expected IsHubContext() = false when no hub env vars are set") diff --git a/pkg/config/settings_v1.go b/pkg/config/settings_v1.go index 35bca51af..9f6f3bdf3 100644 --- a/pkg/config/settings_v1.go +++ b/pkg/config/settings_v1.go @@ -404,6 +404,9 @@ 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. @@ -1375,6 +1378,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 @@ -1534,6 +1546,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{ @@ -2016,6 +2031,32 @@ func UpdateVersionedSetting(dir string, key string, value string) error { } vs.Server.Broker.BrokerNickname = value + // --- Server auth identity --- + case "server.auth.display_name": + if vs.Server == nil { + vs.Server = &V1ServerConfig{} + } + if vs.Server.Auth == nil { + vs.Server.Auth = &V1AuthConfig{} + } + vs.Server.Auth.DisplayName = value + case "server.auth.email": + if vs.Server == nil { + vs.Server = &V1ServerConfig{} + } + if vs.Server.Auth == nil { + vs.Server.Auth = &V1AuthConfig{} + } + vs.Server.Auth.Email = value + case "server.auth.username": + if vs.Server == nil { + vs.Server = &V1ServerConfig{} + } + if vs.Server.Auth == nil { + vs.Server.Auth = &V1AuthConfig{} + } + vs.Server.Auth.Username = value + // --- Deprecated keys: skip silently in v1 --- case "hub.token", "hub.apiKey", "hub.lastSyncedAt": // These fields don't exist in v1 — skip without error diff --git a/pkg/daemon/daemon.go b/pkg/daemon/daemon.go index 5dce7db10..f9ef4bb2b 100644 --- a/pkg/daemon/daemon.go +++ b/pkg/daemon/daemon.go @@ -127,7 +127,7 @@ func StopComponent(component, globalDir string) error { return ErrNotRunning } - if err := process.Signal(syscall.SIGTERM); err != nil { + if err := process.Signal(os.Interrupt); err != nil { _ = RemovePIDComponent(component, globalDir) return ErrNotRunning } diff --git a/pkg/daemon/ports.go b/pkg/daemon/ports.go new file mode 100644 index 000000000..021df1168 --- /dev/null +++ b/pkg/daemon/ports.go @@ -0,0 +1,97 @@ +// 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 daemon + +import ( + "errors" + "fmt" + "net" + "os" + "os/exec" + "strconv" + "strings" + "time" +) + +// DetectOccupiedPorts probes each port by attempting to bind it. +// Returns the subset of ports that are already in use. +func DetectOccupiedPorts(ports []int) []int { + var occupied []int + for _, port := range ports { + ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + occupied = append(occupied, port) + continue + } + _ = ln.Close() + } + return occupied +} + +// FindPIDOnPort returns the PID of the process listening on the given TCP port, +// or 0 if no process is found. Uses lsof (macOS/Linux). +func FindPIDOnPort(port int) int { + out, err := exec.Command("lsof", "-ti", fmt.Sprintf(":%d", port)).Output() + if err != nil { + return 0 + } + lines := strings.Fields(strings.TrimSpace(string(out))) + if len(lines) == 0 { + return 0 + } + pid, err := strconv.Atoi(lines[0]) + if err != nil { + return 0 + } + return pid +} + +// ForceKillPort finds the process listening on the given port and kills it. +// Sends an interrupt first, waits up to 3 seconds, then force-kills if still running. +// Returns the PID that was killed, or 0 if no process was found. +func ForceKillPort(port int) (int, error) { + pid := FindPIDOnPort(port) + if pid == 0 { + return 0, nil + } + + process, err := os.FindProcess(pid) + if err != nil { + return pid, fmt.Errorf("failed to find process %d: %w", pid, err) + } + + // Try graceful shutdown via interrupt signal. + if err := process.Signal(os.Interrupt); err != nil { + _ = process.Kill() + return pid, nil + } + + // Wait up to 3 seconds for the process to release the port. + for i := 0; i < 6; i++ { + time.Sleep(500 * time.Millisecond) + if FindPIDOnPort(port) == 0 { + return pid, nil + } + } + + // Still running — force kill. + if err := process.Kill(); err != nil { + if errors.Is(err, os.ErrProcessDone) { + return pid, nil + } + return pid, fmt.Errorf("failed to kill PID %d: %w", pid, err) + } + return pid, nil +} diff --git a/pkg/hub/auth.go b/pkg/hub/auth.go index 2358e0df7..7b5d8bc22 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,6 @@ 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} 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/events.go b/pkg/hub/events.go index 2d644ce09..c8b7b9859 100644 --- a/pkg/hub/events.go +++ b/pkg/hub/events.go @@ -50,6 +50,7 @@ type EventPublisher interface { // 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 +72,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 +315,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() @@ -575,6 +582,13 @@ func (p *eventBuilder) PublishDispatchDone(_ context.Context, dispatchID string) }) } +// PublishRaw publishes an arbitrary event on the given subject. It is used by +// workstation features (e.g. image-pull progress) that emit ad-hoc SSE events +// not modeled by the typed Publish* methods. +func (p *eventBuilder) PublishRaw(subject string, data interface{}) { + p.sink(subject, data) +} + // subjectMatchesPattern checks if a subject matches a NATS-style pattern. // '*' matches exactly one token, '>' matches one or more remaining tokens. // Tokens are dot-separated. diff --git a/pkg/hub/fs_safety.go b/pkg/hub/fs_safety.go new file mode 100644 index 000000000..c95e2ee1f --- /dev/null +++ b/pkg/hub/fs_safety.go @@ -0,0 +1,147 @@ +// 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" +) + +// pathEqual compares two cleaned absolute paths for equality, +// case-insensitive on platforms with case-insensitive filesystems (macOS, Windows). +func pathEqual(a, b string) bool { + return strings.EqualFold(a, b) +} + +// pathHasPrefix checks whether path starts with prefix as a directory boundary, +// case-insensitive on platforms with case-insensitive filesystems (macOS, Windows). +func pathHasPrefix(path, prefix string) bool { + return strings.HasPrefix(strings.ToLower(path), strings.ToLower(prefix)) +} + +// 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 pathHasPrefix(resolved, cleanManaged+string(filepath.Separator)) || pathEqual(resolved, cleanManaged) { + pc.IsManaged = true + } + // Also check legacy groves path + legacyRoot := strings.Replace(cleanManaged, string(filepath.Separator)+"projects", string(filepath.Separator)+"groves", 1) + if !pathEqual(legacyRoot, cleanManaged) { + if pathHasPrefix(resolved, legacyRoot+string(filepath.Separator)) || pathEqual(resolved, legacyRoot) { + pc.IsManaged = true + } + } + } + + if s != nil && pc.IsDir { + // Cap at 10000 projects; installations with more will not detect duplicates beyond this limit. + result, err := s.ListProjects(ctx, store.ProjectFilter{}, store.ListOptions{Limit: 10000}) + 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 +} diff --git a/pkg/hub/fs_safety_test.go b/pkg/hub/fs_safety_test.go new file mode 100644 index 000000000..4c6501285 --- /dev/null +++ b/pkg/hub/fs_safety_test.go @@ -0,0 +1,216 @@ +// 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. + +//go:build !no_sqlite + +package hub + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/GoogleCloudPlatform/scion/pkg/store" +) + +func TestClassifyPath_NonExistent(t *testing.T) { + pc, err := ClassifyPath(context.Background(), nil, "/nonexistent/path/abcxyz123456", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pc.Exists { + t.Error("expected Exists=false for non-existent path") + } +} + +func TestClassifyPath_ExistingDir(t *testing.T) { + dir := t.TempDir() + pc, err := ClassifyPath(context.Background(), nil, dir, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !pc.Exists { + t.Error("expected Exists=true") + } + if !pc.IsDir { + t.Error("expected IsDir=true") + } + if pc.IsGit { + t.Error("expected IsGit=false for dir without .git") + } +} + +func TestClassifyPath_GitDir(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, ".git"), 0755); err != nil { + t.Fatal(err) + } + pc, err := ClassifyPath(context.Background(), nil, dir, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !pc.IsGit { + t.Error("expected IsGit=true for dir with .git") + } +} + +func TestClassifyPath_ManagedPath(t *testing.T) { + managedRoot := t.TempDir() + sub := filepath.Join(managedRoot, "myproject") + if err := os.Mkdir(sub, 0755); err != nil { + t.Fatal(err) + } + + pc, err := ClassifyPath(context.Background(), nil, sub, managedRoot) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !pc.IsManaged { + t.Error("expected IsManaged=true for path under managedRoot") + } +} + +func TestClassifyPath_ManagedLegacyGroves(t *testing.T) { + // The legacy "groves" path should also be detected as managed + base := t.TempDir() + managedRoot := filepath.Join(base, "projects") + legacyRoot := filepath.Join(base, "groves") + if err := os.MkdirAll(legacyRoot, 0755); err != nil { + t.Fatal(err) + } + sub := filepath.Join(legacyRoot, "old-project") + if err := os.Mkdir(sub, 0755); err != nil { + t.Fatal(err) + } + + pc, err := ClassifyPath(context.Background(), nil, sub, managedRoot) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !pc.IsManaged { + t.Error("expected IsManaged=true for path under legacy groves root") + } +} + +func TestClassifyPath_NotManaged(t *testing.T) { + managedRoot := t.TempDir() + otherDir := t.TempDir() + + pc, err := ClassifyPath(context.Background(), nil, otherDir, managedRoot) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pc.IsManaged { + t.Error("expected IsManaged=false for path outside managedRoot") + } +} + +func TestClassifyPath_AlreadyLinked(t *testing.T) { + s, err := newTestStore(":memory:") + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + + dir := t.TempDir() + + broker := &store.RuntimeBroker{ + ID: uuid.NewString(), + Name: "test-broker", + Slug: "test-broker", + Status: "online", + } + if err := s.CreateRuntimeBroker(ctx, broker); err != nil { + t.Fatal(err) + } + proj := &store.Project{ + ID: uuid.NewString(), + Slug: "linked-test", + Name: "Linked Test", + Created: time.Now(), + Updated: time.Now(), + } + if err := s.CreateProject(ctx, proj); err != nil { + t.Fatal(err) + } + if err := s.AddProjectProvider(ctx, &store.ProjectProvider{ + ProjectID: proj.ID, + BrokerID: broker.ID, + BrokerName: broker.Name, + LocalPath: dir, + Status: "online", + }); err != nil { + t.Fatal(err) + } + + pc, err := ClassifyPath(ctx, s, dir, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !pc.AlreadyLinked { + t.Error("expected AlreadyLinked=true for path matching a provider") + } +} + +func TestClassifyPath_NotLinked(t *testing.T) { + s, err := newTestStore(":memory:") + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + + linkedDir := t.TempDir() + otherDir := t.TempDir() + + broker := &store.RuntimeBroker{ + ID: uuid.NewString(), + Name: "test-broker-nl", + Slug: "test-broker-nl", + Status: "online", + } + if err := s.CreateRuntimeBroker(ctx, broker); err != nil { + t.Fatal(err) + } + proj := &store.Project{ + ID: uuid.NewString(), + Slug: "notlinked-test", + Name: "Not Linked Test", + Created: time.Now(), + Updated: time.Now(), + } + if err := s.CreateProject(ctx, proj); err != nil { + t.Fatal(err) + } + if err := s.AddProjectProvider(ctx, &store.ProjectProvider{ + ProjectID: proj.ID, + BrokerID: broker.ID, + BrokerName: broker.Name, + LocalPath: linkedDir, + Status: "online", + }); err != nil { + t.Fatal(err) + } + + pc, err := ClassifyPath(ctx, s, otherDir, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pc.AlreadyLinked { + t.Error("expected AlreadyLinked=false for unlinked path") + } +} diff --git a/pkg/hub/seed.go b/pkg/hub/seed.go index 3fbe07d0c..6185c3401 100644 --- a/pkg/hub/seed.go +++ b/pkg/hub/seed.go @@ -105,7 +105,8 @@ func seedPolicy(ctx context.Context, s store.Store, groupID string, policy *stor // This is needed because Ent enforces foreign key constraints on owner_id, // and the dev user must exist as a User record for project group creation to // succeed in workstation/dev-auth mode. -func seedDevUser(ctx context.Context, s store.Store) { +func seedDevUser(ctx context.Context, s store.Store, cfg DevUserConfig) { + u := NewDevUser(cfg) _, err := s.GetUser(ctx, DevUserID) if err == nil { return // already exists @@ -116,8 +117,8 @@ func seedDevUser(ctx context.Context, s store.Store) { } if err := s.CreateUser(ctx, &store.User{ ID: DevUserID, - Email: "dev@localhost", - DisplayName: "Development User", + Email: u.Email(), + DisplayName: u.DisplayName(), Role: "admin", Status: "active", }); err != nil && !errors.Is(err, store.ErrAlreadyExists) { diff --git a/pkg/hub/server.go b/pkg/hub/server.go index 563431b9b..d7f17a092 100644 --- a/pkg/hub/server.go +++ b/pkg/hub/server.go @@ -31,6 +31,7 @@ import ( "os" "strings" "sync" + "sync/atomic" "time" "github.com/GoogleCloudPlatform/scion/pkg/agent/state" @@ -178,6 +179,11 @@ 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 + // DevUserConfig holds optional identity overrides for the development user. + DevUserConfig DevUserConfig } // MaintenanceConfig holds configuration for routine maintenance operation executors. @@ -572,12 +578,15 @@ type Server struct { // Phase 3/4 supply the real local-tunnel ops; tests override for exactly-once. execDispatch func(ctx context.Context, d store.BrokerDispatch) (string, error) deliverMsg func(ctx context.Context, m *store.Message) error - maintenance *MaintenanceState // Runtime maintenance mode state - 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 - scheduler *Scheduler // Unified scheduler for recurring tasks - cleanupOnce sync.Once // Ensures CleanupResources runs only once + maintenance *MaintenanceState // Runtime maintenance mode state + 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 + ctx context.Context // Server-lifetime context; cancelled on Shutdown + ctxCancel context.CancelFunc // Cancels ctx logQueryService *LogQueryService // Cloud Logging query service (nil = disabled) @@ -644,6 +653,9 @@ type Server struct { // Shared HTTP client for federation proxy calls (no redirect following). federationClient *http.Client + + imageBuildActive atomic.Bool + imagePullActive atomic.Bool } func newInstanceID() string { @@ -664,6 +676,8 @@ func New(cfg ServerConfig, s store.Store) (*Server, error) { cfg.StalledThreshold = defaults.StalledThreshold } + srvCtx, srvCancel := context.WithCancel(context.Background()) + srv := &Server{ config: cfg, store: s, @@ -673,6 +687,9 @@ func New(cfg ServerConfig, s store.Store) (*Server, error) { maintenance: NewMaintenanceState(cfg.AdminMode, cfg.MaintenanceMessage), hubID: cfg.HubID, instanceID: newInstanceID(), + workstation: cfg.Workstation, + ctx: srvCtx, + ctxCancel: srvCancel, // Subsystem loggers agentLifecycleLog: logging.Subsystem("hub.agent-lifecycle"), @@ -876,7 +893,7 @@ func New(cfg ServerConfig, s store.Store) (*Server, error) { // Seed the dev user when dev-auth is enabled so that Ent FK constraints // on owner_id are satisfied when the dev user creates projects/groups. if cfg.DevAuthToken != "" { - seedDevUser(ctx, s) + seedDevUser(ctx, s, cfg.DevUserConfig) } // Abort any maintenance operations/migrations left in "running" state from @@ -893,6 +910,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, @@ -1298,6 +1316,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 { @@ -2331,6 +2356,11 @@ func (s *Server) Shutdown(ctx context.Context) error { slog.Info("Hub API server shutting down...") + // Cancel server-lifetime context to stop background goroutines + if s.ctxCancel != nil { + s.ctxCancel() + } + // Shutdown control channel first if cc != nil { cc.Shutdown() @@ -2386,6 +2416,11 @@ func (s *Server) CleanupResources(ctx context.Context) error { slog.Info("Cleaning up Hub resources...") + // Cancel server-lifetime context to stop background goroutines + if s.ctxCancel != nil { + s.ctxCancel() + } + if cc != nil { cc.Shutdown() } @@ -2573,6 +2608,20 @@ 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))) + 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))) + 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. @@ -2649,6 +2698,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) { diff --git a/pkg/hub/system_handlers.go b/pkg/hub/system_handlers.go new file mode 100644 index 000000000..6a05e415d --- /dev/null +++ b/pkg/hub/system_handlers.go @@ -0,0 +1,830 @@ +// 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 ( + "bufio" + "context" + "fmt" + "log/slog" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + + "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" + "github.com/GoogleCloudPlatform/scion/pkg/util" +) + +// --- 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 { + activeProfile := vs.ActiveProfile + if activeProfile == "" { + activeProfile = "default" + } + if vs.Profiles != nil { + if profile, ok := vs.Profiles[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 + } + + globalDir, err := config.GetGlobalDir() + if err != nil { + writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "cannot determine config directory", nil) + return + } + + vs, err := config.LoadSingleFileVersioned(globalDir) + if err != nil { + writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "failed to load settings", nil) + return + } + + activeProfile := vs.ActiveProfile + if activeProfile == "" { + activeProfile = "default" + } + + if vs.Profiles == nil { + vs.Profiles = make(map[string]config.V1ProfileConfig) + } + profile := vs.Profiles[activeProfile] + profile.Runtime = req.Runtime + vs.Profiles[activeProfile] = profile + + if err := config.SaveVersionedSettings(globalDir, vs); 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"` + EmbeddedBrokerID string `json:"embeddedBrokerID,omitempty"` + ImageRegistry string `json:"imageRegistry,omitempty"` + BuildAvailable bool `json:"buildAvailable"` + GitVersion string `json:"gitVersion,omitempty"` + GitVersionOK bool `json:"gitVersionOK"` +} + +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 + + status.EmbeddedBrokerID = s.GetEmbeddedBrokerID() + + // ImageRegistry: resolve configured image registry + if vs, loadErr := config.LoadSingleFileVersioned(globalDir); loadErr == nil && vs != nil { + status.ImageRegistry = vs.ResolveImageRegistry("") + } + + // BuildAvailable: true only if the build script can be resolved + status.BuildAvailable = resolveBuildScript() != "" + + // GitVersion: report installed git version and whether it meets the worktree requirement + if gitVersion, _, err := util.GetGitVersion(); err == nil { + status.GitVersion = gitVersion + status.GitVersionOK = util.CheckGitVersion() == nil + } else { + status.GitVersion = "not found" + status.GitVersionOK = false + } + + 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, + }) +} + +// --- 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 + } + + if !s.imagePullActive.CompareAndSwap(false, true) { + writeError(w, http.StatusConflict, ErrCodeConflict, "a pull is already in progress", nil) + return + } + + var req imagePullRequest + if err := readJSON(r, &req); err != nil { + s.imagePullActive.Store(false) + BadRequest(w, "invalid request body") + return + } + + if len(req.Harnesses) == 0 { + s.imagePullActive.Store(false) + 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] { + s.imagePullActive.Store(false) + 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("") + } + } + if registry == "" { + s.imagePullActive.Store(false) + writeError(w, http.StatusUnprocessableEntity, ErrCodeUnprocessable, "image_registry is not configured — run 'scion config set --global image_registry ' or reinstall via Homebrew", nil) + return + } + + jobID := api.NewUUID() + + rt := runtime.GetRuntime("", "") + + go func() { + defer s.imagePullActive.Store(false) + if err := runtime.PullImages(s.ctx, rt, req.Harnesses, registry, func(pr runtime.PullResult) { + s.events.PublishRaw("system.images."+jobID, pr) + }); err != nil { + s.events.PublishRaw("system.images."+jobID, map[string]string{ + "status": "error", + "error": err.Error(), + }) + } + }() + + 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 resolveBuildScript() string { + var path string + if root := os.Getenv("SCION_ROOT"); root != "" { + path = filepath.Join(root, "image-build", "scripts", "build-images.sh") + } else if exe, err := os.Executable(); err == nil { + path = filepath.Join(filepath.Dir(exe), "..", "image-build", "scripts", "build-images.sh") + } + if path == "" { + return "" + } + if _, err := os.Stat(path); err != nil { + return "" + } + return path +} + +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 + } + + if !s.imageBuildActive.CompareAndSwap(false, true) { + writeError(w, http.StatusConflict, ErrCodeConflict, "a build is already in progress", nil) + return + } + buildStarted := false + defer func() { + if !buildStarted { + s.imageBuildActive.Store(false) + } + }() + + _, 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 + } + } + + buildScript := resolveBuildScript() + if buildScript == "" { + writeError(w, http.StatusUnprocessableEntity, ErrCodeUnprocessable, "local builds require a source checkout; use image pull instead", map[string]interface{}{"buildUnavailable": true}) + return + } + + buildStarted = true + jobID := api.NewUUID() + subject := "system.images." + jobID + requestedHarnesses := req.Harnesses + + go func() { + defer s.imageBuildActive.Store(false) + cmd := exec.CommandContext(s.ctx, buildScript, "--target", "harnesses") + cmd.Dir = filepath.Dir(buildScript) + + 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 := scanner.Err(); err != nil { + s.events.PublishRaw(subject, imageBuildLogEvent{Type: "log", Line: "error reading build log: " + err.Error()}) + } + + if err := cmd.Wait(); err != nil { + s.events.PublishRaw(subject, imageBuildLogEvent{Type: "log", Line: "build failed: " + err.Error()}) + } else { + for _, h := range requestedHarnesses { + s.events.PublishRaw(subject, map[string]string{ + "image": "scion-" + h + ":latest", + "status": "done", + }) + } + s.events.PublishRaw(subject, imageBuildLogEvent{Type: "log", Line: "build complete"}) + } + }() + + 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) + } + if !filepath.IsAbs(resolved) { + if resolved, err = filepath.Abs(resolved); err != nil { + writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "cannot resolve absolute path", nil) + return + } + } + + home, err := os.UserHomeDir() + if err != nil { + writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "cannot determine home directory", nil) + return + } + sep := string(filepath.Separator) + if !pathEqual(resolved, home) && !pathHasPrefix(resolved, home+sep) { + 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) + if !filepath.IsAbs(resolved) { + if resolved, err = filepath.Abs(resolved); err != nil { + writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "cannot resolve absolute path", nil) + return + } + } + + home, err := os.UserHomeDir() + if err != nil { + writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "cannot determine home directory", nil) + return + } + sep := string(filepath.Separator) + if !pathEqual(resolved, home) && !pathHasPrefix(resolved, home+sep) { + writeError(w, http.StatusForbidden, ErrCodeForbidden, "parent must be within the home directory", nil) + return + } + + managedRoot, _ := managedProjectRoot() + if managedRoot != "" { + cleanManaged := filepath.Clean(managedRoot) + if pathHasPrefix(resolved, cleanManaged+string(filepath.Separator)) || pathEqual(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"` +} + +// handleFSValidatePath classifies a candidate path for linked-grove creation. +// Intentionally no home-directory fence: linked groves may reference directories +// anywhere on disk (e.g. external drives, /opt projects). Safety is enforced by +// ClassifyPath's managed-path overlap check and the assertLoopback guard. +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' { + return s[:len(s)-1] + } + return s +} diff --git a/pkg/hub/system_handlers_test.go b/pkg/hub/system_handlers_test.go new file mode 100644 index 000000000..ca056a624 --- /dev/null +++ b/pkg/hub/system_handlers_test.go @@ -0,0 +1,406 @@ +// 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. + +//go:build !no_sqlite + +package hub + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GoogleCloudPlatform/scion/pkg/config" + "github.com/GoogleCloudPlatform/scion/pkg/store" + _ "github.com/GoogleCloudPlatform/scion/pkg/store/sqlite" // register the sqlite driver for the hub test binary +) + +// testWorkstationServer creates a test server with workstation mode enabled. +func testWorkstationServer(t *testing.T) (*Server, store.Store) { + t.Helper() + s, err := newTestStore(":memory:") + if err != nil { + t.Fatalf("failed to create test store: %v", err) + } + + cfg := DefaultServerConfig() + cfg.DevAuthToken = testDevToken + cfg.Workstation = true + srv, err := New(cfg, s) + if err != nil { + t.Fatalf("New() failed: %v", err) + } + srv.SetHubID("test-hub-id") + t.Cleanup(func() { _ = srv.Shutdown(context.Background()) }) + return srv, s +} + +// doWorkstationRequest performs an authenticated request from a loopback address. +func doWorkstationRequest(t *testing.T, srv *Server, method, path string, body interface{}) *httptest.ResponseRecorder { + t.Helper() + var bodyBytes []byte + if body != nil { + var err error + bodyBytes, err = json.Marshal(body) + if err != nil { + t.Fatalf("failed to marshal body: %v", err) + } + } + + req := httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Authorization", "Bearer "+testDevToken) + req.RemoteAddr = "127.0.0.1:1234" + + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + return rec +} + +// ============================================================================ +// requireWorkstation middleware +// ============================================================================ + +func TestRequireWorkstation_Disabled(t *testing.T) { + srv, _ := testServer(t) + rec := doRequest(t, srv, http.MethodGet, "/api/v1/system/status", nil) + if rec.Code != http.StatusNotFound { + t.Errorf("expected 404 when workstation disabled, got %d", rec.Code) + } +} + +func TestRequireWorkstation_Enabled(t *testing.T) { + srv, _ := testWorkstationServer(t) + rec := doWorkstationRequest(t, srv, http.MethodGet, "/api/v1/system/status", nil) + if rec.Code != http.StatusOK { + t.Errorf("expected 200 when workstation enabled, got %d: %s", rec.Code, rec.Body.String()) + } +} + +// ============================================================================ +// assertLoopback +// ============================================================================ + +func TestAssertLoopback(t *testing.T) { + tests := []struct { + remoteAddr string + wantErr bool + }{ + {"127.0.0.1:1234", false}, + {"[::1]:1234", false}, + {"192.168.1.1:1234", true}, + {"10.0.0.1:5555", true}, + {"192.0.2.1:1234", true}, + } + + for _, tt := range tests { + t.Run(tt.remoteAddr, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = tt.remoteAddr + err := assertLoopback(req) + if tt.wantErr && err == nil { + t.Errorf("expected error for %s", tt.remoteAddr) + } + if !tt.wantErr && err != nil { + t.Errorf("unexpected error for %s: %v", tt.remoteAddr, err) + } + }) + } +} + +func TestAssertLoopback_RejectViaHandler(t *testing.T) { + srv, _ := testWorkstationServer(t) + + // doRequest uses httptest default RemoteAddr (192.0.2.1:1234) — non-loopback + rec := doRequest(t, srv, http.MethodGet, "/api/v1/system/status", nil) + if rec.Code != http.StatusForbidden { + t.Errorf("expected 403 for non-loopback request, got %d", rec.Code) + } +} + +// ============================================================================ +// POST /system/init +// ============================================================================ + +func TestSystemInit_ValidHarnesses(t *testing.T) { + srv, _ := testWorkstationServer(t) + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + restore := config.OverrideRuntimeDetection( + func(string) (string, error) { return "/usr/bin/docker", nil }, + func(string, []string) error { return nil }, + ) + defer restore() + + rec := doWorkstationRequest(t, srv, http.MethodPost, "/api/v1/system/init", map[string]interface{}{ + "harnesses": []string{"claude"}, + }) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp systemInitResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode error: %v", err) + } + if !resp.OK || !resp.Initialized { + t.Errorf("expected ok=true initialized=true, got %+v", resp) + } + + settingsPath := filepath.Join(tmpHome, ".scion", "settings.yaml") + if _, err := os.Stat(settingsPath); os.IsNotExist(err) { + t.Error("expected settings.yaml to be created") + } +} + +func TestSystemInit_UnknownHarness(t *testing.T) { + srv, _ := testWorkstationServer(t) + rec := doWorkstationRequest(t, srv, http.MethodPost, "/api/v1/system/init", map[string]interface{}{ + "harnesses": []string{"bogus"}, + }) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected 400 for unknown harness, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestSystemInit_EmptyHarnesses(t *testing.T) { + srv, _ := testWorkstationServer(t) + rec := doWorkstationRequest(t, srv, http.MethodPost, "/api/v1/system/init", map[string]interface{}{ + "harnesses": []string{}, + }) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected 400 for empty harnesses, got %d: %s", rec.Code, rec.Body.String()) + } +} + +// ============================================================================ +// PUT /system/identity +// ============================================================================ + +func TestSystemIdentity_PUT(t *testing.T) { + srv, _ := testWorkstationServer(t) + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + // Seed a minimal settings.yaml so UpdateSetting has a file to update + scionDir := filepath.Join(tmpHome, ".scion") + if err := os.MkdirAll(scionDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(scionDir, "settings.yaml"), []byte("version: \"1\"\n"), 0644); err != nil { + t.Fatal(err) + } + + rec := doWorkstationRequest(t, srv, http.MethodPut, "/api/v1/system/identity", map[string]interface{}{ + "displayName": "Test User", + "email": "test@example.com", + }) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp systemIdentityResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode error: %v", err) + } + if resp.DisplayName != "Test User" { + t.Errorf("expected displayName 'Test User', got %q", resp.DisplayName) + } + if resp.Email != "test@example.com" { + t.Errorf("expected email 'test@example.com', got %q", resp.Email) + } +} + +func TestSystemIdentity_MethodNotAllowed(t *testing.T) { + srv, _ := testWorkstationServer(t) + rec := doWorkstationRequest(t, srv, http.MethodGet, "/api/v1/system/identity", nil) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("expected 405, got %d", rec.Code) + } +} + +// ============================================================================ +// POST /system/fs/validate-path +// ============================================================================ + +func TestFSValidatePath_ManagedOverlap(t *testing.T) { + srv, _ := testWorkstationServer(t) + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + managedDir := filepath.Join(tmpHome, ".scion", "projects", "something") + if err := os.MkdirAll(managedDir, 0755); err != nil { + t.Fatal(err) + } + + rec := doWorkstationRequest(t, srv, http.MethodPost, "/api/v1/system/fs/validate-path", map[string]interface{}{ + "path": managedDir, + }) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp fsValidatePathResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode error: %v", err) + } + if !resp.IsManaged { + t.Error("expected IsManaged=true for path inside managed directory") + } + if resp.Error == "" { + t.Error("expected non-empty error message for managed path") + } +} + +func TestFSValidatePath_NormalPath(t *testing.T) { + srv, _ := testWorkstationServer(t) + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + projectDir := filepath.Join(tmpHome, "my-project") + if err := os.MkdirAll(filepath.Join(projectDir, ".git"), 0755); err != nil { + t.Fatal(err) + } + + rec := doWorkstationRequest(t, srv, http.MethodPost, "/api/v1/system/fs/validate-path", map[string]interface{}{ + "path": projectDir, + }) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp fsValidatePathResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode error: %v", err) + } + if resp.IsManaged { + t.Error("expected IsManaged=false for normal path") + } + if !resp.Exists { + t.Error("expected Exists=true") + } + if !resp.IsDir { + t.Error("expected IsDir=true") + } + if !resp.IsGit { + t.Error("expected IsGit=true for dir with .git") + } + if resp.Error != "" { + t.Errorf("expected no error, got %q", resp.Error) + } +} + +// ============================================================================ +// GET /system/fs/list +// ============================================================================ + +func TestFSList_HomeDir(t *testing.T) { + srv, _ := testWorkstationServer(t) + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + if err := os.Mkdir(filepath.Join(tmpHome, "visible-dir"), 0755); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(tmpHome, ".hidden-dir"), 0755); err != nil { + t.Fatal(err) + } + + rec := doWorkstationRequest(t, srv, http.MethodGet, "/api/v1/system/fs/list?path="+tmpHome, nil) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp fsListResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode error: %v", err) + } + + found := false + for _, e := range resp.Entries { + if e.Name == "visible-dir" { + found = true + } + if strings.HasPrefix(e.Name, ".") { + t.Errorf("hidden entry %q should be filtered", e.Name) + } + } + if !found { + t.Error("expected 'visible-dir' in entries") + } +} + +func TestFSList_OutsideHome(t *testing.T) { + srv, _ := testWorkstationServer(t) + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + rec := doWorkstationRequest(t, srv, http.MethodGet, "/api/v1/system/fs/list?path=/tmp", nil) + if rec.Code != http.StatusForbidden { + t.Errorf("expected 403 for path outside home, got %d: %s", rec.Code, rec.Body.String()) + } + + // Sibling-prefix bypass: a path like /home/alice-backup must not pass + // when home is /home/alice. + siblingDir := tmpHome + "-backup" + if err := os.MkdirAll(siblingDir, 0755); err != nil { + t.Fatal(err) + } + rec = doWorkstationRequest(t, srv, http.MethodGet, "/api/v1/system/fs/list?path="+siblingDir, nil) + if rec.Code != http.StatusForbidden { + t.Errorf("expected 403 for sibling-prefix path, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestFSList_DefaultsToHome(t *testing.T) { + srv, _ := testWorkstationServer(t) + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + if err := os.Mkdir(filepath.Join(tmpHome, "subdir"), 0755); err != nil { + t.Fatal(err) + } + + rec := doWorkstationRequest(t, srv, http.MethodGet, "/api/v1/system/fs/list", nil) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp fsListResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode error: %v", err) + } + if resp.Path != tmpHome { + t.Errorf("expected path=%q, got %q", tmpHome, resp.Path) + } + + found := false + for _, e := range resp.Entries { + if e.Name == "subdir" { + found = true + } + } + if !found { + t.Error("expected 'subdir' in entries") + } +} diff --git a/pkg/hub/system_identity.go b/pkg/hub/system_identity.go new file mode 100644 index 000000000..062694769 --- /dev/null +++ b/pkg/hub/system_identity.go @@ -0,0 +1,93 @@ +// 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" + "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 + } + } + + // Also update the database user record so the name is visible immediately + // without a server restart. + if s.store != nil && (req.DisplayName != "" || req.Email != "") { + s.updateDevUserRecord(r.Context(), req.DisplayName, req.Email) + } + + writeJSON(w, http.StatusOK, systemIdentityResponse(req)) +} + +// updateDevUserRecord updates the dev user's display name and/or email in the +// database so that the change is visible immediately without a server restart. +func (s *Server) updateDevUserRecord(ctx context.Context, displayName, email string) { + user, err := s.store.GetUser(ctx, DevUserID) + if err != nil { + return + } + changed := false + if displayName != "" && user.DisplayName != displayName { + user.DisplayName = displayName + changed = true + } + if email != "" && user.Email != email { + user.Email = email + changed = true + } + if changed { + _ = s.store.UpdateUser(ctx, user) + } +} diff --git a/pkg/hub/template_bootstrap.go b/pkg/hub/template_bootstrap.go index 7d74a4113..305299ac2 100644 --- a/pkg/hub/template_bootstrap.go +++ b/pkg/hub/template_bootstrap.go @@ -16,14 +16,16 @@ package hub import ( "context" + "errors" + "fmt" "os" "path/filepath" "strings" - "golang.org/x/sync/errgroup" - "github.com/GoogleCloudPlatform/scion/pkg/api" "github.com/GoogleCloudPlatform/scion/pkg/config" + "github.com/GoogleCloudPlatform/scion/pkg/config/templateimport" + "github.com/GoogleCloudPlatform/scion/pkg/secret" "github.com/GoogleCloudPlatform/scion/pkg/store" ) @@ -202,73 +204,220 @@ func (s *Server) importTemplateHarnessConfigs(ctx context.Context, templatePath, hcScope = store.HarnessConfigScopeProject } - // Each bundled harness-config is an independent DB row + storage prefix, so - // import them concurrently with a bounded pool (Phase 4). This runs inside a - // per-resource import goroutine, so the bound is kept small to limit nesting. - var g errgroup.Group - g.SetLimit(bundledHarnessConfigConcurrency) for _, entry := range entries { if !entry.IsDir() { continue } - entry := entry - g.Go(func() error { - name := entry.Name() - dirPath := filepath.Join(hcDir, name) - slug := api.Slugify(name) + name := entry.Name() + dirPath := filepath.Join(hcDir, name) + slug := api.Slugify(name) - hcDirCfg, err := config.LoadHarnessConfigDir(dirPath) - if err != nil { - s.templateLog.Debug("template harness-config import: failed to load config, skipping", - "config", name, "error", err) - return nil - } + hcDirCfg, err := config.LoadHarnessConfigDir(dirPath) + if err != nil { + s.templateLog.Debug("template harness-config import: failed to load config, skipping", + "config", name, "error", err) + continue + } - existing, err := s.store.GetHarnessConfigBySlug(ctx, slug, hcScope, scopeID) - if err != nil && err != store.ErrNotFound { - return nil - } + existing, err := s.store.GetHarnessConfigBySlug(ctx, slug, hcScope, scopeID) + if err != nil && err != store.ErrNotFound { + continue + } - if existing == nil { - if err := s.bootstrapSingleHarnessConfigScoped(ctx, name, dirPath, hcDirCfg, stor, hcScope, scopeID); err != nil { - s.templateLog.Warn("template harness-config import: failed to import, skipping", - "config", name, "error", err) - return nil - } - s.templateLog.Info("template harness-config import: imported config", - "config", name, "harness", hcDirCfg.Config.Harness, "scope", hcScope) - } else { - if _, err := s.syncExistingHarnessConfig(ctx, existing, dirPath, hcDirCfg, stor, false); err != nil { - s.templateLog.Warn("template harness-config import: failed to sync, skipping", - "config", name, "error", err) - } + if existing == nil { + if err := s.bootstrapSingleHarnessConfigScoped(ctx, name, dirPath, hcDirCfg, stor, hcScope, scopeID); err != nil { + s.templateLog.Warn("template harness-config import: failed to import, skipping", + "config", name, "error", err) + continue + } + s.templateLog.Info("template harness-config import: imported config", + "config", name, "harness", hcDirCfg.Config.Harness, "scope", hcScope) + } else { + if _, err := s.syncExistingHarnessConfig(ctx, existing, dirPath, hcDirCfg, stor, false); err != nil { + s.templateLog.Warn("template harness-config import: failed to sync, skipping", + "config", name, "error", err) } - return nil - }) + } } - _ = g.Wait() } -// bundledHarnessConfigConcurrency bounds how many harness-configs bundled inside -// a template import in parallel (Phase 4). It is kept small because this loop -// runs within a per-resource import goroutine (resourceImportConcurrency), so -// the effective concurrency is the product of the two pools. -const bundledHarnessConfigConcurrency = 4 - // importTemplatesFromRemote fetches a remote source URL, discovers scion // templates within it, and registers each one into the Hub store scoped // to the given project. Returns the names of all templates imported or updated. -// -// This is a thin wrapper over the shared import driver (resource_import.go). func (s *Server) importTemplatesFromRemote(ctx context.Context, projectID, sourceURL string) ([]string, error) { - return s.importFromRemote(ctx, projectID, sourceURL, store.TemplateScopeProject, s.templateImportKind(), nil) + if !config.IsRemoteURI(sourceURL) { + return nil, fmt.Errorf("source must be a remote URI (http://, https://, or rclone)") + } + + stor := s.GetStorage() + if stor == nil { + return nil, fmt.Errorf("template storage is not configured") + } + + // If the project has a GitHub App installation, mint a token for authenticated access + var authToken string + project, err := s.store.GetProject(ctx, projectID) + if err == nil && project != nil && project.GitHubInstallationID != nil { + if token, _, mintErr := s.MintGitHubAppTokenForProject(ctx, project); mintErr == nil && token != "" { + authToken = token + } + } + + // Fall back to project GITHUB_TOKEN secret if no App token minted + if authToken == "" { + if sb := s.GetSecretBackend(); sb != nil { + sec, secErr := sb.Get(ctx, "GITHUB_TOKEN", secret.ScopeProject, projectID) + if secErr == nil && sec != nil && sec.Value != "" { + authToken = sec.Value + s.templateLog.Info("using project GITHUB_TOKEN for template import", "projectID", projectID) + } else if secErr != nil && !errors.Is(secErr, store.ErrNotFound) { + s.templateLog.Warn("Failed to retrieve GITHUB_TOKEN from secret backend", "projectID", projectID, "error", secErr) + } + } + } + + // Fetch to a temporary directory + cachePath, err := config.FetchRemoteTemplate(ctx, sourceURL, authToken) + if err != nil { + return nil, fmt.Errorf("failed to fetch remote templates: %w", err) + } + defer func() { _ = os.RemoveAll(cachePath) }() + + // Collect template directories to import + type templateDir struct{ name, path string } + var dirs []templateDir + + if templateimport.IsScionTemplate(cachePath) { + // URL pointed directly at a single template directory + dirs = append(dirs, templateDir{filepath.Base(cachePath), cachePath}) + } else { + entries, err := os.ReadDir(cachePath) + if err != nil { + return nil, err + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + dir := filepath.Join(cachePath, entry.Name()) + if templateimport.IsScionTemplate(dir) { + dirs = append(dirs, templateDir{entry.Name(), dir}) + } + } + } + + if len(dirs) == 0 { + return nil, fmt.Errorf("no scion templates found at %s", sourceURL) + } + + var imported []string + for _, td := range dirs { + slug := api.Slugify(td.name) + existing, err := s.store.GetTemplateBySlug(ctx, slug, store.TemplateScopeProject, projectID) + if err != nil && err != store.ErrNotFound { + s.templateLog.Warn("template import: failed to look up template, skipping", + "name", td.name, "error", err) + continue + } + if existing == nil { + if err := s.bootstrapSingleTemplate(ctx, td.name, td.path, store.TemplateScopeProject, projectID); err != nil { + s.templateLog.Warn("template import: failed to import template, skipping", + "name", td.name, "error", err) + continue + } + } else { + if _, err := s.syncExistingTemplate(ctx, existing, td.path, true); err != nil { + s.templateLog.Warn("template import: failed to sync template, skipping", + "name", td.name, "error", err) + continue + } + } + imported = append(imported, td.name) + } + return imported, nil } // importTemplatesFromWorkspace imports templates from a path within the // project's workspace filesystem. The workspacePath is relative to the project's // workspace root (e.g. "/.scion/templates" or "/my/custom/path"). -// -// This is a thin wrapper over the shared import driver (resource_import.go). func (s *Server) importTemplatesFromWorkspace(ctx context.Context, project *store.Project, workspacePath string) ([]string, error) { - return s.importFromWorkspace(ctx, project, workspacePath, store.TemplateScopeProject, s.templateImportKind(), nil) + stor := s.GetStorage() + if stor == nil { + return nil, fmt.Errorf("template storage is not configured") + } + + // Resolve the project's workspace root on disk + projectRoot, err := s.resolveProjectWebDAVPath(ctx, project) + if err != nil { + return nil, fmt.Errorf("failed to resolve project workspace: %w", err) + } + + // Clean and join the workspace path to the project root. + // Strip leading slash so it joins correctly. + rel := strings.TrimPrefix(filepath.Clean(workspacePath), "/") + templatesDir := filepath.Join(projectRoot, rel) + + // Validate the resolved path is within the project root + absRoot, _ := filepath.Abs(projectRoot) + absDir, _ := filepath.Abs(templatesDir) + if !strings.HasPrefix(absDir, absRoot) { + return nil, fmt.Errorf("workspace path must be within the project workspace") + } + + info, err := os.Stat(templatesDir) + if err != nil || !info.IsDir() { + return nil, fmt.Errorf("workspace path not found or not a directory: %s", workspacePath) + } + + // Collect template directories to import (same logic as importTemplatesFromRemote) + type templateDir struct{ name, path string } + var dirs []templateDir + + if templateimport.IsScionTemplate(templatesDir) { + dirs = append(dirs, templateDir{filepath.Base(templatesDir), templatesDir}) + } else { + entries, readErr := os.ReadDir(templatesDir) + if readErr != nil { + return nil, readErr + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + dir := filepath.Join(templatesDir, entry.Name()) + if templateimport.IsScionTemplate(dir) { + dirs = append(dirs, templateDir{entry.Name(), dir}) + } + } + } + + if len(dirs) == 0 { + return nil, fmt.Errorf("no scion templates found at workspace path %s", workspacePath) + } + + var imported []string + for _, td := range dirs { + slug := api.Slugify(td.name) + existing, lookupErr := s.store.GetTemplateBySlug(ctx, slug, store.TemplateScopeProject, project.ID) + if lookupErr != nil && lookupErr != store.ErrNotFound { + s.templateLog.Warn("workspace template import: failed to look up template, skipping", + "name", td.name, "error", lookupErr) + continue + } + if existing == nil { + if bootstrapErr := s.bootstrapSingleTemplate(ctx, td.name, td.path, store.TemplateScopeProject, project.ID); bootstrapErr != nil { + s.templateLog.Warn("workspace template import: failed to import template, skipping", + "name", td.name, "error", bootstrapErr) + continue + } + } else { + if _, syncErr := s.syncExistingTemplate(ctx, existing, td.path, true); syncErr != nil { + s.templateLog.Warn("workspace template import: failed to sync template, skipping", + "name", td.name, "error", syncErr) + continue + } + } + imported = append(imported, td.name) + } + return imported, nil } diff --git a/pkg/hub/web.go b/pkg/hub/web.go index 5a780c53a..8e8806e78 100644 --- a/pkg/hub/web.go +++ b/pkg/hub/web.go @@ -1180,11 +1180,24 @@ func (ws *WebServer) devAuthMiddleware(next http.Handler) http.Handler { return } - // No user — auto-login with dev identity + // No user — auto-login with dev identity. + // Read from the store so any identity set via the onboarding wizard is reflected. + devEmail := "dev@localhost" + devName := "Development User" + if ws.store != nil { + if dbUser, err := ws.store.GetUser(r.Context(), DevUserID); err == nil { + if dbUser.Email != "" { + devEmail = dbUser.Email + } + if dbUser.DisplayName != "" { + devName = dbUser.DisplayName + } + } + } devUser := &webSessionUser{ UserID: DevUserID, - Email: "dev@localhost", - Name: "Development User", + Email: devEmail, + Name: devName, AvatarURL: "", Role: "admin", } diff --git a/pkg/hubsync/sync_test.go b/pkg/hubsync/sync_test.go index b8433b0e3..e685f09b9 100644 --- a/pkg/hubsync/sync_test.go +++ b/pkg/hubsync/sync_test.go @@ -32,7 +32,7 @@ import ( func TestEnsureHubReady_GlobalFallbackWithHubEnabled(t *testing.T) { // Unset Hub context to avoid synthetic project root detection - for _, e := range []string{"SCION_HUB_ENDPOINT", "SCION_HUB_URL", "SCION_GROVE_ID", "SCION_HUB_GROVE_ID"} { + for _, e := range []string{"SCION_HUB_ENDPOINT", "SCION_HUB_URL", "SCION_GROVE_ID", "SCION_HUB_GROVE_ID", "SCION_PROJECT_ID"} { if val, ok := os.LookupEnv(e); ok { os.Unsetenv(e) defer os.Setenv(e, val) @@ -187,7 +187,7 @@ hub: func TestEnsureHubReady_GlobalFallbackWithHubDisabled(t *testing.T) { // Unset Hub context to avoid synthetic project root detection - for _, e := range []string{"SCION_HUB_ENDPOINT", "SCION_HUB_URL", "SCION_GROVE_ID", "SCION_HUB_GROVE_ID"} { + for _, e := range []string{"SCION_HUB_ENDPOINT", "SCION_HUB_URL", "SCION_GROVE_ID", "SCION_HUB_GROVE_ID", "SCION_PROJECT_ID"} { if val, ok := os.LookupEnv(e); ok { os.Unsetenv(e) defer os.Setenv(e, val) diff --git a/pkg/runtime/imagepull.go b/pkg/runtime/imagepull.go new file mode 100644 index 000000000..d5b08e4b0 --- /dev/null +++ b/pkg/runtime/imagepull.go @@ -0,0 +1,88 @@ +// 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 { + if err := ctx.Err(); err != nil { + return err + } + 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 +} diff --git a/pkg/sciontool/telemetry/pipeline_test.go b/pkg/sciontool/telemetry/pipeline_test.go index 3a2c8455a..af82fa562 100644 --- a/pkg/sciontool/telemetry/pipeline_test.go +++ b/pkg/sciontool/telemetry/pipeline_test.go @@ -6,7 +6,6 @@ package telemetry import ( "context" - "os" "testing" "time" @@ -17,8 +16,7 @@ import ( func TestNew_Disabled(t *testing.T) { // Clear env and disable telemetry clearTelemetryEnv() - os.Setenv(EnvEnabled, "false") - defer clearTelemetryEnv() + t.Setenv(EnvEnabled, "false") pipeline := New() if pipeline != nil { @@ -28,8 +26,7 @@ func TestNew_Disabled(t *testing.T) { func TestNew_Enabled(t *testing.T) { clearTelemetryEnv() - os.Setenv(EnvEnabled, "true") - defer clearTelemetryEnv() + t.Setenv(EnvEnabled, "true") pipeline := New() if pipeline == nil { @@ -44,12 +41,11 @@ func TestNew_Enabled(t *testing.T) { func TestPipeline_StartStop(t *testing.T) { clearTelemetryEnv() - // Use non-standard ports to avoid conflicts - os.Setenv(EnvEnabled, "true") - os.Setenv(EnvCloudEnabled, "false") // Disable cloud to avoid GCP auth issues in tests - os.Setenv(EnvGRPCPort, "54317") - os.Setenv(EnvHTTPPort, "54318") - defer clearTelemetryEnv() + // Use port 0 to let the OS assign ephemeral ports, avoiding conflicts + t.Setenv(EnvEnabled, "true") + t.Setenv(EnvCloudEnabled, "false") + t.Setenv(EnvGRPCPort, "0") + t.Setenv(EnvHTTPPort, "0") pipeline := New() if pipeline == nil { @@ -85,11 +81,10 @@ func TestPipeline_StartStop(t *testing.T) { func TestPipeline_DoubleStart(t *testing.T) { clearTelemetryEnv() - os.Setenv(EnvEnabled, "true") - os.Setenv(EnvCloudEnabled, "false") - os.Setenv(EnvGRPCPort, "54319") - os.Setenv(EnvHTTPPort, "54320") - defer clearTelemetryEnv() + t.Setenv(EnvEnabled, "true") + t.Setenv(EnvCloudEnabled, "false") + t.Setenv(EnvGRPCPort, "0") + t.Setenv(EnvHTTPPort, "0") pipeline := New() if pipeline == nil { @@ -147,8 +142,8 @@ func TestNewWithConfig(t *testing.T) { // enabled config cfg = &Config{ Enabled: true, - GRPCPort: 54321, - HTTPPort: 54322, + GRPCPort: 0, + HTTPPort: 0, } pipeline := NewWithConfig(cfg) if pipeline == nil { @@ -159,8 +154,8 @@ func TestNewWithConfig(t *testing.T) { func TestPipeline_HandleMetrics_NilExporter(t *testing.T) { cfg := &Config{ Enabled: true, - GRPCPort: 54323, - HTTPPort: 54324, + GRPCPort: 0, + HTTPPort: 0, } pipeline := NewWithConfig(cfg) if pipeline == nil { @@ -179,8 +174,8 @@ func TestPipeline_HandleMetrics_NilExporter(t *testing.T) { func TestPipeline_HandleMetrics_Empty(t *testing.T) { cfg := &Config{ Enabled: true, - GRPCPort: 54325, - HTTPPort: 54326, + GRPCPort: 0, + HTTPPort: 0, } pipeline := NewWithConfig(cfg) if pipeline == nil { @@ -196,11 +191,10 @@ func TestPipeline_HandleMetrics_Empty(t *testing.T) { func TestPipeline_MetricHandlerRegistered(t *testing.T) { clearTelemetryEnv() - os.Setenv(EnvEnabled, "true") - os.Setenv(EnvCloudEnabled, "false") - os.Setenv(EnvGRPCPort, "54327") - os.Setenv(EnvHTTPPort, "54328") - defer clearTelemetryEnv() + t.Setenv(EnvEnabled, "true") + t.Setenv(EnvCloudEnabled, "false") + t.Setenv(EnvGRPCPort, "0") + t.Setenv(EnvHTTPPort, "0") pipeline := New() if pipeline == nil { @@ -229,8 +223,8 @@ func TestPipeline_MetricHandlerRegistered(t *testing.T) { func TestPipeline_HandleLogs_NilExporter(t *testing.T) { cfg := &Config{ Enabled: true, - GRPCPort: 54329, - HTTPPort: 54330, + GRPCPort: 0, + HTTPPort: 0, } pipeline := NewWithConfig(cfg) if pipeline == nil { @@ -249,8 +243,8 @@ func TestPipeline_HandleLogs_NilExporter(t *testing.T) { func TestPipeline_HandleLogs_Empty(t *testing.T) { cfg := &Config{ Enabled: true, - GRPCPort: 54331, - HTTPPort: 54332, + GRPCPort: 0, + HTTPPort: 0, } pipeline := NewWithConfig(cfg) if pipeline == nil { @@ -266,11 +260,10 @@ func TestPipeline_HandleLogs_Empty(t *testing.T) { func TestPipeline_LogHandlerRegistered(t *testing.T) { clearTelemetryEnv() - os.Setenv(EnvEnabled, "true") - os.Setenv(EnvCloudEnabled, "false") - os.Setenv(EnvGRPCPort, "54333") - os.Setenv(EnvHTTPPort, "54334") - defer clearTelemetryEnv() + t.Setenv(EnvEnabled, "true") + t.Setenv(EnvCloudEnabled, "false") + t.Setenv(EnvGRPCPort, "0") + t.Setenv(EnvHTTPPort, "0") pipeline := New() if pipeline == nil { diff --git a/web/src/client/main.ts b/web/src/client/main.ts index 2a757fee4..0102826ad 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') }, @@ -170,7 +171,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 @@ -227,6 +228,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/app-shell.ts b/web/src/components/app-shell.ts index 5f56878d6..dbe36b5e1 100644 --- a/web/src/components/app-shell.ts +++ b/web/src/components/app-shell.ts @@ -51,6 +51,7 @@ const PAGE_TITLES: Record = { '/admin/skill-registries': 'Skill Registries', '/skills': 'Skills', '/github-app/installed': 'GitHub App Setup', + '/onboarding': 'Setup', }; @customElement('scion-app') 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.' diff --git a/web/src/components/pages/onboarding.ts b/web/src/components/pages/onboarding.ts new file mode 100644 index 000000000..130ed655a --- /dev/null +++ b/web/src/components/pages/onboarding.ts @@ -0,0 +1,1410 @@ +/** + * 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'; +import '../shared/dir-browser.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; + imageRegistry?: string; + buildAvailable?: boolean; + gitVersion?: string; + gitVersionOK?: 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(); + + // 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; + @state() private buildAvailable = false; + @state() private imageRegistry = ''; + @state() private gitVersion = ''; + @state() private gitVersionOK = true; + 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; + 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; + } + + .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; + } + + .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 { + super.connectedCallback(); + void this.initialize(); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.cleanupImageEvents(); + } + + 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)); + } + } + + if (status?.imageRegistry) this.imageRegistry = status.imageRegistry; + if (status?.gitVersion !== undefined) this.gitVersion = status.gitVersion; + if (status?.gitVersionOK !== undefined) this.gitVersionOK = status.gitVersionOK; + + // Resume: advance past completed steps only if user has previously started + const previouslyStarted = sessionStorage.getItem('onboardingStarted') === 'true'; + if (status && previouslyStarted) { + 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.renderImages(); + 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; + } + sessionStorage.setItem('onboardingStarted', 'true'); + 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} +
+ `)} + ${!this.gitVersionOK && this.gitVersion ? html` +
+ warn + Git version + + Git 2.47+ is required for agent worktrees. Detected: ${this.gitVersion}. + Run brew install git to upgrade. + +
+ ` : nothing} +
+ `} + + + `; + } + + 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; + void this.loadImagesStep(); + } finally { + this.stepLoading = false; + } + } + + // ── 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.

+ + `; + } + + // No registry and no local build — show registry setup guidance + if (!this.imageRegistry && !this.buildAvailable) { + return html` +

Container Images

+
+ No image registry configured. +

+ An image registry is required to pull container images. + Run the following to configure one, then restart the server: +

+ scion config set --global image_registry ghcr.io/homebrew-scion +

If you installed via Homebrew, try reinstalling to auto-configure the registry:

+ brew reinstall --HEAD homebrew-scion/scion/scion-workstation +
+ + `; + } + + const allDone = harnesses.length > 0 && harnesses.every(h => { + const s = this.imageStatuses.get(h); + return s && (s.status === 'done' || s.status === 'exists'); + }); + + return html` +

Container Images

+

Pull or build the container images for your selected harnesses.

+ + ${!this.runtimeAvailable ? html` +
+ No container runtime detected. +

+ Install Docker or Podman to pull or build images. You can skip this + step and configure a runtime later. +

+
+ ` : nothing} + +
+ ${harnesses.map(h => { + const s = this.imageStatuses.get(h); + const status = s?.status ?? 'pending'; + const prefix = this.imageRegistry ? `${this.imageRegistry}/` : ''; + const displayName = s?.fullName ?? `${prefix}scion-${h}:latest`; + return html` +
+ ${displayName} + ${status === 'pending' ? nothing : html` + + ${status === 'pulling' ? html`` : nothing} + ${status === 'done' || status === 'exists' ? '✓' : nothing} + ${status === 'error' ? '✗' : nothing} + ${status} + + `} +
+ `; + })} +
+ +
+ ${this.imageRegistry ? html` + Pull images + ` : nothing} + + ${this.buildAvailable ? html` + Build locally + ` : nothing} + + ${!this.imageRegistry && !this.buildAvailable ? html` +

+ Pre-built images are available via pull. Local builds require a source checkout. +

+ ` : nothing} + + ${!this.imageRegistry && this.buildAvailable ? html` +

+ To pull pre-built images instead, configure an image registry. +

+ ` : 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) { + const prefix = this.imageRegistry ? `${this.imageRegistry}/` : ''; + statuses.set(h, { status: 'queued', fullName: `${prefix}scion-${h}:latest` }); + } + 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; + + const completedImages = new Set(); + 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 (!d) return; + + if (d['image']) { + const fullImageName = d['image'] as string; + const status = d['status'] as string; + const error = d['error'] as string | undefined; + + const harness = this.imageNameToHarness(fullImageName); + if (harness) { + const next = new Map(this.imageStatuses); + const entry: { status: string; error?: string; fullName?: string } = { status, fullName: fullImageName }; + if (error) entry.error = error; + next.set(harness, entry); + this.imageStatuses = next; + } + + if (mode === 'pull' && (status === 'done' || status === 'exists' || status === 'error')) { + completedImages.add(fullImageName); + if (completedImages.size >= totalImages) { + this.imagePulling = false; + this.cleanupImageEvents(); + } + } + } else if (d['status'] === 'error') { + this.error = (d['error'] as string) || 'An error occurred during image operation.'; + if (mode === 'pull') this.imagePulling = false; + if (mode === 'build') this.imageBuilding = 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 */ } + try { + const res = await apiFetch('/api/v1/system/status'); + if (res.ok) { + const data = (await res.json()) as OnboardingStatus; + this.buildAvailable = data.buildAvailable ?? false; + } + } catch { /* ignore */ } + } + + // ── 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

+

Create your first project to get started.

+ +
+
{ this.workspaceMode = 'hub'; }}> + +
+
Hub-managed 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.
+
+
+
+ + + `; + } + + private renderWsHub() { + return html` +

Create Hub Workspace

+

Give your project a name.

+ +
+ + { this.wsProjectName = (e.target as HTMLInputElement).value; }} + > +
+ + + `; + } + + private async handleWsHubCreate(): Promise { + this.error = null; + this.wsCreating = true; + try { + const res = await apiFetch('/api/v1/projects', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: this.wsProjectName.trim(), visibility: 'private' }), + }); + if (!res.ok) { + this.error = await extractApiError(res, 'Failed to create project'); + return; + } + this.currentStep = 6; + } catch { + this.error = 'Failed to connect to the server.'; + } finally { + this.wsCreating = false; + } + } + + private renderWsLinked() { + const pathOk = this.wsPathValidation && !this.wsPathValidation.error && this.wsPathValidation.exists && this.wsPathValidation.isDir; + + return html` +

Add Local Directory

+

Browse to or enter the path of a local directory.

+ +
+ + { this.wsProjectName = (e.target as HTMLInputElement).value; }} + > +
+ +
+ + ) => { + this.wsLocalPath = e.detail.path; + void this.wsValidatePath(e.detail.path); + }} + > +
+ + ${this.wsLocalPath ? html` +
+ + +
+ ` : nothing} + + ${this.wsValidatingPath + ? html`
+ Validating… +
` + : this.wsPathValidation + ? html` + ${this.wsPathValidation.error + ? html`
${this.wsPathValidation.error}
` + : !this.wsPathValidation.exists + ? html`
Path does not exist.
` + : !this.wsPathValidation.isDir + ? html`
Not a directory.
` + : html`
Path is valid: ${this.wsPathValidation.resolved}
+ ${this.wsPathValidation.isGit ? html`
This is a git repository.
` : nothing} + ${this.wsPathValidation.alreadyLinked ? html`
Already linked to another project.
` : nothing} + `} + ` + : nothing} + + + `; + } + + private async loadWsBrokerID(): Promise { + if (this.wsEmbeddedBrokerID) return; + 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.wsEmbeddedBrokerID = data.embeddedBrokerID; + } catch { /* ignore */ } + } + + private async wsValidatePath(path: string): Promise { + this.wsValidatingPath = true; + this.wsPathValidation = 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.wsPathValidation = (await res.json()) as typeof this.wsPathValidation; + } catch { /* ignore */ } + finally { this.wsValidatingPath = false; } + } + + private async handleWsLinkedCreate(): Promise { + if (!this.wsEmbeddedBrokerID) { + this.error = 'No embedded broker available.'; + return; + } + this.error = null; + this.wsCreating = true; + try { + const projRes = await apiFetch('/api/v1/projects', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: this.wsProjectName.trim(), visibility: 'private' }), + }); + if (!projRes.ok) { + this.error = await extractApiError(projRes, 'Failed to create project'); + return; + } + const projData = (await projRes.json()) as { project?: { id: string }; id?: string }; + const projectId = projData.project?.id || projData.id; + if (!projectId) { this.error = 'No project ID in response'; return; } + + const provRes = await apiFetch(`/api/v1/projects/${projectId}/providers`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ brokerId: this.wsEmbeddedBrokerID, localPath: this.wsPathValidation!.resolved }), + }); + if (!provRes.ok) { + this.error = await extractApiError(provRes, 'Project created but failed to link directory. You can retry.'); + return; + } + this.currentStep = 6; + } catch { + this.error = 'Failed to connect to the server.'; + } finally { + this.wsCreating = false; + } + } + + // ── Step 6: Done ── + + private renderDone() { + sessionStorage.setItem('onboardingComplete', 'true'); + sessionStorage.removeItem(ONBOARDING_STATUS_KEY); + sessionStorage.removeItem('onboardingStarted'); + + 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; + } +} diff --git a/web/src/components/pages/project-create.ts b/web/src/components/pages/project-create.ts index 28610bbd9..2588f8283 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,14 +86,33 @@ 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 { try { - const res = await fetch('/api/v1/github-app', { credentials: 'include' }); + const res = await apiFetch('/api/v1/github-app'); if (!res.ok) return; const data = (await res.json()) as { configured: boolean; installation_url?: string }; if (data.configured && data.installation_url) { @@ -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 { @@ -334,9 +454,8 @@ export class ScionPageProjectCreate extends LitElement { private async checkExistingProjects(gitUrl: string): Promise { try { - const response = await fetch( + const response = await apiFetch( `/api/v1/projects?gitRemote=${encodeURIComponent(gitUrl)}`, - { credentials: 'include' }, ); if (!response.ok) return; const data = (await response.json()) as { @@ -364,6 +483,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; @@ -405,9 +539,8 @@ export class ScionPageProjectCreate extends LitElement { } } - const response = await fetch('/api/v1/projects', { + const response = await apiFetch('/api/v1/projects', { method: 'POST', - credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); @@ -424,11 +557,28 @@ 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 apiFetch(`/api/v1/projects/${projectId}/providers`, { + method: 'POST', + 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 +624,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 +731,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.replace(/\\/g, '/'); + 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 lastSlash = this.currentPath.lastIndexOf('/'); + if (lastSlash < 0) return; + const parent = lastSlash === 0 ? '/' : this.currentPath.substring(0, lastSlash); + void this.navigate(parent); + } + + private navigateToBreadcrumb(index: number): void { + const segments = this.currentPath.split('/').filter(Boolean); + const subSegments = segments.slice(0, index + 1); + let path = ''; + if (subSegments[0] && /^[a-zA-Z]:$/.test(subSegments[0])) { + path = subSegments.join('/'); + if (subSegments.length === 1) { + path += '/'; + } + } else { + path = '/' + subSegments.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 || (segments.length === 1 && /^[a-zA-Z]:$/.test(segments[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 5cd685ba185efa1fe4fc2394ee29300e3cb32114 Mon Sep 17 00:00:00 2001 From: "Scion Agent (onboarding-improvements-lead)" Date: Tue, 16 Jun 2026 22:57:17 +0000 Subject: [PATCH 2/9] Fix onboarding wizard UX and post-wizard issues Wizard improvements: - Prioritize apple-container over podman in runtime detection - Replace "Container (generic)" label with "Container (Apple Virtualization)" - Show detected/not-detected status on runtime options and disable unavailable ones - Add in-wizard registry input field (default: ghcr.io/homebrew-scion) instead of requiring CLI command Dir browser improvements: - Auto-navigate into newly created folders instead of staying in parent - Add inline typeahead filter in breadcrumb bar for quick path navigation Backend fixes: - Ensure local storage fallback is always initialized so template bootstrap registers default templates in the Hub database - Initialize .scion/ directory structure when linking a local folder as a project --- cmd/server_foreground.go | 49 +++++++++------- pkg/config/runtime_detect.go | 24 +++++++- pkg/hub/handlers.go | 10 ++++ pkg/hub/server.go | 1 + pkg/hub/system_handlers.go | 71 +++++++++++++++++++++-- web/src/components/pages/onboarding.ts | 72 +++++++++++++++++++----- web/src/components/shared/dir-browser.ts | 52 ++++++++++++++++- 7 files changed, 237 insertions(+), 42 deletions(-) diff --git a/cmd/server_foreground.go b/cmd/server_foreground.go index e7644e5b6..5d08c66ef 100644 --- a/cmd/server_foreground.go +++ b/cmd/server_foreground.go @@ -1175,6 +1175,10 @@ func initHubServer(ctx context.Context, cfg *config.GlobalConfig, s store.Store, } // initHubStorage initializes the storage backend for the Hub server. +// It always ensures a storage backend is configured: if the explicitly +// configured backend (GCS or local) fails, it falls back to a local +// filesystem storage at ~/.scion/storage so that template bootstrap +// and other storage-dependent features still work. func initHubStorage(ctx context.Context, hubSrv *hub.Server, cfg *config.GlobalConfig, globalDir string) { if storageBucket != "" { log.Printf("Initializing GCS storage with bucket: %s", storageBucket) @@ -1184,11 +1188,12 @@ func initHubStorage(ctx context.Context, hubSrv *hub.Server, cfg *config.GlobalC } stor, err := storage.New(ctx, storageCfg) if err != nil { - log.Printf("Warning: failed to initialize GCS storage: %v", err) + log.Printf("Warning: failed to initialize GCS storage, falling back to local: %v", err) + } else { + hubSrv.SetStorage(stor) + log.Printf("GCS storage configured: gs://%s", storageBucket) return } - hubSrv.SetStorage(stor) - log.Printf("GCS storage configured: gs://%s", storageBucket) } else if storageDir != "" { log.Printf("Initializing local storage at: %s", storageDir) storageCfg := storage.Config{ @@ -1197,27 +1202,33 @@ func initHubStorage(ctx context.Context, hubSrv *hub.Server, cfg *config.GlobalC } stor, err := storage.New(ctx, storageCfg) if err != nil { - log.Printf("Warning: failed to initialize local storage: %v", err) + log.Printf("Warning: failed to initialize local storage, falling back to default: %v", err) + } else { + hubSrv.SetStorage(stor) + log.Printf("Local storage configured: %s", storageDir) return } - hubSrv.SetStorage(stor) - log.Printf("Local storage configured: %s", storageDir) - } else { - defaultStorageDir := filepath.Join(globalDir, "storage") + } + + // Always set up a local filesystem fallback so that template bootstrap + // and other storage-dependent features work even when no explicit + // storage backend is configured (or the configured one failed). + defaultStorageDir := filepath.Join(globalDir, "storage") + if storageBucket == "" && storageDir == "" { log.Printf("WARNING: No storage backend configured. Using local filesystem storage at: %s", defaultStorageDir) log.Printf(" For production use, configure --storage-bucket (GCS) or --storage-dir (explicit local path)") - storageCfg := storage.Config{ - Provider: storage.ProviderLocal, - LocalPath: defaultStorageDir, - Bucket: "local", - } - stor, err := storage.New(ctx, storageCfg) - if err != nil { - log.Printf("Warning: failed to initialize local storage fallback: %v", err) - return - } - hubSrv.SetStorage(stor) } + storageCfg := storage.Config{ + Provider: storage.ProviderLocal, + LocalPath: defaultStorageDir, + Bucket: "local", + } + stor, err := storage.New(ctx, storageCfg) + if err != nil { + log.Printf("Warning: failed to initialize local storage fallback: %v", err) + return + } + hubSrv.SetStorage(stor) } // newEventPublisher selects the event publisher backend based on the configured diff --git a/pkg/config/runtime_detect.go b/pkg/config/runtime_detect.go index ef45abe8a..a33d7efe8 100644 --- a/pkg/config/runtime_detect.go +++ b/pkg/config/runtime_detect.go @@ -32,8 +32,8 @@ type runtimeCandidate struct { // localRuntimeCandidates lists container runtimes in preference order. var localRuntimeCandidates = []runtimeCandidate{ - {Name: "podman", Binary: "podman", CheckArgs: []string{"--version"}}, {Name: "container", Binary: "container", DarwinOnly: true, CheckArgs: []string{"--version"}}, + {Name: "podman", Binary: "podman", CheckArgs: []string{"--version"}}, {Name: "docker", Binary: "docker", CheckArgs: []string{"--version"}}, } @@ -52,7 +52,7 @@ var runCheckFunc = func(binary string, args []string) error { // DetectLocalRuntime probes the system for an available container runtime. // It checks OS compatibility and verifies each candidate is both on PATH // and can execute successfully. Candidates are checked in preference order: -// podman, container (macOS only), docker. +// container (macOS only), podman, docker. // Returns the runtime name or an error if no supported runtime is found. func DetectLocalRuntime() (string, error) { for _, c := range localRuntimeCandidates { @@ -74,6 +74,26 @@ func DetectLocalRuntime() (string, error) { return "", fmt.Errorf("no supported container runtime found: install podman or docker") } +// DetectAllLocalRuntimes probes the system for all available container runtimes. +// Unlike DetectLocalRuntime which returns the first match, this function returns +// every runtime that is both on PATH and can execute successfully. +func DetectAllLocalRuntimes() []string { + var available []string + for _, c := range localRuntimeCandidates { + if c.DarwinOnly && runtime.GOOS != "darwin" { + continue + } + if _, err := lookPathFunc(c.Binary); err != nil { + continue + } + if err := runCheckFunc(c.Binary, c.CheckArgs); err != nil { + continue + } + available = append(available, c.Name) + } + return available +} + // OverrideRuntimeDetection replaces the functions used by DetectLocalRuntime // to look up and verify container runtimes. This is intended for use in tests // across packages. Returns a restore function that should be deferred. diff --git a/pkg/hub/handlers.go b/pkg/hub/handlers.go index ccb68617c..1d3e5b6e1 100644 --- a/pkg/hub/handlers.go +++ b/pkg/hub/handlers.go @@ -4668,6 +4668,16 @@ func (s *Server) handleProjectRegister(w http.ResponseWriter, r *http.Request) { return } + // For linked projects (local directory), initialize the .scion + // directory structure so agents and templates directories exist. + if localPath != "" { + scionDir := filepath.Join(localPath, ".scion") + if err := config.InitProject(scionDir, nil, config.InitProjectOpts{SkipRuntimeCheck: true}); err != nil { + slog.Warn("failed to initialize .scion in linked project", + "project_id", project.ID, "localPath", localPath, "error", err.Error()) + } + } + // Set as default runtime broker if project doesn't have one if project.DefaultRuntimeBrokerID == "" { project.DefaultRuntimeBrokerID = broker.ID diff --git a/pkg/hub/server.go b/pkg/hub/server.go index d7f17a092..c35ae9be1 100644 --- a/pkg/hub/server.go +++ b/pkg/hub/server.go @@ -2617,6 +2617,7 @@ 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))) + s.mux.Handle("/api/v1/system/registry", s.requireWorkstation(http.HandlerFunc(s.handleSystemRegistry))) // Workstation-only filesystem endpoints s.mux.Handle("/api/v1/system/fs/list", s.requireWorkstation(http.HandlerFunc(s.handleFSList))) diff --git a/pkg/hub/system_handlers.go b/pkg/hub/system_handlers.go index 6a05e415d..6039e607e 100644 --- a/pkg/hub/system_handlers.go +++ b/pkg/hub/system_handlers.go @@ -109,9 +109,10 @@ func (s *Server) handleSystemCheck(w http.ResponseWriter, r *http.Request) { // --- 2.2: Runtime GET/PUT --- type systemRuntimeResponse struct { - Detected string `json:"detected"` - Configured string `json:"configured"` - Available bool `json:"available"` + Detected string `json:"detected"` + Configured string `json:"configured"` + Available bool `json:"available"` + AvailableRuntimes []string `json:"availableRuntimes,omitempty"` } type putRuntimeRequest struct { @@ -143,6 +144,7 @@ func (s *Server) handleSystemRuntime(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetRuntime(w http.ResponseWriter, r *http.Request) { detected, detectErr := config.DetectLocalRuntime() available := detectErr == nil + allRuntimes := config.DetectAllLocalRuntimes() var configured string globalDir, err := config.GetGlobalDir() @@ -161,9 +163,10 @@ func (s *Server) handleGetRuntime(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, systemRuntimeResponse{ - Detected: detected, - Configured: configured, - Available: available, + Detected: detected, + Configured: configured, + Available: available, + AvailableRuntimes: allRuntimes, }) } @@ -215,6 +218,62 @@ func (s *Server) handlePutRuntime(w http.ResponseWriter, r *http.Request) { }) } +// --- 2.2b: Registry PUT --- + +type putRegistryRequest struct { + ImageRegistry string `json:"image_registry"` +} + +type putRegistryResponse struct { + ImageRegistry string `json:"image_registry"` +} + +func (s *Server) handleSystemRegistry(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 putRegistryRequest + if err := readJSON(r, &req); err != nil { + BadRequest(w, "invalid request body") + return + } + + if req.ImageRegistry == "" { + ValidationError(w, "image_registry must not be empty", nil) + return + } + + globalDir, err := config.GetGlobalDir() + if err != nil { + writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "cannot determine config directory", nil) + return + } + + vs, err := config.LoadSingleFileVersioned(globalDir) + if err != nil { + writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "failed to load settings", nil) + return + } + + vs.ImageRegistry = req.ImageRegistry + + if err := config.SaveVersionedSettings(globalDir, vs); err != nil { + writeError(w, http.StatusInternalServerError, ErrCodeInternalError, "failed to save image registry setting", nil) + return + } + + writeJSON(w, http.StatusOK, putRegistryResponse{ + ImageRegistry: req.ImageRegistry, + }) +} + // --- 2.3: Onboarding Status --- type OnboardingStatus struct { diff --git a/web/src/components/pages/onboarding.ts b/web/src/components/pages/onboarding.ts index 130ed655a..3cfbf05ef 100644 --- a/web/src/components/pages/onboarding.ts +++ b/web/src/components/pages/onboarding.ts @@ -52,6 +52,7 @@ interface RuntimeResponse { detected: string; configured: string; available: boolean; + availableRuntimes?: string[]; } @customElement('scion-page-onboarding') @@ -73,6 +74,7 @@ export class ScionPageOnboarding extends LitElement { @state() private detectedRuntime = ''; @state() private configuredRuntime = ''; @state() private selectedRuntime = ''; + @state() private availableRuntimes: string[] = []; // Step 3: Harnesses @state() private selectedHarnesses = new Set(); @@ -86,6 +88,8 @@ export class ScionPageOnboarding extends LitElement { @state() private runtimeAvailable = false; @state() private buildAvailable = false; @state() private imageRegistry = ''; + @state() private registryInput = 'ghcr.io/homebrew-scion'; + @state() private registrySaving = false; @state() private gitVersion = ''; @state() private gitVersionOK = true; private imageEventSource: EventSource | null = null; @@ -737,9 +741,9 @@ export class ScionPageOnboarding extends LitElement { value=${this.selectedRuntime} @sl-change=${(e: Event) => { this.selectedRuntime = (e.target as HTMLSelectElement).value; }} > - Docker - Podman - Container (generic) + ${this.renderRuntimeOption('docker', 'Docker')} + ${this.renderRuntimeOption('podman', 'Podman')} + ${this.renderRuntimeOption('container', 'Container (Apple Virtualization)')} `} @@ -770,6 +774,7 @@ export class ScionPageOnboarding extends LitElement { const data = (await res.json()) as RuntimeResponse; this.detectedRuntime = data.detected; this.configuredRuntime = data.configured; + this.availableRuntimes = data.availableRuntimes ?? []; this.selectedRuntime = data.configured || data.detected || 'docker'; } catch { this.error = 'Failed to connect to the server.'; @@ -778,6 +783,18 @@ export class ScionPageOnboarding extends LitElement { } } + private renderRuntimeOption(value: string, label: string) { + const isAvailable = this.availableRuntimes.includes(value); + const isDetected = this.detectedRuntime === value; + let suffix = ''; + if (isDetected) { + suffix = ' (detected)'; + } else if (!isAvailable) { + suffix = ' (not detected)'; + } + return html`${label}${suffix}`; + } + private async handleRuntimeNext(): Promise { this.error = null; this.stepLoading = true; @@ -878,20 +895,26 @@ export class ScionPageOnboarding extends LitElement { `; } - // No registry and no local build — show registry setup guidance + // No registry and no local build — show registry input if (!this.imageRegistry && !this.buildAvailable) { return html`

Container Images

-
- No image registry configured. -

- An image registry is required to pull container images. - Run the following to configure one, then restart the server: -

- scion config set --global image_registry ghcr.io/homebrew-scion -

If you installed via Homebrew, try reinstalling to auto-configure the registry:

- brew reinstall --HEAD homebrew-scion/scion/scion-workstation +

An image registry is required to pull container images. Enter one below.

+
+ + { this.registryInput = (e.target as HTMLInputElement).value; }} + >
+ Save ` : nothing} - ${this.entries.map(e => html` + ${this.filteredEntries.length === 0 && this.filterText ? html` +
No matches for "${this.filterText}"
+ ` : nothing} + ${this.filteredEntries.map(e => html`
this.onEntryClick(e)}> ${e.name} From b6eedbaf91864732239d0802aa6d59064e9c4d1b Mon Sep 17 00:00:00 2001 From: "Scion Agent (onboarding-improvements-dev)" Date: Wed, 17 Jun 2026 05:23:17 +0000 Subject: [PATCH 3/9] Fix server stop when PID file is deleted while server is running When the PID file is missing but the server process is still alive, `scion server stop` now probes the default server ports (8080, 9800, 9810) instead of immediately erroring. If occupied ports are found, it shows the user what was detected and asks for confirmation before killing the processes. In non-interactive/auto-confirm mode the kill proceeds automatically. --- cmd/server_daemon.go | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/cmd/server_daemon.go b/cmd/server_daemon.go index a2e2bb137..1b3459320 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/hubsync" "github.com/GoogleCloudPlatform/scion/pkg/util" "github.com/spf13/cobra" ) @@ -189,7 +190,46 @@ func runServerStop(cmd *cobra.Command, args []string) error { } if !running { - return fmt.Errorf("server daemon is not running") + // PID file is missing or stale — probe ports to see if a server is + // still listening. This handles the case where the PID file was + // deleted while the server was still running. + ports := []int{8080, 9800, 9810} + occupied := daemon.DetectOccupiedPorts(ports) + if len(occupied) == 0 { + return fmt.Errorf("server daemon is not running") + } + + fmt.Println("No PID file found, but server port(s) appear to be in use:") + for _, port := range occupied { + fmt.Printf(" port %d\n", port) + } + fmt.Println() + + if !hubsync.ConfirmAction("Kill the process(es) on these ports?", false, autoConfirm) { + fmt.Println("Aborted.") + return nil + } + + killed := 0 + for _, port := range occupied { + killedPID, err := daemon.ForceKillPort(port) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to kill process on port %d: %v\n", port, err) + continue + } + if killedPID > 0 { + fmt.Printf("Killed process %d on port %d\n", killedPID, port) + killed++ + } + } + + _ = daemon.RemovePIDComponent(serverDaemonComponent, globalDir) + + if killed == 0 { + return fmt.Errorf("failed to kill any processes on occupied ports") + } + fmt.Println("Server stopped.") + return nil } fmt.Printf("Stopping server daemon (PID: %d)...\n", pid) From ca62a0930ea7c7099e9540f479c3da50d2409613 Mon Sep 17 00:00:00 2001 From: "Scion Agent (onboarding-improvements-lead)" Date: Wed, 17 Jun 2026 05:26:25 +0000 Subject: [PATCH 4/9] Fix version command in Makefile install message The install target printed "scion --version" but the correct command is "scion version" (subcommand, not flag). --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index a30b39f1f..153ae1737 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,7 @@ install: @echo "" @echo "✔ Installed $(BINARY) to $(DESTDIR)$(INSTALL_DIR)/$(BINARY)" @echo "" - @echo " Run 'scion --version' to verify." + @echo " Run 'scion version' to verify." @echo "" @case ":$$PATH:" in \ *":$(INSTALL_DIR):"* | *":$(INSTALL_DIR)/:"*) ;; \ From e0079f69a62d20a4e68b2b8766065c4e0401be66 Mon Sep 17 00:00:00 2001 From: "Scion Agent (onboarding-improvements-lead)" Date: Wed, 17 Jun 2026 05:37:42 +0000 Subject: [PATCH 5/9] Add Tab completion to dir-browser and auto-fill project name from path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dir browser: Tab key now completes the current filter — navigates into the directory if there's a single match, or extends the filter to the common prefix if there are multiple matches. This lets users build paths quickly by typing + Tab. Project creation: When a path is selected via the dir browser, the project name field auto-fills from the final path segment if it's empty. Applied to both the onboarding wizard and project-create page. --- web/src/components/pages/onboarding.ts | 4 ++++ web/src/components/pages/project-create.ts | 10 +++++++++ web/src/components/shared/dir-browser.ts | 25 ++++++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/web/src/components/pages/onboarding.ts b/web/src/components/pages/onboarding.ts index 3cfbf05ef..1b3839718 100644 --- a/web/src/components/pages/onboarding.ts +++ b/web/src/components/pages/onboarding.ts @@ -1320,6 +1320,10 @@ export class ScionPageOnboarding extends LitElement { ) => { this.wsLocalPath = e.detail.path; + if (!this.wsProjectName.trim()) { + const segments = e.detail.path.replace(/\/+$/, '').split('/'); + this.wsProjectName = segments[segments.length - 1] || ''; + } void this.wsValidatePath(e.detail.path); }} > diff --git a/web/src/components/pages/project-create.ts b/web/src/components/pages/project-create.ts index 2588f8283..95b4bf9f3 100644 --- a/web/src/components/pages/project-create.ts +++ b/web/src/components/pages/project-create.ts @@ -171,6 +171,16 @@ export class ScionPageProjectCreate extends LitElement { private onDirBrowserPathSelected(e: CustomEvent<{ path: string }>): void { this.localPath = e.detail.path; this.browseDialogOpen = false; + if (!this.name) { + const segments = e.detail.path.replace(/\/+$/, '').split('/'); + const derived = segments[segments.length - 1] || ''; + if (derived) { + this.name = derived; + if (!this.slugManuallyEdited) { + this.slug = this.slugify(derived); + } + } + } void this.validateLocalPath(e.detail.path); } diff --git a/web/src/components/shared/dir-browser.ts b/web/src/components/shared/dir-browser.ts index 49ca472de..a6b2d8084 100644 --- a/web/src/components/shared/dir-browser.ts +++ b/web/src/components/shared/dir-browser.ts @@ -297,6 +297,19 @@ export class ScionDirBrowser extends LitElement { this.filterText = ''; return; } + if (e.key === 'Tab') { + e.preventDefault(); + const dirMatches = this.filteredEntries.filter(entry => entry.isDir); + if (dirMatches.length === 1) { + void this.navigate(this.currentPath + '/' + dirMatches[0].name); + } else if (dirMatches.length > 1) { + const prefix = this.commonPrefix(dirMatches.map(d => d.name)); + if (prefix.length > this.filterText.length) { + this.filterText = prefix; + } + } + return; + } if (e.key === 'Enter') { const matches = this.filteredEntries.filter(entry => entry.isDir); if (matches.length === 1) { @@ -306,6 +319,18 @@ export class ScionDirBrowser extends LitElement { } } + private commonPrefix(strings: string[]): string { + if (strings.length === 0) return ''; + let prefix = strings[0]; + for (let i = 1; i < strings.length; i++) { + while (!strings[i].toLowerCase().startsWith(prefix.toLowerCase())) { + prefix = prefix.slice(0, -1); + if (!prefix) return ''; + } + } + return prefix; + } + override render() { const segments = this.currentPath.split('/').filter(Boolean); From b582da49bf59f9c26f039901184a9554c2f7296f Mon Sep 17 00:00:00 2001 From: "Scion Agent (onboarding-improvements-dev)" Date: Wed, 17 Jun 2026 05:40:22 +0000 Subject: [PATCH 6/9] Run InitProject when adding a local directory provider via the API The POST /api/v1/projects/{id}/providers endpoint (used by both the onboarding wizard and project-create page) was missing the InitProject call that handleProjectRegister already had. This meant .scion/ was never created in the linked directory when using the two-step create-project-then-add-provider flow. --- pkg/hub/handlers.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pkg/hub/handlers.go b/pkg/hub/handlers.go index 1d3e5b6e1..48eb63ada 100644 --- a/pkg/hub/handlers.go +++ b/pkg/hub/handlers.go @@ -8886,6 +8886,16 @@ func (s *Server) addProjectProvider(w http.ResponseWriter, r *http.Request, proj return } + // For linked projects (local directory), initialize the .scion directory + // so agents and templates directories exist before the first agent starts. + if req.LocalPath != "" { + scionDir := filepath.Join(req.LocalPath, ".scion") + if err := config.InitProject(scionDir, nil, config.InitProjectOpts{SkipRuntimeCheck: true}); err != nil { + slog.Warn("failed to initialize .scion in linked project", + "project_id", projectID, "localPath", req.LocalPath, "error", err.Error()) + } + } + // Get the project to check if we should set default runtime broker project, err := s.store.GetProject(ctx, projectID) if err == nil && project.DefaultRuntimeBrokerID == "" { From 27b52507b2f6effcae56a75492996159935b52fc Mon Sep 17 00:00:00 2001 From: "Scion Agent (onboarding-improvements-dev)" Date: Wed, 17 Jun 2026 05:44:21 +0000 Subject: [PATCH 7/9] Wait for server readiness before opening browser on first start Add waitForServerReady() that polls /healthz with 250ms intervals up to a 20-second timeout. On first start the server may take several seconds to initialize (storage, templates, migrations), so opening the browser immediately caused connection errors. Now the browser only opens once the health endpoint returns 200. If the timeout expires, the URL is still printed but the browser is not opened. --- cmd/server_daemon.go | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/cmd/server_daemon.go b/cmd/server_daemon.go index 1b3459320..b27830ec6 100644 --- a/cmd/server_daemon.go +++ b/cmd/server_daemon.go @@ -461,6 +461,25 @@ func runServerStatus(cmd *cobra.Command, args []string) error { return nil } +// waitForServerReady polls the server's /healthz endpoint until it returns 200 +// or the timeout expires. Returns true if the server became ready. +func waitForServerReady(host string, port int, timeout time.Duration) bool { + client := &http.Client{Timeout: 2 * time.Second} + url := fmt.Sprintf("http://%s:%d/healthz", host, port) + deadline := time.Now().Add(timeout) + + for time.Now().Before(deadline) { + if resp, err := client.Get(url); err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return true + } + } + time.Sleep(250 * time.Millisecond) + } + return false +} + // 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. @@ -481,9 +500,13 @@ func printWorkstationQuickstart(needsOnboarding bool, globalDir string, host str url := fmt.Sprintf("http://%s:%d%s", displayHost, wPort, path) fmt.Printf("Web UI: %s\n", url) - // Auto-open the browser in interactive terminals + // Auto-open the browser in interactive terminals once the server is ready. if os.Getenv("SCION_NO_BROWSER") == "" && util.IsTerminal() && !util.IsHeadlessEnvironment() { - _ = util.OpenBrowser(url) + if waitForServerReady(displayHost, wPort, 20*time.Second) { + _ = util.OpenBrowser(url) + } else { + fmt.Println(" (server not yet ready — open the URL manually once it starts)") + } } } From 14bf1c30b3a429234f62999c556362c1ebb89f57 Mon Sep 17 00:00:00 2001 From: "Scion Agent (onboarding-improvements-dev)" Date: Wed, 17 Jun 2026 10:54:34 +0000 Subject: [PATCH 8/9] Add harness config URL import and imported image support to onboarding Feature 1: Embed on the harness selection step (step 3) so users can import additional harness configurations from a URL. After import, the new configs appear as selectable checkboxes alongside the built-in harnesses. Feature 2: On the image step (step 4), show images from imported harness configs with their actual image names. Configs that include a Dockerfile in their file manifest are labeled "Dockerfile included" and the build-locally gate accounts for them, so even without a system-level build script available, users can still proceed if imported configs have Dockerfiles. --- web/src/components/pages/onboarding.ts | 85 ++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 5 deletions(-) diff --git a/web/src/components/pages/onboarding.ts b/web/src/components/pages/onboarding.ts index 1b3839718..ef328c31c 100644 --- a/web/src/components/pages/onboarding.ts +++ b/web/src/components/pages/onboarding.ts @@ -19,6 +19,7 @@ import { customElement, state } from 'lit/decorators.js'; import { apiFetch, extractApiError } from '../../client/api.js'; import '../shared/dir-browser.js'; +import '../shared/resource-import.js'; const ONBOARDING_STATUS_KEY = 'onboardingStatus'; const TOTAL_STEPS = 6; @@ -55,6 +56,18 @@ interface RuntimeResponse { availableRuntimes?: string[]; } +interface HarnessConfigInfo { + id: string; + name: string; + slug: string; + displayName?: string; + harness: string; + config?: { + image?: string; + }; + files?: Array<{ path: string }>; +} + @customElement('scion-page-onboarding') export class ScionPageOnboarding extends LitElement { @state() private currentStep = 0; @@ -78,6 +91,7 @@ export class ScionPageOnboarding extends LitElement { // Step 3: Harnesses @state() private selectedHarnesses = new Set(); + @state() private importedHarnessConfigs: HarnessConfigInfo[] = []; // Step 4: Images @state() private imageStatuses = new Map(); @@ -817,7 +831,7 @@ export class ScionPageOnboarding extends LitElement { // ── Step 3: Harnesses ── private renderHarnesses() { - const harnesses = [ + const builtinHarnesses = [ { id: 'claude', label: 'Claude Code' }, { id: 'gemini', label: 'Gemini' }, { id: 'codex', label: 'Codex' }, @@ -829,7 +843,7 @@ export class ScionPageOnboarding extends LitElement {

Select which AI coding harnesses to configure.

- ${harnesses.map(h => html` + ${builtinHarnesses.map(h => html`
${h.label}
`)} + + ${this.importedHarnessConfigs.map(hc => html` +
+ { + const checked = (e.target as HTMLInputElement).checked; + const next = new Set(this.selectedHarnesses); + if (checked) { next.add(hc.slug); } else { next.delete(hc.slug); } + this.selectedHarnesses = next; + }} + >${hc.displayName || hc.name} (imported) +
+ `)} +
+ +
+

Import additional harness configurations from URL

+