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..a433f60e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ - 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. +- Keep each agent credential scoped to its authenticated session and direct children instead of inheriting access to every session owned by the same human. - 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..5de623e8 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,11 +444,11 @@ 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. -Built-in Sandbox sessions receive `CRABFLEET_SESSION_ID`, `CRABFLEET_PARENT_SESSION_ID`, `CRABFLEET_ROOT_SESSION_ID`, `CRABFLEET_AGENT_TOKEN`, and `CRABFLEET_API_URL`. The managed provision hook rotates a fresh agent token in the same durable claim that owns provisioning, then injects that exact token into the Sandbox. The agent token can call the `/api/agent/*` endpoints below for same-owner session discovery, child creation, transcripts, and summary updates. +Built-in Sandbox sessions receive `CRABFLEET_SESSION_ID`, `CRABFLEET_PARENT_SESSION_ID`, `CRABFLEET_ROOT_SESSION_ID`, `CRABFLEET_AGENT_TOKEN`, and `CRABFLEET_API_URL`. The managed provision hook rotates a fresh agent token in the same durable claim that owns provisioning, then injects that exact token into the Sandbox. The agent token can call the `/api/agent/*` endpoints below for its authenticated session and direct children, including child creation, transcripts, and summary updates; it cannot discover unrelated sessions owned by the same human. ### POST /api/interactive-sessions/cleanup @@ -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: @@ -545,12 +574,12 @@ Crabfleet-issued session agents use `Authorization: Bearer 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 ? (