From 84c5766148357bc8e45b38e9122b73812942f9a7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 19 Jun 2026 22:34:35 -0700 Subject: [PATCH 1/3] feat: add private tenant isolation --- .github/workflows/deploy-worker.yml | 10 + CHANGELOG.md | 8 + README.md | 12 +- docs/admin.md | 37 +- docs/api.md | 77 +++- docs/architecture.md | 15 +- docs/github-actions-sessions.md | 4 + docs/index.md | 1 + docs/runs.md | 10 +- docs/spec.md | 17 +- internal/terminalws/client.go | 42 +- internal/terminalws/client_test.go | 255 ++++++++++++ migrations/0028_tenant_isolation.sql | 391 ++++++++++++++++++ package.json | 4 +- pnpm-workspace.yaml | 5 + scripts/backfill-tenant-isolation.sql | 129 ++++++ scripts/finalize-tenant-isolation.sql | 136 ++++++ scripts/generate-assets.mjs | 23 +- scripts/lucide-icon-script.mjs | 23 ++ src/app.html | 80 +++- src/app/app-data.js | 253 ++++++++++-- src/app/app-mutations.js | 62 ++- src/app/app-shell-state.js | 10 +- src/app/dialogs.jsx | 132 +++++- src/app/fleet.jsx | 5 +- src/app/linked-session.js | 43 +- src/app/main.jsx | 8 +- src/app/routing.js | 6 + src/app/session-state.js | 5 +- src/app/session-workspace.jsx | 20 +- src/app/terminal.js | 39 +- src/app/utils.js | 14 +- src/fleet-state.ts | 23 +- src/worker/auth.ts | 18 +- src/worker/card-lifecycle-service.ts | 55 ++- src/worker/card-model.ts | 3 + src/worker/card-repository.ts | 96 +++-- src/worker/database.ts | 18 + src/worker/env.ts | 2 + src/worker/github-actions-application.ts | 7 + src/worker/github-actions-repository.ts | 10 + .../github-actions-session-registration.ts | 47 ++- src/worker/interactive-desktop-service.ts | 72 +++- src/worker/interactive-session-application.ts | 236 +++++++++-- src/worker/interactive-terminal-repository.ts | 2 + src/worker/interactive-terminal-service.ts | 70 +++- src/worker/models.ts | 7 + src/worker/openclaw-application.ts | 83 +++- src/worker/openclaw-controller.ts | 8 +- src/worker/openclaw-create.ts | 40 +- src/worker/openclaw-embed-access.ts | 10 + src/worker/openclaw-mutations.ts | 8 +- src/worker/openclaw-request.ts | 47 ++- .../runtime-adapter-repository.ts | 6 + src/worker/provisioning/sandbox-lifecycle.ts | 2 + src/worker/provisioning/sandbox.ts | 20 +- src/worker/routes/control-plane.ts | 4 +- .../routes/interactive-session-resources.ts | 40 +- src/worker/routes/service-sessions.ts | 4 +- .../sandbox-credential-policy-cleanup.ts | 2 + .../sandbox-session-resource-service.ts | 37 +- src/worker/sandbox-session-resources.ts | 40 +- src/worker/session-access.ts | 136 +++++- src/worker/session-agent-auth.ts | 7 +- src/worker/session-authorized-refresh.ts | 15 + src/worker/session-cleanup.ts | 4 + src/worker/session-creation.ts | 43 +- src/worker/session-grant-repository.ts | 289 +++++++++++++ src/worker/session-grant-service.ts | 217 ++++++++++ src/worker/session-lineage.ts | 7 +- src/worker/session-metadata.ts | 15 +- src/worker/session-model.ts | 11 + src/worker/session-presentation.ts | 19 +- src/worker/session-reconciliation.ts | 2 + src/worker/session-repository.ts | 60 +++ src/worker/session-terminal-availability.ts | 4 +- src/worker/tenancy.ts | 21 + src/worker/terminal-hub.ts | 98 +++-- src/worker/worker-application.ts | 29 +- tests/app-data.test.ts | 176 ++++++++ tests/app-linked-session.test.ts | 14 +- tests/app-mutations.test.ts | 39 ++ tests/app-routing.test.ts | 7 + tests/app-session-state.test.ts | 5 +- tests/app-shell-state.test.ts | 30 ++ tests/app-terminal-access.test.ts | 56 +++ tests/app-utils.test.ts | 13 + tests/application-architecture.test.ts | 49 ++- tests/auth.test.ts | 72 ++++ tests/browser-session-routes.test.ts | 45 +- tests/card-repository.test.ts | 112 +++++ tests/control-plane-routes.test.ts | 12 +- tests/fleet-state.test.ts | 36 ++ tests/github-actions-repository.test.ts | 15 +- ...ithub-actions-session-registration.test.ts | 116 ++++++ tests/helpers/session-row.ts | 3 + tests/html-dialogs.test.ts | 29 ++ tests/interactive-desktop-service.test.ts | 19 +- tests/openclaw-application.test.ts | 74 ++++ tests/openclaw-controller.test.ts | 6 +- tests/openclaw-create.test.ts | 153 +++++++ tests/openclaw-embed-access.test.ts | 33 ++ tests/openclaw-mutations.test.ts | 11 +- tests/openclaw-request.test.ts | 62 +++ tests/openclaw-routes.test.ts | 6 +- tests/sandbox-provisioning.test.ts | 52 +++ .../sandbox-session-resource-service.test.ts | 41 +- tests/service-session-routes.test.ts | 14 +- tests/session-access.test.ts | 216 ++++++++++ tests/session-agent-auth.test.ts | 14 + tests/session-authorized-refresh.test.ts | 52 +++ tests/session-cleanup.test.ts | 8 +- tests/session-creation.test.ts | 29 +- tests/session-grant-repository.test.ts | 249 +++++++++++ tests/session-grant-service.test.ts | 271 ++++++++++++ tests/session-lineage.test.ts | 6 +- tests/session-metadata.test.ts | 29 +- tests/session-presentation.test.ts | 26 ++ tests/session-repository.test.ts | 60 +++ tests/tenancy.test.ts | 44 ++ tests/tenant-isolation-migration.test.ts | 229 ++++++++++ tests/terminal-hub.test.ts | 139 +++++++ tests/vite-config.test.ts | 50 +++ vite.config.mjs | 41 +- worker-configuration.d.ts | 2 + wrangler.jsonc | 3 +- wrangler.product.jsonc | 2 +- 127 files changed, 6151 insertions(+), 489 deletions(-) create mode 100644 migrations/0028_tenant_isolation.sql create mode 100644 scripts/backfill-tenant-isolation.sql create mode 100644 scripts/finalize-tenant-isolation.sql create mode 100644 scripts/lucide-icon-script.mjs create mode 100644 src/worker/session-authorized-refresh.ts create mode 100644 src/worker/session-grant-repository.ts create mode 100644 src/worker/session-grant-service.ts create mode 100644 src/worker/tenancy.ts create mode 100644 tests/app-terminal-access.test.ts create mode 100644 tests/card-repository.test.ts create mode 100644 tests/openclaw-application.test.ts create mode 100644 tests/session-authorized-refresh.test.ts create mode 100644 tests/session-grant-repository.test.ts create mode 100644 tests/session-grant-service.test.ts create mode 100644 tests/tenancy.test.ts create mode 100644 tests/tenant-isolation-migration.test.ts create mode 100644 tests/vite-config.test.ts diff --git a/.github/workflows/deploy-worker.yml b/.github/workflows/deploy-worker.yml index 0f737d3f..ff1145cf 100644 --- a/.github/workflows/deploy-worker.yml +++ b/.github/workflows/deploy-worker.yml @@ -120,6 +120,16 @@ jobs: done pnpm exec wrangler deploy --secrets-file "$secrets_file" + - name: Repeat tenant isolation backfill + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: | + for attempt in 1 2 3; do + pnpm deploy:tenant-backfill && exit 0 + sleep $((attempt * 5)) + done + pnpm deploy:tenant-backfill + - name: Ensure domains env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_DNS_API_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index a643e120..65549984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ - Add atomically claimed recurring card intervals with constant-time catch-up, crash-recoverable leases, scheduler/API proof, and coalescing for active or capacity-blocked runs, thanks @Jhacarreiro. - Reuse `@openclaw/libterminal` for terminal protocol codecs, Worker relays, Ghostty assets, and browser hub transport while keeping Crabfleet authorization and session policy local. - Update `@openclaw/libterminal` to 0.3.1 for terminal lifecycle, Worker asset generation, and package-validation fixes. +- Add private-by-default tenant isolation for cards and sessions, trusted-proxy automatic onboarding, stable owner identities, expiring named viewer/controller grants with UI/API revocation, and current authorization checks across terminal, desktop, diagnostics, checkpoints, logs, transcripts, metadata, cleanup, and child-session paths. +- Revalidate named principals against current authentication policy, migrate only unambiguous legacy subjects, reject subjectless private control approvals, and scope Fleet policy totals to visible sessions. +- Bridge the tenant-isolation cutover with legacy-writer triggers and an idempotent post-deploy backfill, retaining the triggers until a later rollout has drained old Worker requests. +- Keep teardown revocation available, bind OpenClaw replay identity and Sandbox refresh to stable owners, conceal hidden terminal state, and render destructive controls only from server-computed per-session authority. +- Keep shared-mode live terminal reads behind control or a named grant, revision-fence concurrent grant revocation, and preserve owner-validated legacy OpenClaw replays. +- Bind OpenClaw crabboxes and GitHub Actions sessions to explicit stable human owners in private tenancy, retain service authority only across validated OpenClaw lineage, and keep bootstrap ownership stable across token rotation. +- Fix local Vite development with `@openclaw/libterminal` by reserving Worker-served Ghostty aliases for production builds and serving the local WASM, icons, and logo without generated placeholders. +- Keep the mobile navigation and named-access dialog within narrow viewports. - Fix automated Worker deployments by converging the app Custom Domain with the DNS-scoped deployment token instead of requiring zone-route access from the Worker token. ## 0.2.0 - 2026-06-15 diff --git a/README.md b/README.md index 7f791084..dc50db0d 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Crabfleet gives OpenClaw maintainers a fleet dashboard where every Codex crabbox - **Diff previews.** Card tiles show changed files and totals; the run drawer shows a compact Codiff-style patch view. - **Multi-runtime policy.** Auto-select between the Container and Crabbox adapter surfaces based on card overrides, repo workflow defaults, and task requirements. - **Allowlist controls.** Restrict access to OpenClaw org members and specific repos through admin-managed allowlists. +- **Private tenant isolation.** New deployments show each user only their own cards and sessions unless the owner creates a named grant, a delegated-control lease, or a public read-only link. - **Session history.** D1-backed card/run events plus periodically refreshed R2 event, transcript, and summary snapshots with terminal finalization guarantees. - **Repo workflow config.** Owners can evaluate `CRABBOX.md` per repo and use it for runtime and merge defaults. @@ -89,10 +90,10 @@ POST /api/openclaw/action-sessions Authorization: Bearer CRABBOX_OPENCLAW_TOKEN Content-Type: application/json -{"workKey":"openclaw/crabfleet:pr:42","workKind":"pr_repair","repo":"openclaw/crabfleet","branch":"fix/pr-42","sourceUrl":"https://github.com/openclaw/crabfleet/pull/42","runUrl":"https://github.com/openclaw/crabfleet/actions/runs/123","purpose":"repair PR 42","summary":"starting repair"} +{"workKey":"openclaw/crabfleet:pr:42","workKind":"pr_repair","repo":"openclaw/crabfleet","branch":"fix/pr-42","owner":"operator@example.test","sourceUrl":"https://github.com/openclaw/crabfleet/pull/42","runUrl":"https://github.com/openclaw/crabfleet/actions/runs/123","purpose":"repair PR 42","summary":"starting repair"} ``` -The response contains `{session, agentToken, runnerPtyUrl, browserUrl}`. `runnerPtyUrl` includes the rotated session-scoped query credential and works directly with Node's global `WebSocket`: +The response contains `{session, agentToken, runnerPtyUrl, browserUrl}`. Private-tenancy deployments require `owner` to resolve to one active Crabfleet user; the stable subject owns browser visibility while the OpenClaw service retains lifecycle authority for its session. `runnerPtyUrl` includes the rotated session-scoped query credential and works directly with Node's global `WebSocket`: ```js const terminal = new WebSocket(runnerPtyUrl); @@ -149,6 +150,7 @@ merge: - Bootstrap token for admin setup and recovery - Short-lived D1-backed sessions; users reauthenticate after expiry - Role-based access control (owner, maintainer, viewer) +- Private-by-default tenant isolation with time-limited named viewer/controller grants ## Deployment @@ -197,6 +199,8 @@ The Crabbox namespace cutover intentionally has no old-name compatibility. Exist - `CRABFLEET_TRUSTED_PROXY_PUBLIC_ORIGIN` – Optional browser-visible HTTPS origin required on mutations and WebSocket upgrades; defaults to `CRABFLEET_TRUSTED_PROXY_ORIGIN` - `CRABFLEET_TRUSTED_PROXY_SECRET` – Shared secret required on `X-Crabfleet-Proxy-Secret` for trusted reverse-proxy identity - `CRABFLEET_TRUSTED_USER_HEADER` – Optional trusted identity header name, default `X-Authenticated-User`; the proxy must remove caller-supplied copies before injecting it +- `CRABFLEET_TRUSTED_PROXY_AUTO_ROLE` – Optional `viewer` or `maintainer` role for valid trusted-proxy identities without individual allowlist entries; other values fail closed. Use `maintainer` when every authenticated tenant should be able to create its own work. +- `CRABFLEET_TENANCY_MODE` – Optional `private` or `shared`; defaults to `private`. Private mode scopes cards and sessions to their stable owner subject plus explicit, unexpired session grants. Bootstrap token rotation preserves one stable bootstrap owner subject. `shared` restores the legacy team-wide visibility model and should be an intentional deployment choice. - `GITHUB_CLIENT_ID` – GitHub OAuth app client ID (optional) - `GITHUB_CLIENT_SECRET` – GitHub OAuth app secret (optional) - `GITHUB_REDIRECT_URI` – Optional authoritative GitHub OAuth callback URL; when set it must be an absolute HTTPS URL with no credentials, query, or fragment and the exact `/auth/github/callback` path. Requests on another host restart login on this configured origin. When absent, the callback defaults to the HTTPS request origin (or literal-loopback HTTP for local development). @@ -368,7 +372,7 @@ curl -fsS https://crabfleet.openclaw.ai/api/openclaw/crabboxes \ -d '{"owner":"@steipete","repo":"openclaw/crabfleet","prompt":"prep the meeting follow-up"}' ``` -The created crabbox appears in the fleet grid under the requested owner. Provisioning follows normal interactive-session routing: built-in Sandbox for Container or the versioned adapter for Crabbox. +The created crabbox appears in the fleet grid under the requested owner. In private tenancy, `owner` must resolve to one active Crabfleet user by login, email, or stable subject. Provisioning follows normal interactive-session routing: built-in Sandbox for Container or the versioned adapter for Crabbox. ### Project Structure @@ -412,6 +416,8 @@ Full documentation available at [docs.crabfleet.ai](https://docs.crabfleet.ai): - All state-changing operations require authentication - Repo operations require allowlist membership +- Cards and sessions are tenant-private by default; global roles do not bypass another tenant's session boundary +- Named session grants are owner-managed, time-limited, and independently scoped to read-only or terminal-control access - Merge policy is stored as intent; Crabfleet does not currently perform merges - Runtime tokens are scoped and short-lived - Secrets never logged or stored in D1/R2 diff --git a/docs/admin.md b/docs/admin.md index 2b4ab1ed..227541be 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -23,7 +23,8 @@ The Admin drawer manages user/team access, enabled repos, card policy defaults, - Create and move cards. - Start/pulse/stall card attempts. -- Create, attach, share, control, delete, and clean up visible interactive sessions. +- Create cards and interactive sessions for their tenant. +- Attach, share, control, delete, and clean up sessions they own or can access at the required level. - Take over active card attempts when the runtime descriptor advertises takeover. Maintainers cannot edit org policy or allowlists. @@ -33,9 +34,29 @@ Maintainers cannot edit org policy or allowlists. - Read Board and Fleet state. - Open session logs. - Use a public session share link. +- Use a current named viewer or controller grant. - Request delegated terminal control where enabled. -Viewers cannot create cards or sessions or mutate policy. Terminal subscription additionally requires session ownership, a valid share token, or an approved control grant. +Viewers cannot create cards or sessions or mutate policy. Terminal subscription additionally requires session ownership, a current named grant, a valid share token, or an approved control lease. + +## Tenant Isolation + +`CRABFLEET_TENANCY_MODE` defaults to `private`. + +In private mode: + +- cards are visible only to their stable owner subject; +- sessions are visible only to their owner, a user with an unexpired named grant, or the current delegated controller; +- `maintainer` and `owner` roles do not bypass another tenant's card or session boundary; +- only the session owner can manage named grants, sharing, checkpoints, metadata, and lifecycle; +- an exact internal service creator retains lifecycle and terminal authority over its own session and validated descendants in that service-owned OpenClaw lineage, without granting another human tenant visibility; +- named `viewer` grants allow state, logs, transcript, and read-only terminal output; +- named `controller` grants add terminal input, diagnostics, clipboard, and desktop access; +- a public share link remains read-only and can be disabled independently. + +Named owners and grants resolve only users admitted by the current allowlist or configured trusted-proxy automatic role. A stale `users.allowed` value does not authorize a principal. Private mode never falls back to mutable legacy owner/controller labels; an unresolved legacy control request must be denied rather than approved. + +Set `CRABFLEET_TENANCY_MODE=shared` only to retain the legacy team-wide visibility model. Shared mode keeps role-based maintainer/owner management semantics. ## Access Control @@ -64,7 +85,7 @@ GitHub OAuth refreshes org/team membership at login. The strongest matching role owner > maintainer > viewer ``` -Trusted-proxy assertions cannot claim team entries; proxy users need a direct login/email allowlist entry. +Trusted-proxy assertions cannot claim team entries. Proxy users need a direct login/email allowlist entry unless `CRABFLEET_TRUSTED_PROXY_AUTO_ROLE` is configured. ### Repositories @@ -169,6 +190,7 @@ Optional: - `CRABFLEET_TRUSTED_PROXY_PUBLIC_ORIGIN` - `CRABFLEET_TRUSTED_USER_HEADER` (default `X-Authenticated-User`) +- `CRABFLEET_TRUSTED_PROXY_AUTO_ROLE` (`viewer` or `maintainer` only) The proxy must: @@ -179,7 +201,7 @@ The proxy must: 5. inject `X-Crabfleet-Proxy-Secret`; 6. preserve browser `Origin`. -Crabfleet requires exact backend origin and constant-time shared-secret proof, then applies the normal direct allowlist and role. Unsafe methods and WebSocket upgrades also require the exact browser-visible origin. Missing, partial, malformed, or unexpected assertions fail closed. +Crabfleet requires exact backend origin and constant-time shared-secret proof, then applies the normal direct allowlist and role. When `CRABFLEET_TRUSTED_PROXY_AUTO_ROLE` is set, a valid asserted identity that has no matching allowlist entry is admitted with that role and persisted as an active user; an allowlist match still wins. `owner`, malformed, and unexpected automatic-role values fail closed. Unsafe methods and WebSocket upgrades also require the exact browser-visible origin. Missing, partial, malformed, or unexpected assertions fail closed. Authenticated proxy requests have asserted identity, proxy secret, local cookie, `Authorization`, and `Proxy-Authorization` stripped before app or terminal routing. Service-token routes keep their own scoped auth model. @@ -192,10 +214,15 @@ Proxy-only identity cannot link SSH keys. Use a separate OAuth-capable origin th `CRABBOX_BOOTSTRAP_TOKEN` is owner break-glass access for initial setup or OAuth recovery. - Session lifetime: one hour. +- Tenant ownership uses one stable bootstrap subject, so rotating the token does not orphan bootstrap-owned cards or sessions. - Store in 1Password. - Do not use for routine onboarding. - Open `/app?auth=token` when GitHub auto-login is enabled. +The tenant-isolation migration backfills stable card owners, session owners, and delegated-control subjects only when a legacy subject, login, or email resolves to exactly one active tenant subject. Rotated bootstrap rows collapse to `bootstrap:owner`; ambiguous or unresolved legacy actors remain unbound and fail closed in private mode. + +The migration installs compatibility triggers for writes from the previous Worker during cutover. The standard deploy command and GitHub workflow deploy the new Worker and run `pnpm deploy:tenant-backfill`, but deliberately leave those triggers installed so late writes from drained Worker isolates remain protected. During a later deployment or maintenance window—never the migration rollout itself—run `pnpm deploy:tenant-finalize` to repeat the backfill and remove the triggers after the old Worker generation has fully drained. + ### Scoped Service Auth - SSH gateway: `CRABFLEET_SSH_GATEWAY_TOKEN`. @@ -331,7 +358,7 @@ Verify: 1. request URL origin equals `CRABFLEET_TRUSTED_PROXY_ORIGIN`; 2. browser mutation/WebSocket `Origin` equals the public origin; 3. both identity and secret headers are present; -4. the asserted direct identity is allowlisted; +4. the asserted direct identity is allowlisted or a valid automatic role is configured; 5. the backend is not reachable around the proxy. ### Repo Missing diff --git a/docs/api.md b/docs/api.md index 04146390..e2b1496f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -19,7 +19,9 @@ Session cookie: `crabbox_session` GitHub sessions last 15 minutes. Bootstrap sessions last 1 hour. API JSON responses use `cache-control: no-store`. -Deployments may instead accept a trusted reverse-proxy identity when all trusted-proxy bindings are configured. The request URL must use the exact configured backend origin, the proxy must send the shared secret and configured identity header, and the asserted user must still have a direct login/email allowlist entry. Mutations and WebSocket upgrades must also prove the configured public origin. Crabfleet strips proxy assertions, cookies, and upstream authorization credentials before app and terminal routing. +Deployments may instead accept a trusted reverse-proxy identity when all trusted-proxy bindings are configured. The request URL must use the exact configured backend origin, and the proxy must send the shared secret and configured identity header. The asserted user needs a direct login/email allowlist entry unless `CRABFLEET_TRUSTED_PROXY_AUTO_ROLE` grants valid proxy identities the `viewer` or `maintainer` role; allowlist matches take precedence and all other automatic-role values fail closed. Mutations and WebSocket upgrades must also prove the configured public origin. Crabfleet strips proxy assertions, cookies, and upstream authorization credentials before app and terminal routing. + +`CRABFLEET_TENANCY_MODE` defaults to `private`. Private API reads return only cards and sessions owned by the authenticated stable subject, plus sessions covered by an unexpired named grant or delegated-control lease. Global roles do not bypass this boundary. Set the mode to exact `shared` only for the legacy team-wide visibility contract. ## Public Endpoints @@ -115,6 +117,8 @@ Every card may include: - `logs`: last 80 events - `schedule`, `nextRunAt`, and `lastScheduledRunAt`: recurring cadence and persisted scheduler evidence +In private tenancy mode, `cards`, `interactiveSessions`, and derived Fleet state contain only the current tenant's visible records. + ## GitHub Lookup ### GET /api/github/refs?number=76552 @@ -307,7 +311,7 @@ An adapter-reported `failed` workspace is not locally terminal until Crabfleet c ### GET /api/terminal/ws -Session owner, maintainer/owner role, viewer with a current delegated control grant, SSH gateway linked-key identity, scoped session agent, or a public shared-link token for read-only sessions. Multiplex WebSocket endpoint used by the Ghostty WASM session grid, Go CLI, and SSH gateway. One socket can subscribe to multiple interactive sessions, receive PTY output frames, resize terminals, and send input only when the current user has control. +Visible session owner, user with a current named viewer/controller grant, current delegated controller, SSH gateway linked-key identity, scoped session agent, or a public shared-link token for read-only sessions. In shared tenancy mode, legacy maintainer/owner visibility also applies. Multiplex WebSocket endpoint used by the Ghostty WASM session grid, Go CLI, and SSH gateway. One socket can subscribe to multiple interactive sessions, receive PTY output frames, resize terminals, and send input only when the current user has control. The wire format is a compact binary frame: @@ -331,7 +335,7 @@ Supported client actions: - `Ping`: keepalive, answered with `Pong`. - `Ack`: acknowledge consumed output bytes for negotiated flow control. -Server messages include `Welcome`, `Output`, `Event`, `Error`, `ControlRevoked`, and `Pong`. Shared-link viewers can subscribe and scroll output, but input frames are rejected unless an owner/maintainer grants writable control. Subscriptions require the current `terminal` capability; withdrawing it prevents new attaches, closes existing terminal sockets on the next authorization check, suppresses attachable state from app, API, fleet, CLI, and SSH responses, and removes Fleet terminal/SSH affordances. Recurring and per-input authorization use short-lived D1 snapshots only; throttled subscription reconciliation runs independently and never blocks an input frame on provider I/O. +Server messages include `Welcome`, `Output`, `Event`, `Error`, `ControlRevoked`, and `Pong`. Shared-link and named-viewer clients can subscribe and scroll output, but input frames are rejected without current controller or owner access. Subscriptions require the current `terminal` capability; withdrawing it prevents new attaches, closes existing terminal sockets on the next authorization check, suppresses attachable state from app, API, fleet, CLI, and SSH responses, and removes Fleet terminal/SSH affordances. Recurring and per-input authorization use short-lived D1 snapshots only; throttled subscription reconciliation runs independently and never blocks an input frame on provider I/O. Target resolution: @@ -363,6 +367,7 @@ Request: "workKind": "pr_repair", "repo": "openclaw/crabfleet", "branch": "fix/pr-42", + "owner": "operator@example.test", "sourceUrl": "https://github.com/openclaw/crabfleet/pull/42", "runUrl": "https://github.com/openclaw/crabfleet/actions/runs/123", "purpose": "repair PR 42", @@ -381,7 +386,7 @@ Response: } ``` -`runnerPtyUrl` is directly usable with Node's global `WebSocket`; no custom headers are required. The query credential is session-scoped, rotates on registration, is stored only as a hash, and is not exposed through viewer/session APIs. +Private-tenancy deployments require `owner` for a new work key; it must resolve to exactly one active Crabfleet user by login, email, or stable subject. Existing owned work keys can resume without repeating it, and a work key cannot transfer to a different stable owner. Shared tenancy keeps `owner` optional. `runnerPtyUrl` is directly usable with Node's global `WebSocket`; no custom headers are required. The query credential is session-scoped, rotates on registration, is stored only as a hash, and is not exposed through viewer/session APIs. ### GET /api/agent/interactive-sessions/:id/runner-pty @@ -439,7 +444,7 @@ Fields: Container sessions use the built-in Sandbox when its binding is available. Otherwise, and for Crabbox sessions, `CRABBOX_RUNTIME_ADAPTER_URL` or `CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE` creates and reconciles the versioned adapter workspace and records its resolved lifecycle identity, status, capabilities, expiry, and terminal connection. Without either supported backend the session is stored as `pending_adapter`. -Session responses include `ptyAvailable`, the authenticated Worker's authoritative answer for whether the current terminal capability, lifecycle state, and configured Sandbox or adapter route can resolve a PTY connection. Every controllable session exposes only the Worker-owned `/api/terminal/ws` route in `attachUrl`; signed provider connections remain server-side even for owners and controllers. +Session responses include `ptyAvailable`, the authenticated Worker's authoritative answer for whether the current terminal capability, lifecycle state, grant, and configured Sandbox or adapter route permit a PTY connection. Owners, controllers, and named viewers with live read-only access receive only the Worker-owned `/api/terminal/ws` route in `attachUrl`; signed provider connections remain server-side, and every input frame is independently restricted to current controllers. When the selected runtime profile configures `codexSsh`, a ready `runtime-v1` session response may include `codexSsh: { alias, setupCommand }` for session managers. The alias and optional command are resolved from bounded `{providerResourceId}`, `{workspaceId}`, `{sessionId}`, and `{profile}` placeholders. Alias components use a strict OpenSSH-safe character set. `codexSsh.setupCommand` is an argv-like array whose first and static items use a shell-safe character set and whose dynamic items must each be one complete placeholder; Crabfleet POSIX-shell-quotes every substituted argument so opaque provider identifiers remain data. Missing values, an unsafe resolved alias, or a current profile route that differs from the workspace's immutable registered adapter control plane suppresses the handoff. Shared links and delegated terminal-only controllers never receive it. The command is display/copy data only; Crabfleet never executes it. @@ -459,19 +464,19 @@ Returns refreshed app state plus `removedIds`. ### GET /api/interactive-sessions/:id -Viewer+. Returns one current decorated session after a bounded lifecycle refresh. +Authenticated viewer. Returns one current decorated session after a bounded lifecycle refresh only when the caller owns it, has a current named grant, or holds the active delegated-control lease. Hidden sessions return `404`. ### GET /api/interactive-sessions/:id/logs -Viewer+. Returns up to 5,000 recent D1 events, the total event count, truncation state, and current R2 archive snapshot metadata when available. It does not read or return the archived R2 objects. +Visible-session viewer. Returns up to 5,000 recent D1 events, the total event count, truncation state, and current R2 archive snapshot metadata when available. It does not read or return the archived R2 objects. ### GET /api/interactive-sessions/:id/transcript -Session owner or maintainer/owner role. Returns the Markdown transcript from R2 when archived, or a D1 event-log transcript fallback. +Visible-session viewer. Returns the Markdown transcript from R2 when archived, or a D1 event-log transcript fallback. ### POST /api/interactive-sessions/:id/summary -Viewer+ with owner/maintainer access. Updates `purpose` and/or `summary`. +Session manager. In private mode this is the stable session owner. Updates `purpose` and/or `summary`. ```json { @@ -480,36 +485,60 @@ Viewer+ with owner/maintainer access. Updates `purpose` and/or `summary`. } ``` +### GET /api/interactive-sessions/:id/grants + +Session owner. Lists named access grants, including role and expiry. Expired grants remain visible for owner audit but do not authorize access. Listing remains available while a session is stopping so access can be audited and revoked during teardown. + +### POST /api/interactive-sessions/:id/grants + +Session owner. Creates or replaces one time-limited named grant for exactly one active Crabfleet user. Resolution rechecks the current allowlist or configured trusted-proxy automatic role instead of trusting persisted historical admission state. Returns `201` and the refreshed grant list. + +```json +{ + "principal": "teammate@example.com", + "role": "controller", + "expiresInSeconds": 86400 +} +``` + +- `principal`: unique active user subject, login, or email. +- `role`: `viewer` for state/log/transcript/read-only terminal access, or `controller` to add terminal input, diagnostics, clipboard, and desktop access. +- `expiresInSeconds`: optional; defaults to 24 hours and must be between 5 minutes and 30 days. + +### DELETE /api/interactive-sessions/:id/grants/:subject + +Session owner. Revokes the named grant, atomically clears any pending or active delegated-control lease for that subject, and advances the session revision so an older concurrent approval cannot restore control. Revocation remains available while the session is stopping. Returns the refreshed grant list. `:subject` is URL encoded. + ### GET /api/interactive-sessions/:id/diagnostics Viewer+ with writable control. Runs a bounded environment, checkout, GitHub, Codex, and tool inventory inside a Cloudflare Sandbox session. Other backends return an unavailable result instead of executing diagnostics. ### GET /api/interactive-sessions/:id/checkpoints -Session owner or maintainer. Lists registered Cloudflare Sandbox checkpoints without exposing provider backup material. +Session manager. In private mode this is the stable session owner. Lists registered Cloudflare Sandbox checkpoints without exposing provider backup material. ### POST /api/interactive-sessions/:id/checkpoints -Session owner or maintainer. Creates a backup of the current Sandbox worktree and returns `201`. Checkpoint storage requires the configured backup R2 binding and, for presigned backups, the matching Cloudflare account and R2 credentials. +Session manager. In private mode this is the stable session owner. Creates a backup of the current Sandbox worktree and returns `201`. Checkpoint storage requires the configured backup R2 binding and, for presigned backups, the matching Cloudflare account and R2 credentials. ### POST /api/interactive-sessions/:id/checkpoints/:checkpoint/restore -Session owner or maintainer. Restores a registered checkpoint into the active Cloudflare Sandbox session. +Session manager. In private mode this is the stable session owner. Restores a registered checkpoint into the active Cloudflare Sandbox session. ### POST /api/interactive-sessions/:id/actions Actions: - `attach`: viewer with control, mark seen/attached and return the session. -- `share_link`: owner/maintainer, enable or rotate a public read-only share URL; response includes `shareUrl` once. -- `disable_share`: owner/maintainer, disable the share URL and clear pending/granted control. +- `share_link`: session manager, enable or rotate a public read-only share URL; response includes `shareUrl` once. +- `disable_share`: session manager, disable the share URL and clear pending/granted control. - `request_control`: viewer, request writable terminal control. -- `approve_control`: owner/maintainer, grant pending requester 30 minutes of writable terminal control. -- `deny_control`: owner/maintainer, clear a pending control request. -- `revoke_control`: owner/maintainer, revoke active delegated control. +- `approve_control`: session manager, grant pending requester 30 minutes of writable terminal control. +- `deny_control`: session manager, clear a pending control request. +- `revoke_control`: session manager, revoke active delegated control. - `enable_multiplayer`: session creator, prefix submitted terminal prompts with the actor. - `disable_multiplayer`: session creator, stop prefixing submitted terminal prompts with the actor. -- `stop`: owner/maintainer, internal wire action behind user-facing Delete or End. Versioned adapters release the provider workspace before marking stopped, and asynchronous releases remain `stopping` until reconciliation confirms completion. Built-in Sandbox sessions clean up their durable lease and credential policy. For GitHub Actions, End disconnects and finalizes only the Crabfleet terminal session; it does not call GitHub's workflow-cancellation API, so the workflow run may continue. +- `stop`: session manager, internal wire action behind user-facing Delete or End. Versioned adapters release the provider workspace before marking stopped, and asynchronous releases remain `stopping` until reconciliation confirms completion. Built-in Sandbox sessions clean up their durable lease and credential policy. For GitHub Actions, End disconnects and finalizes only the Crabfleet terminal session; it does not call GitHub's workflow-cancellation API, so the workflow run may continue. Response: @@ -579,9 +608,13 @@ request returns the original crabbox; reusing the ID with a different request is rejected. A replay while the original reservation is still preparing returns a retryable service-unavailable response instead of claiming the crabbox is ready. After finalized-session cleanup, the retained replay tombstone rejects the -request instead of provisioning duplicate work. The fingerprint includes a -nonreversible digest of any supplied GitHub credential; the credential itself is -not stored in the replay ledger. +request instead of provisioning duplicate work. The fingerprint includes the +resolved stable owner subject and a nonreversible digest of any supplied GitHub +credential; the credential itself is not stored in the replay ledger. +Pre-upgrade display-owner fingerprints remain replayable only when the migrated +session owner subject matches the currently resolved stable owner. + +In private tenancy, `owner` must resolve to exactly one active Crabfleet user by login, email, or stable subject. Crabfleet persists both the stable subject and the user's actor-compatible login/email so Sandbox credential and lease checks agree with the human owner. That subject owns human visibility and management; the exact OpenClaw service separately retains automation lifecycle and terminal authority for its own session and only descendants first validated inside that service-owned lineage. Response: @@ -589,7 +622,7 @@ Response: { "session": { "id": "IS-105", - "owner": "@steipete", + "owner": "steipete", "runtime": "crabbox", "vncUrl": "https://..." }, diff --git a/docs/architecture.md b/docs/architecture.md index d3bf0769..ac46cffe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -83,9 +83,10 @@ D1 is canonical for product metadata: - `allow_entries`: direct user, email, and team allowlist roles. - `repos`: enabled repositories. - `users`, `sessions`, `ssh_keys`, `ssh_link_codes`: browser and SSH identity. -- `cards`, `run_attempts`, `events`: Board state and durable attempt evidence. +- `cards`, `run_attempts`, `events`: tenant-owned Board state and durable attempt evidence. - `repo_workflows`: evaluated `CRABBOX.md` source, parsed defaults, and errors. -- `interactive_sessions`, `interactive_session_events`: live/retained session state. +- `interactive_sessions`, `interactive_session_events`: live/retained session state with stable owner subjects. +- `interactive_session_grants`: expiring per-subject viewer/controller access created by the stable session owner. - `interactive_session_log_archives`: current archive snapshot metadata and object keys. - `id_sequences`: monotonic managed session IDs. - `interactive_session_credential_policies`, `standalone_sandbox_provisions`, and reconcile state: durable credential ownership and teardown. @@ -138,7 +139,13 @@ Interactive sessions are the live execution plane. Supported paths: - **Versioned runtime adapter:** Worker durably registers a tenant-namespaced workspace ID, creates and reconciles the provider workspace, proxies PTY access, mints transient desktop links, and confirms provider release before terminal state. - **GitHub Actions:** OpenClaw automation registers a logical work key; an Actions runner connects outbound to `SessionControlDO`, reports work state, and receives browser steering. -Sessions can carry parent/root lineage, purpose, summary, share state, delegated control, multiplayer mode, archive metadata, and runtime-specific capability state. +Sessions can carry a stable tenant owner, parent/root lineage, purpose, summary, named grants, public share state, delegated control, multiplayer mode, archive metadata, and runtime-specific capability state. + +`CRABFLEET_TENANCY_MODE` defaults to `private`. Private D1 reads scope cards to the stable owner and sessions to ownership, an unexpired named grant, or a current delegated-control lease before events or archive metadata are loaded. Bootstrap access normalizes every token-derived identity to one stable tenant subject. OpenClaw-created sessions bind their human-facing owner to an active stable subject; the exact internal service creator separately retains lifecycle and terminal authority only after the requested session is validated inside its service-owned lineage. Every direct session, terminal, diagnostics, checkpoint, desktop, cleanup, and metadata path repeats current authorization at its mutation boundary. Global roles do not bypass another tenant. Exact `shared` mode retains the legacy team-wide projection. + +Fleet aggregation filters Sandbox policy summaries to the already-visible session IDs before attaching policy details or computing totals. Tenant responses therefore cannot infer another tenant's policy activity from global Durable Object state. + +Terminal subscriptions authorize the loaded session before returning lifecycle or capability errors, so missing and tenant-hidden IDs have the same observable result. Generic shared-mode visibility does not imply live terminal access: terminal output requires control authority, a current named grant, or a separately validated share/embed token. Sandbox GitHub-token attachment and stale-lease refresh use the deployment tenancy mode, compare stable owner subjects when present, and leave unresolved private legacy owners fail-closed. Client destructive controls use the Worker's per-session `canManage` projection rather than global role inference. An optional `CRABFLEET_RUNTIME_PROFILES_JSON` allowlist exposes generic Crabbox profile labels and capability previews without teaching the Worker provider-specific semantics. The Worker validates the opaque profile ID. A fixed adapter maps it internally, or `CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE` selects a distinct outbound adapter by lowercase DNS-label profile; each adapter enforces its real provider capabilities. @@ -201,7 +208,7 @@ If R2 is enabled after D1-only finalization, reconciliation requeues missing obj ## Authentication - **GitHub OAuth:** org membership plus direct/team allowlist; encrypted per-session OAuth token supports scoped runtime GitHub access. -- **Trusted reverse proxy:** exact backend origin plus shared-secret assertion; identity still passes the existing allowlist and role model. +- **Trusted reverse proxy:** exact backend origin plus shared-secret assertion; identity passes the direct allowlist unless a fail-closed `viewer` or `maintainer` automatic role is configured. - **Bootstrap token:** owner break-glass access. - **SSH gateway:** linked public-key fingerprint plus gateway bearer. - **Agent session:** session ID plus session-scoped agent token. diff --git a/docs/github-actions-sessions.md b/docs/github-actions-sessions.md index 3e40ee45..b70f00ce 100644 --- a/docs/github-actions-sessions.md +++ b/docs/github-actions-sessions.md @@ -100,6 +100,7 @@ Example: "workKind": "issue_to_pr", "repo": "openclaw/openclaw", "branch": "clawsweeper/issue-openclaw-openclaw-123", + "owner": "operator@example.test", "sourceUrl": "https://github.com/openclaw/openclaw/issues/123", "runUrl": "https://github.com/openclaw/clawsweeper/actions/runs/123456", "purpose": "Convert issue to pull request", @@ -113,9 +114,12 @@ Required fields: - `workKind` - `repo` +Private-tenancy deployments also require `owner`, resolved to exactly one active Crabfleet user by login, email, or stable subject. Existing owned work keys can resume without repeating it. + Optional fields: - `branch`, default `main` +- `owner` in shared tenancy - `sourceUrl` - `runUrl` - `purpose` diff --git a/docs/index.md b/docs/index.md index da86f909..d5a2b03c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -37,6 +37,7 @@ The web app at [crabfleet.openclaw.ai/app](https://crabfleet.openclaw.ai/app/) e - **Repo-gated cards.** Prompt cards and GitHub issue/PR previews stay scoped to enabled OpenClaw repos. - **Runtime policy.** Crabfleet records runtime selection, capabilities, heartbeat, stall state, and operator intent. - **Admin guardrails.** User/team allowlists, repo allowlists, roles, caps, and `CRABBOX.md` workflow evaluation live in the dashboard. +- **Private tenant boundaries.** Cards and sessions are owner-scoped by default, with expiring named viewer/controller grants and separate public read-only links. - **Generated docs.** The spec, API pages, and architecture notes are built into a searchable documentation shell. ## What Works Today diff --git a/docs/runs.md b/docs/runs.md index befea2a9..4d63f9eb 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -94,7 +94,7 @@ The Take over action records `controlIntent = "takeover"` and operator only for ## Interactive CLI Sessions -Maintainers can create a standalone Codex CLI session without making a board card. The Worker stores the requested repo, branch, runtime, command, owner, attach/VNC URLs, status, and event log in D1. `CRABFLEET_INTERACTIVE_RUNTIMES` limits manual creation to `container`, `crabbox`, or both. `CRABFLEET_DEFAULT_RUNTIME` selects the deployment default (`container` when enabled, otherwise the only enabled runtime); the CLI and SSH gateway leave runtime unspecified unless the operator passes `--runtime`. Internal automation can also register service-owned `github_actions` sessions; this runtime is visible in Fleet but is not offered in the manual session form. +Maintainers can create a standalone Codex CLI session without making a board card. The Worker stores the requested repo, branch, runtime, command, owner, attach/VNC URLs, status, and event log in D1. `CRABFLEET_INTERACTIVE_RUNTIMES` limits manual creation to `container`, `crabbox`, or both. `CRABFLEET_DEFAULT_RUNTIME` selects the deployment default (`container` when enabled, otherwise the only enabled runtime); the CLI and SSH gateway leave runtime unspecified unless the operator passes `--runtime`. Internal automation can also register `github_actions` sessions; private-tenancy registrations bind browser visibility to an explicit active user while the exact creating service retains lifecycle authority. This runtime is visible in Fleet but is not offered in the manual session form. Deployments can expose an allowlisted set of generic Crabbox profiles. The create drawer, Go CLI, and SSH gateway pass the selected opaque profile ID to Crabfleet; the Worker validates it and includes it in the immutable adapter create request. Profile capability flags are previews and requested capabilities, not a substitute for provider enforcement. A profile may also configure a provider-neutral Codex SSH handoff: after a versioned-adapter workspace is ready, its managers receive a validated concrete alias and optional copyable local setup command derived from non-secret session/provider identifiers. The handoff remains fenced to the workspace's immutable adapter control-plane registration. Crabfleet does not execute the command or emulate an SSH login shell; the deployment helper must install the alias and the remote host must expose an authenticated `codex` command on its login-shell `PATH`. @@ -132,14 +132,18 @@ verification contract. Session sharing: +- Sessions are tenant-private by default; global maintainer/owner roles do not reveal another tenant's sessions. +- `Access` creates an expiring named `viewer` or `controller` grant for one authenticated user. +- Named viewer grants allow logs, transcript, and terminal output; controller grants add terminal input, diagnostics, clipboard, and desktop access. +- Owners can revoke a named grant independently; revocation atomically clears that subject's pending or active delegated-control lease. - `Share` creates a public read-only URL at `/app/sessions/:id?token=...`. - The share token is stored as a hash; generating a new link rotates the old one. - Public viewers can scroll the persisted session event buffer without signing in. -- Writable PTY access still requires a signed-in allowlisted viewer and owner/maintainer approval. +- Writable PTY access still requires owner access, a current controller grant, or an owner-approved delegated-control lease. Sandbox checkpoints: -- The session owner, maintainers, and owners can list, create, and restore checkpoints for supported Sandbox sessions. +- Session managers can list, create, and restore checkpoints for supported Sandbox sessions; in private mode only the stable session owner is a manager. - Delegated terminal control alone does not grant checkpoint access. - Browser APIs use `/api/interactive-sessions/:id/checkpoints`. - CLI and SSH use `checkpoints`, `checkpoint`, and `restore`. diff --git a/docs/spec.md b/docs/spec.md index 8031ce07..3c8cc237 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -107,20 +107,22 @@ Runtime selection order: Roles: - `owner`: deployment administration, plus all maintainer/viewer actions; -- `maintainer`: session creation/control and card mutation; -- `viewer`: visible state, logs, public share links, and delegated control requests. A terminal subscription requires session ownership, maintainer/owner role, a valid share token, or a current control grant. Session ownership separately grants management operations such as transcripts and checkpoints. +- `maintainer`: session creation and card mutation; in shared mode, management of team-visible sessions; +- `viewer`: authorized state, logs, public share links, named grants, and delegated control requests. + +`CRABFLEET_TENANCY_MODE` defaults to `private`. Private mode scopes cards to their stable owner subject and sessions to their owner, an unexpired named viewer/controller grant, or the current delegated controller. Global roles do not bypass another tenant. Only the stable owner manages session lifecycle, metadata, checkpoints, links, and named grants; an exact internal service creator retains lifecycle and terminal authority for its own session and explicitly validated descendants in a service-owned lineage. Exact `shared` mode preserves legacy team-wide visibility and role management. Authorization layers: 1. authenticate by GitHub OAuth, bootstrap token, trusted reverse proxy, linked SSH key, or scoped service token; 2. verify active OpenClaw org membership for GitHub users; -3. resolve direct user/email or team allowlist role; +3. resolve direct user/email or team allowlist role, or a configured trusted-proxy automatic `viewer`/`maintainer` role; 4. enforce enabled repository access; 5. apply action-specific ownership/control checks. GitHub sessions last 15 minutes. Bootstrap sessions last 1 hour. Browser sessions are not silently refreshed; expiry requires authentication again. -Trusted-proxy identity is accepted only on the exact configured backend origin with the shared secret and configured identity header. The asserted identity still needs a direct allowlist entry. Mutation and WebSocket requests also prove the public origin. Proxy assertions and upstream credentials are stripped before downstream routing. +Trusted-proxy identity is accepted only on the exact configured backend origin with the shared secret and configured identity header. The asserted identity needs a direct allowlist entry unless `CRABFLEET_TRUSTED_PROXY_AUTO_ROLE` is exactly `viewer` or `maintainer`; malformed or elevated automatic roles fail closed. Mutation and WebSocket requests also prove the public origin. Proxy assertions and upstream credentials are stripped before downstream routing. ## Runtime Backends @@ -242,7 +244,9 @@ The signed provider URL is not stored in D1 or returned through Fleet. Session sharing: -- owner/maintainer enables or rotates a public read-only URL; +- the stable owner creates expiring named `viewer` or `controller` grants for active users; +- revoking a named grant atomically clears that subject's pending or active delegated-control lease; +- the session manager enables or rotates a public read-only URL; - disabling sharing invalidates the token and clears delegated control; - shared state includes D1 event scrollback but no write permission. @@ -261,7 +265,7 @@ Primary tables cover: - users, sessions, allowlist, repositories, workflow configs; - cards, run attempts, card events, changes; -- interactive sessions and events; +- interactive sessions, events, stable owner subjects, and expiring named grants; - share/control state and supervision metadata; - provider lifecycles, standalone Sandbox ownership, credential-policy ownership; - terminal finalization and archive pointers; @@ -392,6 +396,7 @@ Every push to `main` runs checks, tests, builds, migrations, deploys, and endpoi - Provider URLs and messages are bounded, validated, and redacted before persistence. - Workspace IDs and immutable create requests are fenced against replay and adoption. - Browser mutations and WebSocket upgrades enforce origin checks. +- Tenant-private mode is the fail-closed default; global roles do not reveal foreign cards or sessions. - Shared links are read-only by default. - Terminal input requires current control on every path. - Sandbox credential cleanup is generation-fenced and retried until ownership is safely removed. diff --git a/internal/terminalws/client.go b/internal/terminalws/client.go index 8c7b443a..bc84fdc4 100644 --- a/internal/terminalws/client.go +++ b/internal/terminalws/client.go @@ -11,6 +11,7 @@ import ( "net/url" "strings" "sync" + "sync/atomic" "github.com/coder/websocket" ) @@ -74,6 +75,8 @@ type Size struct { type Client struct { conn *websocket.Conn sessionID string + canInput atomic.Bool + lastSize atomic.Uint64 writeMu sync.Mutex } @@ -136,6 +139,7 @@ func Dial(ctx context.Context, endpoint string, sessionID string, options Option } conn.SetReadLimit(maxFrameBytes) client := &Client{conn: conn, sessionID: sessionID} + client.rememberSize(Size{Cols: options.Cols, Rows: options.Rows}) closeWithError := func(err error) (*Client, error) { _ = conn.Close(websocket.StatusInternalError, "terminal setup failed") return nil, err @@ -169,9 +173,7 @@ func Dial(ctx context.Context, endpoint string, sessionID string, options Option return closeWithError(fmt.Errorf("decode terminal event: %w", err)) } if event.Type == "subscribed" { - if !event.CanInput { - return closeWithError(errors.New("terminal control has not been granted")) - } + client.canInput.Store(event.CanInput) return client, nil } if event.Type == "closed" { @@ -189,6 +191,9 @@ func (c *Client) SendInput(ctx context.Context, payload []byte) error { if len(payload) == 0 { return nil } + if !c.canInput.Load() { + return errors.New("terminal control has not been granted") + } return c.write(ctx, frame{ messageType: messageInput, sessionID: c.sessionID, @@ -200,6 +205,10 @@ func (c *Client) Resize(ctx context.Context, size Size) error { if size.Cols == 0 || size.Rows == 0 { return nil } + c.rememberSize(size) + if !c.canInput.Load() { + return nil + } return c.write(ctx, frame{ messageType: messageResize, sessionID: c.sessionID, @@ -227,7 +236,7 @@ func (c *Client) Attach(ctx context.Context, terminal io.ReadWriter, resizes <-c buffer := make([]byte, 32*1024) for { count, err := terminal.Read(buffer) - if count > 0 { + if count > 0 && c.canInput.Load() { if writeErr := c.SendInput(ctx, buffer[:count]); writeErr != nil { errCh <- writeErr return @@ -288,9 +297,23 @@ func (c *Client) Attach(ctx context.Context, terminal io.ReadWriter, resizes <-c errCh <- err return } - case messageError, messageControlRevoked: + case messageError: errCh <- frameError(current, "terminal connection failed") return + case messageControlRevoked: + c.canInput.Store(false) + case messageControlGranted: + c.canInput.Store(true) + if size := c.rememberedSize(); size.Cols > 0 && size.Rows > 0 { + if err := c.write(ctx, frame{ + messageType: messageResize, + sessionID: c.sessionID, + payload: resizePayload(size), + }); err != nil { + errCh <- err + return + } + } case messageEvent: var event eventPayload if json.Unmarshal(current.payload, &event) == nil && event.Type == "closed" { @@ -309,6 +332,15 @@ func (c *Client) Attach(ctx context.Context, terminal io.ReadWriter, resizes <-c return normalizeCloseError(err) } +func (c *Client) rememberSize(size Size) { + c.lastSize.Store(uint64(size.Cols)<<32 | uint64(size.Rows)) +} + +func (c *Client) rememberedSize() Size { + value := c.lastSize.Load() + return Size{Cols: uint32(value >> 32), Rows: uint32(value)} +} + func (c *Client) write(ctx context.Context, current frame) error { c.writeMu.Lock() defer c.writeMu.Unlock() diff --git a/internal/terminalws/client_test.go b/internal/terminalws/client_test.go index db79db3a..f1e04cc6 100644 --- a/internal/terminalws/client_test.go +++ b/internal/terminalws/client_test.go @@ -328,6 +328,261 @@ func TestAttachClosesCloseableTerminalAfterRemoteClosure(t *testing.T) { } } +func TestClientSubscribesReadOnlyAndSuppressesInput(t *testing.T) { + acknowledged := make(chan uint32, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, nil) + if err != nil { + t.Error(err) + return + } + defer conn.Close(websocket.StatusNormalClosure, "") + + for range 2 { + if _, _, err := conn.Read(r.Context()); err != nil { + t.Error(err) + return + } + } + event, _ := json.Marshal(eventPayload{Type: "subscribed", CanInput: false}) + if err := conn.Write(r.Context(), websocket.MessageBinary, encodeFrame(frame{ + messageType: messageEvent, + sessionID: "IS-read-only", + payload: event, + })); err != nil { + t.Error(err) + return + } + if err := conn.Write(r.Context(), websocket.MessageBinary, encodeFrame(frame{ + messageType: messageOutput, + sessionID: "IS-read-only", + payload: []byte("read-only\n"), + })); err != nil { + t.Error(err) + return + } + _, payload, err := conn.Read(r.Context()) + if err != nil { + t.Error(err) + return + } + ack, err := decodeFrame(payload) + if err != nil { + t.Error(err) + return + } + if ack.messageType != messageAck { + t.Errorf("read-only client sent message type = %d", ack.messageType) + return + } + acknowledged <- binary.LittleEndian.Uint32(ack.payload) + closed, _ := json.Marshal(eventPayload{Type: "closed"}) + _ = conn.Write(r.Context(), websocket.MessageBinary, encodeFrame(frame{ + messageType: messageEvent, + sessionID: "IS-read-only", + payload: closed, + })) + })) + defer server.Close() + + endpoint, err := Endpoint(server.URL) + if err != nil { + t.Fatal(err) + } + client, err := Dial(context.Background(), endpoint, "IS-read-only", Options{}) + if err != nil { + t.Fatal(err) + } + defer client.Close() + if client.canInput.Load() { + t.Fatal("read-only subscription unexpectedly has input control") + } + inputReader, inputWriter := io.Pipe() + defer inputReader.Close() + defer inputWriter.Close() + terminal := &readWriter{reader: inputReader, closer: inputReader} + resizes := make(chan Size, 1) + resizes <- Size{Cols: 132, Rows: 43} + if err := client.Attach(context.Background(), terminal, resizes); err != nil { + t.Fatal(err) + } + if terminal.String() != "read-only\n" { + t.Fatalf("output = %q", terminal.String()) + } + if bytes := <-acknowledged; bytes != uint32(len("read-only\n")) { + t.Fatalf("acknowledged = %d", bytes) + } +} + +func TestClientReadOnlyAttachReturnsOnTerminalEOF(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, nil) + if err != nil { + t.Error(err) + return + } + defer conn.Close(websocket.StatusNormalClosure, "") + for range 2 { + if _, _, err := conn.Read(r.Context()); err != nil { + t.Error(err) + return + } + } + event, _ := json.Marshal(eventPayload{Type: "subscribed", CanInput: false}) + if err := conn.Write(r.Context(), websocket.MessageBinary, encodeFrame(frame{ + messageType: messageEvent, + sessionID: "IS-read-only-eof", + payload: event, + })); err != nil { + t.Error(err) + return + } + _, _, _ = conn.Read(r.Context()) + })) + defer server.Close() + + endpoint, err := Endpoint(server.URL) + if err != nil { + t.Fatal(err) + } + client, err := Dial(context.Background(), endpoint, "IS-read-only-eof", Options{}) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + done := make(chan error, 1) + go func() { + done <- client.Attach(context.Background(), &readWriter{reader: bytes.NewReader(nil)}, nil) + }() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("read-only attach did not return after terminal EOF") + } +} + +func TestClientContinuesReadOnlyAndResumesControl(t *testing.T) { + resumedSize := make(chan Size, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, nil) + if err != nil { + t.Error(err) + return + } + defer conn.Close(websocket.StatusNormalClosure, "") + for range 2 { + if _, _, err := conn.Read(r.Context()); err != nil { + t.Error(err) + return + } + } + subscribed, _ := json.Marshal(eventPayload{Type: "subscribed", CanInput: true}) + if err := conn.Write(r.Context(), websocket.MessageBinary, encodeFrame(frame{ + messageType: messageEvent, + sessionID: "IS-live-control", + payload: subscribed, + })); err != nil { + t.Error(err) + return + } + if err := conn.Write(r.Context(), websocket.MessageBinary, encodeFrame(frame{ + messageType: messageControlRevoked, + sessionID: "IS-live-control", + })); err != nil { + t.Error(err) + return + } + if err := conn.Write(r.Context(), websocket.MessageBinary, encodeFrame(frame{ + messageType: messageOutput, + sessionID: "IS-live-control", + payload: []byte("read-only\n"), + })); err != nil { + t.Error(err) + return + } + if _, payload, err := conn.Read(r.Context()); err != nil { + t.Error(err) + return + } else if ack, decodeErr := decodeFrame(payload); decodeErr != nil || ack.messageType != messageAck { + t.Errorf("read-only acknowledgement = %#v, %v", ack, decodeErr) + return + } + if err := conn.Write(r.Context(), websocket.MessageBinary, encodeFrame(frame{ + messageType: messageControlGranted, + sessionID: "IS-live-control", + })); err != nil { + t.Error(err) + return + } + _, payload, err := conn.Read(r.Context()) + if err != nil { + t.Error(err) + return + } + resize, err := decodeFrame(payload) + if err != nil || resize.messageType != messageResize { + t.Errorf("resumed resize = %#v, %v", resize, err) + return + } + resumedSize <- Size{ + Cols: binary.LittleEndian.Uint32(resize.payload[0:4]), + Rows: binary.LittleEndian.Uint32(resize.payload[4:8]), + } + if err := conn.Write(r.Context(), websocket.MessageBinary, encodeFrame(frame{ + messageType: messageOutput, + sessionID: "IS-live-control", + payload: []byte("live\n"), + })); err != nil { + t.Error(err) + return + } + if _, _, err := conn.Read(r.Context()); err != nil { + t.Error(err) + return + } + closed, _ := json.Marshal(eventPayload{Type: "closed"}) + _ = conn.Write(r.Context(), websocket.MessageBinary, encodeFrame(frame{ + messageType: messageEvent, + sessionID: "IS-live-control", + payload: closed, + })) + })) + defer server.Close() + + endpoint, err := Endpoint(server.URL) + if err != nil { + t.Fatal(err) + } + client, err := Dial(context.Background(), endpoint, "IS-live-control", Options{ + Cols: 132, + Rows: 43, + }) + if err != nil { + t.Fatal(err) + } + defer client.Close() + inputReader, inputWriter := io.Pipe() + defer inputReader.Close() + defer inputWriter.Close() + terminal := &readWriter{reader: inputReader, closer: inputReader} + if err := client.Attach(context.Background(), terminal, nil); err != nil { + t.Fatal(err) + } + if terminal.String() != "read-only\nlive\n" { + t.Fatalf("output = %q", terminal.String()) + } + if size := <-resumedSize; size != (Size{Cols: 132, Rows: 43}) { + t.Fatalf("resumed size = %#v", size) + } + if !client.canInput.Load() { + t.Fatal("client did not resume input control") + } +} + type readWriter struct { reader io.Reader closer io.Closer diff --git a/migrations/0028_tenant_isolation.sql b/migrations/0028_tenant_isolation.sql new file mode 100644 index 00000000..f46d8e29 --- /dev/null +++ b/migrations/0028_tenant_isolation.sql @@ -0,0 +1,391 @@ +-- Stable tenant ownership, named grants, and cutover compatibility triggers. +ALTER TABLE interactive_sessions ADD COLUMN owner_subject TEXT NOT NULL DEFAULT ''; +ALTER TABLE interactive_sessions ADD COLUMN control_requested_by_subject TEXT; +ALTER TABLE interactive_sessions ADD COLUMN controller_subject TEXT; + +UPDATE interactive_sessions +SET owner_subject = ( + SELECT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END + FROM users + WHERE allowed = 1 + AND ( + lower(owner) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(owner) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(owner) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(owner) = lower(email)) + ) + LIMIT 1 +) +WHERE owner_subject = '' + AND ( + SELECT count(DISTINCT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END) + FROM users + WHERE allowed = 1 + AND ( + lower(owner) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(owner) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(owner) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(owner) = lower(email)) + ) + ) = 1; + +UPDATE interactive_sessions +SET control_requested_by_subject = ( + SELECT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END + FROM users + WHERE allowed = 1 + AND ( + lower(control_requested_by) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(control_requested_by) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(control_requested_by) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(control_requested_by) = lower(email)) + ) + LIMIT 1 +) +WHERE control_requested_by_subject IS NULL + AND control_requested_by IS NOT NULL + AND ( + SELECT count(DISTINCT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END) + FROM users + WHERE allowed = 1 + AND ( + lower(control_requested_by) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(control_requested_by) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(control_requested_by) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(control_requested_by) = lower(email)) + ) + ) = 1; + +UPDATE interactive_sessions +SET controller_subject = ( + SELECT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END + FROM users + WHERE allowed = 1 + AND ( + lower(controller) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(controller) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(controller) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(controller) = lower(email)) + ) + LIMIT 1 +) +WHERE controller_subject IS NULL + AND controller IS NOT NULL + AND ( + SELECT count(DISTINCT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END) + FROM users + WHERE allowed = 1 + AND ( + lower(controller) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(controller) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(controller) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(controller) = lower(email)) + ) + ) = 1; + +CREATE INDEX IF NOT EXISTS idx_interactive_sessions_owner_subject + ON interactive_sessions(owner_subject, updated_at DESC); + +CREATE TABLE IF NOT EXISTS interactive_session_grants ( + session_id TEXT NOT NULL, + subject TEXT NOT NULL, + principal TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('viewer', 'controller')), + created_by_subject TEXT NOT NULL, + expires_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (session_id, subject), + FOREIGN KEY (session_id) REFERENCES interactive_sessions(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_interactive_session_grants_subject + ON interactive_session_grants(subject, expires_at, session_id); + +ALTER TABLE cards ADD COLUMN owner_subject TEXT NOT NULL DEFAULT ''; + +UPDATE cards +SET owner_subject = ( + SELECT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END + FROM users + WHERE allowed = 1 + AND ( + lower(owner) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(owner) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(owner) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(owner) = lower(email)) + ) + LIMIT 1 +) +WHERE owner_subject = '' + AND ( + SELECT count(DISTINCT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END) + FROM users + WHERE allowed = 1 + AND ( + lower(owner) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(owner) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(owner) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(owner) = lower(email)) + ) + ) = 1; + +CREATE INDEX IF NOT EXISTS idx_cards_owner_subject + ON cards(owner_subject, updated_at DESC); + +-- Bridge the migration/deploy window while an older Worker can still write rows +-- without stable subjects. A later, explicitly scheduled finalizer repeats the +-- backfill after old Worker requests have drained, then removes these triggers. +CREATE TRIGGER IF NOT EXISTS trg_tenant_session_owner_insert +AFTER INSERT ON interactive_sessions +WHEN NEW.owner_subject = '' +BEGIN + UPDATE interactive_sessions + SET owner_subject = ( + SELECT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END + FROM users + WHERE allowed = 1 + AND ( + lower(NEW.owner) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(NEW.owner) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(NEW.owner) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(NEW.owner) = lower(email)) + ) + LIMIT 1 + ) + WHERE id = NEW.id + AND ( + SELECT count(DISTINCT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END) + FROM users + WHERE allowed = 1 + AND ( + lower(NEW.owner) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(NEW.owner) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(NEW.owner) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(NEW.owner) = lower(email)) + ) + ) = 1; +END; + +CREATE TRIGGER IF NOT EXISTS trg_tenant_card_owner_insert +AFTER INSERT ON cards +WHEN NEW.owner_subject = '' +BEGIN + UPDATE cards + SET owner_subject = ( + SELECT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END + FROM users + WHERE allowed = 1 + AND ( + lower(NEW.owner) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(NEW.owner) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(NEW.owner) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(NEW.owner) = lower(email)) + ) + LIMIT 1 + ) + WHERE id = NEW.id + AND ( + SELECT count(DISTINCT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END) + FROM users + WHERE allowed = 1 + AND ( + lower(NEW.owner) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(NEW.owner) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(NEW.owner) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(NEW.owner) = lower(email)) + ) + ) = 1; +END; + +CREATE TRIGGER IF NOT EXISTS trg_tenant_control_request_insert +AFTER INSERT ON interactive_sessions +WHEN NEW.control_requested_by IS NOT NULL AND NEW.control_requested_by_subject IS NULL +BEGIN + UPDATE interactive_sessions + SET control_requested_by_subject = ( + SELECT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END + FROM users + WHERE allowed = 1 + AND ( + lower(NEW.control_requested_by) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(NEW.control_requested_by) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(NEW.control_requested_by) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(NEW.control_requested_by) = lower(email)) + ) + LIMIT 1 + ) + WHERE id = NEW.id + AND ( + SELECT count(DISTINCT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END) + FROM users + WHERE allowed = 1 + AND ( + lower(NEW.control_requested_by) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(NEW.control_requested_by) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(NEW.control_requested_by) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(NEW.control_requested_by) = lower(email)) + ) + ) = 1; +END; + +CREATE TRIGGER IF NOT EXISTS trg_tenant_controller_insert +AFTER INSERT ON interactive_sessions +WHEN NEW.controller IS NOT NULL AND NEW.controller_subject IS NULL +BEGIN + UPDATE interactive_sessions + SET controller_subject = ( + SELECT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END + FROM users + WHERE allowed = 1 + AND ( + lower(NEW.controller) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(NEW.controller) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(NEW.controller) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(NEW.controller) = lower(email)) + ) + LIMIT 1 + ) + WHERE id = NEW.id + AND ( + SELECT count(DISTINCT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END) + FROM users + WHERE allowed = 1 + AND ( + lower(NEW.controller) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(NEW.controller) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(NEW.controller) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(NEW.controller) = lower(email)) + ) + ) = 1; +END; + +CREATE TRIGGER IF NOT EXISTS trg_tenant_control_request_update +AFTER UPDATE OF control_requested_by ON interactive_sessions +WHEN NEW.control_requested_by IS NOT OLD.control_requested_by + AND NEW.control_requested_by_subject IS OLD.control_requested_by_subject +BEGIN + UPDATE interactive_sessions + SET control_requested_by_subject = CASE + WHEN NEW.control_requested_by IS NULL THEN NULL + WHEN ( + SELECT count(DISTINCT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END) + FROM users + WHERE allowed = 1 + AND ( + lower(NEW.control_requested_by) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(NEW.control_requested_by) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(NEW.control_requested_by) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(NEW.control_requested_by) = lower(email)) + ) + ) = 1 THEN ( + SELECT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END + FROM users + WHERE allowed = 1 + AND ( + lower(NEW.control_requested_by) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(NEW.control_requested_by) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(NEW.control_requested_by) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(NEW.control_requested_by) = lower(email)) + ) + LIMIT 1 + ) + ELSE NULL + END + WHERE id = NEW.id; +END; + +CREATE TRIGGER IF NOT EXISTS trg_tenant_controller_update +AFTER UPDATE OF controller ON interactive_sessions +WHEN NEW.controller IS NOT OLD.controller + AND NEW.controller_subject IS OLD.controller_subject +BEGIN + UPDATE interactive_sessions + SET controller_subject = CASE + WHEN NEW.controller IS NULL THEN NULL + WHEN ( + SELECT count(DISTINCT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END) + FROM users + WHERE allowed = 1 + AND ( + lower(NEW.controller) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(NEW.controller) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(NEW.controller) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(NEW.controller) = lower(email)) + ) + ) = 1 THEN ( + SELECT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END + FROM users + WHERE allowed = 1 + AND ( + lower(NEW.controller) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(NEW.controller) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(NEW.controller) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(NEW.controller) = lower(email)) + ) + LIMIT 1 + ) + ELSE NULL + END + WHERE id = NEW.id; +END; diff --git a/package.json b/package.json index 3e676454..ab350cf0 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,13 @@ "scripts": { "build": "node scripts/generate-assets.mjs && tsgo --noEmit", "build:static": "node scripts/generate-assets.mjs --static", - "deploy": "pnpm build && pnpm deploy:product:worker && wrangler d1 migrations apply DB --remote && wrangler deploy && pnpm deploy:domains", + "deploy": "pnpm build && pnpm deploy:product:worker && wrangler d1 migrations apply DB --remote && wrangler deploy && pnpm deploy:tenant-backfill && pnpm deploy:domains", "deploy:product": "pnpm deploy:product:worker && pnpm deploy:domains:product", "deploy:product:worker": "wrangler deploy --config wrangler.product.jsonc", "deploy:domains": "node scripts/ensure-cloudflare-domains.mjs", "deploy:domains:product": "node scripts/ensure-cloudflare-domains.mjs --product-only", + "deploy:tenant-backfill": "wrangler d1 execute DB --remote --file scripts/backfill-tenant-isolation.sql", + "deploy:tenant-finalize": "wrangler d1 execute DB --remote --file scripts/finalize-tenant-isolation.sql", "test": "node --test --experimental-strip-types tests/*.test.ts", "lint": "oxlint --ignore-pattern node_modules --ignore-pattern src/generated.ts .", "format": "oxfmt --check .", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 08414aaf..b26f84fd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,7 @@ preferOffline: true pmOnFail: ignore verifyDepsBeforeRun: false minimumReleaseAgeExclude: + - "@openclaw/libterminal@0.1.0" - "@typescript/native-preview-darwin-arm64@7.0.0-dev.20260609.1" - "@typescript/native-preview-darwin-x64@7.0.0-dev.20260609.1" - "@typescript/native-preview-linux-arm64@7.0.0-dev.20260609.1" @@ -18,3 +19,7 @@ minimumReleaseAgeExclude: - wrangler@4.99.0 onlyBuiltDependencies: - workerd +allowBuilds: + esbuild: false + sharp: false + workerd: true diff --git a/scripts/backfill-tenant-isolation.sql b/scripts/backfill-tenant-isolation.sql new file mode 100644 index 00000000..81044b7c --- /dev/null +++ b/scripts/backfill-tenant-isolation.sql @@ -0,0 +1,129 @@ +UPDATE interactive_sessions +SET owner_subject = ( + SELECT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END + FROM users + WHERE allowed = 1 + AND ( + lower(owner) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(owner) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(owner) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(owner) = lower(email)) + ) + LIMIT 1 +) +WHERE owner_subject = '' + AND ( + SELECT count(DISTINCT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END) + FROM users + WHERE allowed = 1 + AND ( + lower(owner) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(owner) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(owner) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(owner) = lower(email)) + ) + ) = 1; + +UPDATE interactive_sessions +SET control_requested_by_subject = ( + SELECT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END + FROM users + WHERE allowed = 1 + AND ( + lower(control_requested_by) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(control_requested_by) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(control_requested_by) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(control_requested_by) = lower(email)) + ) + LIMIT 1 +) +WHERE control_requested_by_subject IS NULL + AND control_requested_by IS NOT NULL + AND ( + SELECT count(DISTINCT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END) + FROM users + WHERE allowed = 1 + AND ( + lower(control_requested_by) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(control_requested_by) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(control_requested_by) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(control_requested_by) = lower(email)) + ) + ) = 1; + +UPDATE interactive_sessions +SET controller_subject = ( + SELECT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END + FROM users + WHERE allowed = 1 + AND ( + lower(controller) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(controller) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(controller) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(controller) = lower(email)) + ) + LIMIT 1 +) +WHERE controller_subject IS NULL + AND controller IS NOT NULL + AND ( + SELECT count(DISTINCT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END) + FROM users + WHERE allowed = 1 + AND ( + lower(controller) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(controller) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(controller) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(controller) = lower(email)) + ) + ) = 1; + +UPDATE cards +SET owner_subject = ( + SELECT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END + FROM users + WHERE allowed = 1 + AND ( + lower(owner) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(owner) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(owner) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(owner) = lower(email)) + ) + LIMIT 1 +) +WHERE owner_subject = '' + AND ( + SELECT count(DISTINCT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END) + FROM users + WHERE allowed = 1 + AND ( + lower(owner) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(owner) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(owner) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(owner) = lower(email)) + ) + ) = 1; diff --git a/scripts/finalize-tenant-isolation.sql b/scripts/finalize-tenant-isolation.sql new file mode 100644 index 00000000..02a0ca3e --- /dev/null +++ b/scripts/finalize-tenant-isolation.sql @@ -0,0 +1,136 @@ +UPDATE interactive_sessions +SET owner_subject = ( + SELECT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END + FROM users + WHERE allowed = 1 + AND ( + lower(owner) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(owner) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(owner) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(owner) = lower(email)) + ) + LIMIT 1 +) +WHERE owner_subject = '' + AND ( + SELECT count(DISTINCT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END) + FROM users + WHERE allowed = 1 + AND ( + lower(owner) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(owner) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(owner) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(owner) = lower(email)) + ) + ) = 1; + +UPDATE interactive_sessions +SET control_requested_by_subject = ( + SELECT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END + FROM users + WHERE allowed = 1 + AND ( + lower(control_requested_by) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(control_requested_by) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(control_requested_by) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(control_requested_by) = lower(email)) + ) + LIMIT 1 +) +WHERE control_requested_by_subject IS NULL + AND control_requested_by IS NOT NULL + AND ( + SELECT count(DISTINCT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END) + FROM users + WHERE allowed = 1 + AND ( + lower(control_requested_by) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(control_requested_by) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(control_requested_by) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(control_requested_by) = lower(email)) + ) + ) = 1; + +UPDATE interactive_sessions +SET controller_subject = ( + SELECT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END + FROM users + WHERE allowed = 1 + AND ( + lower(controller) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(controller) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(controller) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(controller) = lower(email)) + ) + LIMIT 1 +) +WHERE controller_subject IS NULL + AND controller IS NOT NULL + AND ( + SELECT count(DISTINCT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END) + FROM users + WHERE allowed = 1 + AND ( + lower(controller) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(controller) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(controller) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(controller) = lower(email)) + ) + ) = 1; + +UPDATE cards +SET owner_subject = ( + SELECT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END + FROM users + WHERE allowed = 1 + AND ( + lower(owner) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(owner) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(owner) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(owner) = lower(email)) + ) + LIMIT 1 +) +WHERE owner_subject = '' + AND ( + SELECT count(DISTINCT CASE + WHEN lower(subject) LIKE 'bootstrap:%' THEN 'bootstrap:owner' + ELSE subject + END) + FROM users + WHERE allowed = 1 + AND ( + lower(owner) = lower(subject) + OR (COALESCE(login, '') <> '' AND lower(owner) = lower(login)) + OR (COALESCE(login, '') <> '' AND lower(owner) = ('@' || lower(login))) + OR (COALESCE(email, '') <> '' AND lower(owner) = lower(email)) + ) + ) = 1; + +DROP TRIGGER IF EXISTS trg_tenant_session_owner_insert; +DROP TRIGGER IF EXISTS trg_tenant_card_owner_insert; +DROP TRIGGER IF EXISTS trg_tenant_control_request_insert; +DROP TRIGGER IF EXISTS trg_tenant_controller_insert; +DROP TRIGGER IF EXISTS trg_tenant_control_request_update; +DROP TRIGGER IF EXISTS trg_tenant_controller_update; diff --git a/scripts/generate-assets.mjs b/scripts/generate-assets.mjs index 9bbc487c..d5b70899 100644 --- a/scripts/generate-assets.mjs +++ b/scripts/generate-assets.mjs @@ -5,6 +5,7 @@ import { promisify } from "node:util"; import { GHOSTTY_ASSET_PATHS } from "@openclaw/libterminal/node"; import { css, js, preThemeScript, themeToggleHtml } from "./docs-site-assets.mjs"; +import { buildLucideIconScript } from "./lucide-icon-script.mjs"; import { escapeHtml, markdownToHtml } from "./worker-markdown.mjs"; const run = promisify(execFile); @@ -122,28 +123,6 @@ function viteAssetUrl(path) { return new URL(relative, appBuildRoot); } -function buildLucideIconScript(iconNodes) { - const names = [ - "book-open", - "check", - "copy", - "git-pull-request", - "layout-grid", - "link-2", - "moon", - "settings", - "square-terminal", - "sun", - "terminal", - "triangle-alert", - "x", - ]; - const selected = Object.fromEntries(names.map((name) => [name, iconNodes[name]])); - return `(() => { - globalThis.lucideIconNodes = ${JSON.stringify(selected)}; -})();`; -} - function stripFrontmatter(markdown) { return markdown.replace(/^---\n[\s\S]*?\n---\n+/, ""); } diff --git a/scripts/lucide-icon-script.mjs b/scripts/lucide-icon-script.mjs new file mode 100644 index 00000000..c9f32321 --- /dev/null +++ b/scripts/lucide-icon-script.mjs @@ -0,0 +1,23 @@ +export const lucideIconNames = [ + "book-open", + "check", + "copy", + "git-pull-request", + "layout-grid", + "link-2", + "moon", + "settings", + "square-terminal", + "sun", + "terminal", + "triangle-alert", + "user-plus", + "x", +]; + +export function buildLucideIconScript(iconNodes) { + const selected = Object.fromEntries(lucideIconNames.map((name) => [name, iconNodes[name]])); + return `(() => { + globalThis.lucideIconNodes = ${JSON.stringify(selected)}; +})();`; +} diff --git a/src/app.html b/src/app.html index f285c1ad..dbe3535f 100644 --- a/src/app.html +++ b/src/app.html @@ -2140,6 +2140,9 @@ backdrop-filter: blur(4px); animation: fadeIn 0.16s ease; } + .action-dialog.access { + width: min(620px, calc(100vw - 32px)); + } .action-dialog-surface { display: grid; grid-template-rows: minmax(0, 1fr) auto; @@ -2219,6 +2222,56 @@ font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; font-size: 12px; } + .action-dialog-access { + grid-column: 1 / -1; + display: grid; + gap: 18px; + margin-top: 18px; + } + .action-dialog-access-form { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + } + .action-dialog-access-form .full { + grid-column: 1 / -1; + } + .action-dialog-grants { + display: grid; + gap: 8px; + } + .action-dialog-grants > strong { + color: var(--muted); + font-size: 12px; + } + .action-dialog-grant { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 11px; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--panel-2); + } + .action-dialog-grant > span { + min-width: 0; + display: grid; + } + .action-dialog-grant strong, + .action-dialog-grant small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .action-dialog-grant small, + .action-dialog-grants-empty { + color: var(--muted); + font-size: 12px; + } + .action-dialog-grant button { + flex: none; + } .action-dialog-error { padding: 9px 11px; border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--line)); @@ -2284,11 +2337,11 @@ .rail { position: static; height: auto; - flex-direction: row; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; border-right: 0; border-bottom: 1px solid var(--line); padding: 12px 16px; - flex-wrap: wrap; } .rail .spacer { display: none; @@ -2297,8 +2350,23 @@ display: none; } .brand-lockup { + grid-column: 1; + grid-row: 1; min-width: 0; - margin-right: auto; + } + .nav-actions { + grid-column: 1 / -1; + grid-row: 2; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; + } + .nav-actions button { + width: 100%; + } + .rail .theme-toggle { + grid-column: 2; + grid-row: 1; } .shell { width: calc(100vw - 32px); @@ -2404,6 +2472,12 @@ display: grid; grid-template-columns: 1fr 1fr; } + .action-dialog-access-form { + grid-template-columns: 1fr; + } + .action-dialog-access-form .full { + grid-column: auto; + } } diff --git a/src/app/app-data.js b/src/app/app-data.js index 69f2444b..12c42b48 100644 --- a/src/app/app-data.js +++ b/src/app/app-data.js @@ -40,12 +40,16 @@ const githubAutoLoginReadyKey = "crabbox-github-auto-login-ready"; export function initialAppState(initialSessionLink) { if (!initialSessionLink.id) return emptyState; + const sharedLinkOnly = Boolean(initialSessionLink.token); return { ...emptyState, interactiveSessions: [ - linkedInteractiveSessionPlaceholder(initialSessionLink.id, { - sharedReadOnly: Boolean(initialSessionLink.token), - }), + { + ...linkedInteractiveSessionPlaceholder(initialSessionLink.id, { + sharedReadOnly: sharedLinkOnly, + }), + sharedLinkOnly, + }, ], }; } @@ -63,6 +67,84 @@ export function retainLinkedSession(nextState, linkedSession) { }; } +export async function reconcileLinkedSessionState( + nextState, + linkedSession, + { sharedToken, loadSession }, +) { + if ( + !linkedSession || + (nextState.interactiveSessions || []).some((session) => session.id === linkedSession.id) + ) { + return nextState; + } + try { + const result = await loadSession(linkedSession.id); + return result?.session ? upsertLinkedSession(nextState, result.session) : nextState; + } catch (error) { + if (error?.status === 403 || error?.status === 404) { + return sharedToken + ? upsertLinkedSession(nextState, { + ...linkedSession, + sharedRevalidationPending: true, + }) + : nextState; + } + console.warn("Linked session revalidation failed", error); + return sharedToken + ? upsertLinkedSession(nextState, { + ...linkedSession, + sharedRevalidationPending: true, + }) + : retainLinkedSession(nextState, linkedSession); + } +} + +export function upsertLinkedSession(nextState, linkedSession) { + return { + ...nextState, + interactiveSessions: [ + linkedSession, + ...(nextState.interactiveSessions || []).filter((session) => session.id !== linkedSession.id), + ], + }; +} + +export function upsertSharedLinkedSession(nextState, linkedSession) { + const current = (nextState.interactiveSessions || []).find( + (session) => session.id === linkedSession.id, + ); + if ( + current && + !current.routePlaceholder && + current.sharedLinkOnly !== true && + current.sharedRevalidationPending !== true + ) { + return nextState; + } + return upsertLinkedSession(nextState, { ...linkedSession, sharedLinkOnly: true }); +} + +export function removeSharedLinkedSession(nextState, sessionId) { + const current = (nextState.interactiveSessions || []).find((session) => session.id === sessionId); + if (current?.sharedLinkOnly !== true && current?.sharedRevalidationPending !== true) { + return nextState; + } + return { + ...nextState, + interactiveSessions: (nextState.interactiveSessions || []).filter( + (session) => session.id !== sessionId, + ), + }; +} + +export function retainTokenBackedSession(nextState, currentState) { + const linkedSession = (currentState.interactiveSessions || []).find( + (session) => session.sharedLinkOnly === true || session.sharedRevalidationPending === true, + ); + return retainLinkedSession(nextState, linkedSession); +} + export function sharedSessionState(session, auth, deployment = defaultDeployment) { return { user: { subject: "shared", login: "shared link", role: "viewer" }, @@ -133,6 +215,23 @@ export function createAppPolling({ }; } +export async function runAppPollingInterval({ + signedIn, + shared, + locked, + loadState, + loadSharedSession, + onSharedError, +}) { + if (signedIn) await loadState?.(); + if (!shared.id || !shared.token || locked) return; + try { + await loadSharedSession?.({ preserveSignedIn: signedIn, notify: false }); + } catch (error) { + await onSharedError?.(error, { preserveSignedIn: signedIn, notify: false, shared }); + } +} + export function createRequestFence() { let generation = 0; return { @@ -155,6 +254,7 @@ export function useAppData({ onSignedOut, onSharedSessionLoaded, onSharedSessionRejected, + onSharedSessionInvalidated, }) { const [state, setState] = useState(() => initialAppState(initialSessionLink)); const [signedIn, setSignedIn] = useState(false); @@ -169,6 +269,7 @@ export function useAppData({ onSignedOut, onSharedSessionLoaded, onSharedSessionRejected, + onSharedSessionInvalidated, }); const mountedRef = useRef(false); const autoLoginStarted = useRef(false); @@ -189,20 +290,46 @@ export function useAppData({ onSignedOut, onSharedSessionLoaded, onSharedSessionRejected, + onSharedSessionInvalidated, }; + function updateSignedIn(value) { + signedInRef.current = value; + setSignedIn(value); + } + + function clearAuthenticatedState(shared = sharedRef.current) { + callbacksRef.current.onSignedOut?.(); + updateSignedIn(false); + const nextState = { + ...initialAppState(shared), + auth: stateRef.current.auth, + deployment: stateRef.current.deployment, + }; + stateRef.current = nextState; + setState(nextState); + } + if (!pollingRef.current) { pollingRef.current = createAppPolling({ runInitial: () => loadStateRef.current?.(), runInterval: () => { - if (signedInRef.current) return loadStateRef.current?.(); + const signedIn = signedInRef.current; const shared = sharedRef.current; - if (!shared.id || !shared.token || document.body.classList.contains("locked")) return; - return loadSharedSessionRef.current?.().catch((error) => { - if (error.status === 403 || error.status === 404) { - return showSharedLinkError(error); - } - console.warn("Shared session refresh failed", error); + return runAppPollingInterval({ + signedIn, + shared, + locked: document.body.classList.contains("locked"), + loadState: () => loadStateRef.current?.(), + loadSharedSession: (options) => loadSharedSessionRef.current?.(options), + onSharedError: (error, context) => { + if (error.status === 403 || error.status === 404) { + return context.preserveSignedIn + ? showPreservedSharedLinkError(error, context.shared) + : showSharedLinkError(error); + } + console.warn("Shared session refresh failed", error); + }, }); }, runRetry: () => loadStateRef.current?.(), @@ -232,7 +359,11 @@ export function useAppData({ (session) => session.id === linkedSessionId, ) : null; - nextState = retainLinkedSession(nextState, linkedSession); + nextState = await reconcileLinkedSessionState(nextState, linkedSession, { + sharedToken: sharedRef.current.token, + loadSession: (id) => api(`/api/interactive-sessions/${encodeURIComponent(id)}`), + }); + if (!isCurrentStateRequest(generation)) return; const activeRun = activeRunRef.current; const activeCard = nextState.cards.find((card) => card.id === activeRun.id); if (activeRun.id && activeRun.open && activeCard?.changes?.files?.length) { @@ -248,16 +379,19 @@ export function useAppData({ pollingRef.current.clearRetry(); setAuthMethods(nextState.auth || authMethodsRef.current); setState(nextState); - setSignedIn(true); + updateSignedIn(true); setLoginMessage(""); finishGithubLoginCallback(true); } catch (error) { if (!isCurrentStateRequest(generation)) return; if (error.status === 401 || error.status === 403) { const shared = sharedRef.current; + const sharedFallback = shared.id && shared.token ? shared : { id: null, token: null }; + finishGithubLoginCallback(false); + clearAuthenticatedState(sharedFallback); if (shared.id && shared.token) { try { - await loadSharedSession(); + await loadSharedSession({ forceSharedOnlyGeneration: generation }); } catch (sharedError) { await showSharedLinkError(sharedError); } @@ -265,10 +399,7 @@ export function useAppData({ } const methods = await loadAuthMethods(); if (!isCurrentStateRequest(generation)) return; - finishGithubLoginCallback(false); if (error.status === 401 && (await maybeAutoGithubLogin(methods))) return; - callbacksRef.current.onSignedOut?.(); - setSignedIn(false); setLoginMessage(error.message === "unauthorized" ? "" : error.message); return; } @@ -295,7 +426,7 @@ export function useAppData({ return mountedRef.current && stateRequestFence.current.isCurrent(generation); } - async function performLoadSharedSession(shared) { + async function performLoadSharedSession(shared, notify, forceSharedOnlyGeneration) { let result; try { result = await api( @@ -303,36 +434,80 @@ export function useAppData({ { authOptional: true }, ); } catch (error) { - if (!sameSharedLink(sharedRef.current, shared)) return null; + if ( + !sameSharedLink(sharedRef.current, shared) || + (forceSharedOnlyGeneration !== null && !isCurrentStateRequest(forceSharedOnlyGeneration)) + ) + return null; throw error; } if (!sameSharedLink(sharedRef.current, shared)) return null; - const methods = await loadAuthMethods(); - if (!mountedRef.current || !sameSharedLink(sharedRef.current, shared)) return null; - setState( - sharedSessionState(result.session, methods, stateRef.current.deployment || defaultDeployment), - ); - setSignedIn(false); - callbacksRef.current.onSharedSessionLoaded?.(result.session); - return result.session; + if ( + !mountedRef.current || + !sameSharedLink(sharedRef.current, shared) || + (forceSharedOnlyGeneration !== null && !isCurrentStateRequest(forceSharedOnlyGeneration)) + ) + return null; + const linkedSession = { ...result.session, sharedLinkOnly: true }; + if (forceSharedOnlyGeneration !== null || !commitSharedSessionToSignedInState(linkedSession)) { + const methods = await loadAuthMethods(); + if ( + !mountedRef.current || + !sameSharedLink(sharedRef.current, shared) || + (forceSharedOnlyGeneration !== null && !isCurrentStateRequest(forceSharedOnlyGeneration)) + ) + return null; + if ( + forceSharedOnlyGeneration !== null || + !commitSharedSessionToSignedInState(linkedSession) + ) { + setState( + sharedSessionState( + linkedSession, + methods, + stateRef.current.deployment || defaultDeployment, + ), + ); + updateSignedIn(false); + } + } + if (notify) callbacksRef.current.onSharedSessionLoaded?.(linkedSession); + return linkedSession; } - function loadSharedSession() { + function loadSharedSession({ + preserveSignedIn = false, + notify = true, + forceSharedOnlyGeneration = null, + } = {}) { const shared = { ...sharedRef.current }; - const key = sharedLinkKey(shared); + const key = `${sharedLinkKey(shared)}\0${preserveSignedIn ? "signed-in" : "shared"}\0${notify ? "notify" : "silent"}\0${forceSharedOnlyGeneration ?? "current"}`; if (sharedRequestRef.current?.key === key) return sharedRequestRef.current.request; - const request = performLoadSharedSession(shared).finally(() => { - if (sharedRequestRef.current?.request === request) sharedRequestRef.current = null; - }); + const request = performLoadSharedSession(shared, notify, forceSharedOnlyGeneration).finally( + () => { + if (sharedRequestRef.current?.request === request) sharedRequestRef.current = null; + }, + ); sharedRequestRef.current = { key, request }; return request; } + function commitSharedSessionToSignedInState(linkedSession) { + if (!signedInRef.current) return false; + setState((current) => upsertSharedLinkedSession(current, linkedSession)); + return true; + } + async function showSharedLinkError(error) { + const shared = { ...sharedRef.current }; await loadAuthMethods(); - if (!mountedRef.current) return; + if (!mountedRef.current || !sameSharedLink(sharedRef.current, shared)) return; + if (signedInRef.current) { + showPreservedSharedLinkError(error, shared); + return; + } + clearAuthenticatedState({ id: null, token: null }); callbacksRef.current.onSharedSessionRejected?.(); - setSignedIn(false); setLoginMessage( error?.status === 404 ? "Shared session link is invalid or expired." @@ -340,6 +515,17 @@ export function useAppData({ ); } + function showPreservedSharedLinkError(error, shared) { + if (!mountedRef.current || !sameSharedLink(sharedRef.current, shared)) return; + setState((current) => removeSharedLinkedSession(current, shared.id)); + callbacksRef.current.onSharedSessionInvalidated?.(shared.id); + console.warn( + error?.status === 404 + ? "Shared session link expired or was revoked" + : "Shared session access was rejected", + ); + } + async function loadAuthMethods() { try { const result = await api("/api/auth", { authOptional: true }); @@ -400,6 +586,7 @@ export function useAppData({ } catch {} autoLoginStarted.current = false; await api("/api/logout", { method: "POST", authOptional: true }); + clearAuthenticatedState(); await refreshState(); } diff --git a/src/app/app-mutations.js b/src/app/app-mutations.js index ca609388..fa65f918 100644 --- a/src/app/app-mutations.js +++ b/src/app/app-mutations.js @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "preact/hooks"; import { api } from "./api.js"; +import { retainTokenBackedSession } from "./app-data.js"; import { canCleanInteractiveSession } from "./session-state.js"; import { disposeTerminal } from "./terminal.js"; import { @@ -80,6 +81,20 @@ export function interactiveShareDialog(shareUrl) { }; } +export function interactiveAccessDialog(grants, action, revoke) { + return { + kind: "access", + eyebrow: "Named access", + title: "Share this session", + description: + "Grant time-limited read-only or terminal-control access to an authenticated user.", + grants, + confirmLabel: "Grant access", + action, + revoke, + }; +} + export async function presentInteractiveShareLink(id, interactiveSessionAction, openActionDialog) { const result = await interactiveSessionAction(id, "share_link"); if (result.shareUrl) openActionDialog(interactiveShareDialog(result.shareUrl)); @@ -134,6 +149,10 @@ export function useAppMutations({ setState((current) => removeInteractiveSessionState(current, id)); } + function replaceFullState(nextState) { + setState((current) => retainTokenBackedSession(nextState, current)); + } + async function cardAction(id, action) { const result = await api(`/api/cards/${encodeURIComponent(id)}/actions`, { method: "POST", @@ -175,7 +194,7 @@ export function useAppMutations({ method: "POST", body: { ids }, }); - setState(result.state); + replaceFullState(result.state); const removed = new Set(result.removedIds || []); if (removed.has(focusedSessionIdRef.current)) { setFocusedSessionId(null); @@ -209,9 +228,8 @@ export function useAppMutations({ } function cleanupDeadInteractiveSessions() { - const user = stateRef.current.user; const ids = (stateRef.current.interactiveSessions || []) - .filter((session) => canCleanInteractiveSession(session, user)) + .filter((session) => canCleanInteractiveSession(session)) .map((session) => session.id); if (!ids.length) return null; openActionDialog({ @@ -230,6 +248,25 @@ export function useAppMutations({ return presentInteractiveShareLink(id, interactiveSessionAction, openActionDialog); } + async function manageInteractiveSessionAccess(id) { + const result = await api(`/api/interactive-sessions/${encodeURIComponent(id)}/grants`); + openActionDialog( + interactiveAccessDialog( + result.grants || [], + (input) => + api(`/api/interactive-sessions/${encodeURIComponent(id)}/grants`, { + method: "POST", + body: input, + }), + (subject) => + api( + `/api/interactive-sessions/${encodeURIComponent(id)}/grants/${encodeURIComponent(subject)}`, + { method: "DELETE" }, + ), + ), + ); + } + async function openRunDetails(id) { closeDrawer("sessions"); setActiveRunId(id); @@ -362,27 +399,33 @@ export function useAppMutations({ } async function addAllow(value, role) { - setState(await api("/api/admin/allow", { method: "POST", body: { value, role } })); + replaceFullState(await api("/api/admin/allow", { method: "POST", body: { value, role } })); } async function removeAllow(value) { - setState(await api(`/api/admin/allow/${encodeURIComponent(value)}`, { method: "DELETE" })); + replaceFullState( + await api(`/api/admin/allow/${encodeURIComponent(value)}`, { method: "DELETE" }), + ); } async function addRepo(repo) { - setState(await api("/api/admin/repos", { method: "POST", body: { repo } })); + replaceFullState(await api("/api/admin/repos", { method: "POST", body: { repo } })); } async function removeRepo(repo) { - setState(await api(`/api/admin/repos/${encodeURIComponent(repo)}`, { method: "DELETE" })); + replaceFullState( + await api(`/api/admin/repos/${encodeURIComponent(repo)}`, { method: "DELETE" }), + ); } async function refreshWorkflow(repo) { - setState(await api("/api/admin/workflows/evaluate", { method: "POST", body: { repo } })); + replaceFullState( + await api("/api/admin/workflows/evaluate", { method: "POST", body: { repo } }), + ); } async function updatePolicy(policy) { - setState(await api("/api/admin/policy", { method: "PUT", body: policy })); + replaceFullState(await api("/api/admin/policy", { method: "PUT", body: policy })); } return { @@ -398,6 +441,7 @@ export function useAppMutations({ cleanupInteractiveSession, cleanupDeadInteractiveSessions, shareInteractiveSession, + manageInteractiveSessionAccess, openRunDetails, createRefCard, createCard, diff --git a/src/app/app-shell-state.js b/src/app/app-shell-state.js index 80469f4c..0c39f9e0 100644 --- a/src/app/app-shell-state.js +++ b/src/app/app-shell-state.js @@ -1,13 +1,15 @@ -import { isFleetSessionAttachable } from "./utils.js"; +import { fleetCoversInteractiveSessions, isFleetSessionAttachable } from "./utils.js"; export function appShellMetrics(state) { + const interactiveSessions = state.interactiveSessions || []; + const fleetTotals = fleetCoversInteractiveSessions(state.fleet, interactiveSessions) + ? state.fleet.totals + : null; return { active: state.cards.filter((card) => card.lane === "Running").length, queue: state.cards.filter((card) => card.lane === "Todo").length, review: state.cards.filter((card) => card.lane === "Human Review").length, - cli: - state.fleet?.totals?.attachable ?? - (state.interactiveSessions || []).filter(isFleetSessionAttachable).length, + cli: fleetTotals?.attachable ?? interactiveSessions.filter(isFleetSessionAttachable).length, }; } diff --git a/src/app/dialogs.jsx b/src/app/dialogs.jsx index f6fe8ec4..08be67a4 100644 --- a/src/app/dialogs.jsx +++ b/src/app/dialogs.jsx @@ -14,12 +14,12 @@ export function useActionDialog() { dispatch({ type: "close" }); } - async function confirmActionDialog() { + async function confirmActionDialog(input) { const current = state.dialog; if (!current?.action || current.pending) return; dispatch({ type: "start", id: current.id }); try { - await current.action(); + await current.action(input); dispatch({ type: "resolve", id: current.id }); } catch (error) { dispatch({ @@ -90,15 +90,27 @@ export function ActionDialog({ dialog, onCancel, onConfirm }) { const elementRef = useRef(null); const cancelRef = useRef(null); const valueRef = useRef(null); + const principalRef = useRef(null); const [copied, setCopied] = useState(false); + const [accessGrants, setAccessGrants] = useState([]); + const [revoking, setRevoking] = useState(""); + const [revokeError, setRevokeError] = useState(""); useLayoutEffect(() => { if (!dialog) return; const element = elementRef.current; const previousFocus = document.activeElement; setCopied(false); + setAccessGrants(dialog.grants || []); + setRevoking(""); + setRevokeError(""); if (element && !element.open) element.showModal(); - const focusTarget = dialog.kind === "share" ? valueRef.current : cancelRef.current; + const focusTarget = + dialog.kind === "share" + ? valueRef.current + : dialog.kind === "access" + ? principalRef.current + : cancelRef.current; focusTarget?.focus(); if (dialog.kind === "share") focusTarget?.select(); return () => { @@ -110,6 +122,8 @@ export function ActionDialog({ dialog, onCancel, onConfirm }) { if (!dialog) return null; const titleId = `action-dialog-title-${dialog.id}`; const descriptionId = `action-dialog-description-${dialog.id}`; + const accessFormId = `action-dialog-access-${dialog.id}`; + const errorMessage = dialog.error || revokeError; async function copyValue() { const value = dialog.value || ""; @@ -125,10 +139,26 @@ export function ActionDialog({ dialog, onCancel, onConfirm }) { setCopied(success); } + async function revokeGrant(subject) { + if (!dialog.revoke || revoking || dialog.pending) return; + setRevoking(subject); + setRevokeError(""); + try { + await dialog.revoke(subject); + setAccessGrants((grants) => grants.filter((grant) => grant.subject !== subject)); + } catch (error) { + setRevokeError(error?.message || "Access could not be revoked."); + } finally { + setRevoking(""); + } + } + return ( { @@ -143,7 +173,15 @@ export function ActionDialog({ dialog, onCancel, onConfirm }) {
{dialog.eyebrow} @@ -157,9 +195,80 @@ export function ActionDialog({ dialog, onCancel, onConfirm }) { ) : null} - {dialog.error ? ( + {dialog.kind === "access" ? ( +
+
{ + event.preventDefault(); + const data = new FormData(event.currentTarget); + void onConfirm({ + principal: data.get("principal"), + role: data.get("role"), + expiresInSeconds: Number(data.get("expiresInSeconds")), + }); + }} + > + + + +
+
+ Current access + {accessGrants.length ? ( + accessGrants.map((grant) => ( +
+ + {grant.principal} + + {grant.role === "controller" ? "Terminal control" : "Read only"} + {grant.expiresAt + ? ` · expires ${new Date(grant.expiresAt).toLocaleString()}` + : ""} + + + +
+ )) + ) : ( + No named access grants. + )} +
+
+ ) : null} + {errorMessage ? ( ) : null}
@@ -176,6 +285,15 @@ export function ActionDialog({ dialog, onCancel, onConfirm }) { + ) : dialog.kind === "access" ? ( + <> + + + ) : ( <> ) : null} - + {!session.sharedLinkOnly && !session.sharedReadOnly ? ( + + ) : null} {canManage ? : null} + {canManage ? ( + + ) : null} {canChangeMultiplayer ? ( : null} + {canManage ? ( + + ) : null} {canChangeMultiplayer ? (