diff --git a/.github/workflows/deploy-worker.yml b/.github/workflows/deploy-worker.yml index e69db9c..4910486 100644 --- a/.github/workflows/deploy-worker.yml +++ b/.github/workflows/deploy-worker.yml @@ -77,8 +77,13 @@ jobs: env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CRABBOX_OPENCLAW_TOKEN: ${{ secrets.CRABBOX_OPENCLAW_TOKEN }} + CRABBOX_EMBED_TICKET_SECRET: ${{ secrets.CRABBOX_EMBED_TICKET_SECRET }} run: | set -euo pipefail + if [ -z "${CRABBOX_EMBED_TICKET_SECRET:-}" ]; then + echo "::error::Set CRABBOX_EMBED_TICKET_SECRET before deploying Crabfleet." + exit 1 + fi runtime_token="$( node -e ' const { createHmac } = require("node:crypto"); @@ -95,12 +100,15 @@ jobs: trap 'rm -f "$secrets_file"' EXIT SECRETS_FILE="$secrets_file" \ CRABBOX_RUNTIME_ADAPTER_TOKEN="$runtime_token" \ + CRABBOX_EMBED_TICKET_SECRET="$CRABBOX_EMBED_TICKET_SECRET" \ node -e ' const { writeFileSync } = require("node:fs"); writeFileSync( process.env.SECRETS_FILE, JSON.stringify({ CRABBOX_OPENCLAW_TOKEN: process.env.CRABBOX_OPENCLAW_TOKEN, + CRABBOX_EMBED_TICKET_SECRET: + process.env.CRABBOX_EMBED_TICKET_SECRET, CRABBOX_RUNTIME_ADAPTER_TOKEN: process.env.CRABBOX_RUNTIME_ADAPTER_TOKEN, }), @@ -127,4 +135,3 @@ jobs: set -euo pipefail test "$(curl --fail --silent --show-error --retry 12 --retry-all-errors --retry-delay 5 --max-time 20 https://crabfleet.openclaw.ai/healthz)" = "ok" test "$(curl --fail --silent --show-error --location --retry 12 --retry-all-errors --retry-delay 5 --max-time 20 --output /dev/null --write-out '%{url_effective}' https://crabfleet.ai/)" = "https://docs.crabfleet.ai/" - test "$(curl --fail --silent --show-error --location --retry 12 --retry-all-errors --retry-delay 5 --max-time 20 --output /dev/null --write-out '%{url_effective}' https://www.crabfleet.ai/)" = "https://docs.crabfleet.ai/" diff --git a/CHANGELOG.md b/CHANGELOG.md index d358198..a03a83b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,79 @@ ## Unreleased -- Serve Ghostty's WASM runtime and preserve embedded-session PTY readiness so Codex workspaces mount a live interactive terminal instead of a static session transcript. +- Add session-scoped OpenClaw terminal embed tickets, deployment-configured interactive runtime choices, explicit Ghostty WASM delivery, and shared embedded PTY readiness without restoring provider or protocol compatibility paths. +- Keep SSH terminal dimensions synchronized after attach and verify the TypeScript and Go multiplex clients against shared protocol vectors. +- Remove the configurable PTY bridge so managed terminal upstreams are limited to built-in Sandbox, versioned runtime-adapter attach, and GitHub Actions relay behind the multiplex terminal protocol. +- Remove generic create-only provisioning, the external Cloudflare runner, and the ClawFleet compatibility provider so managed workspaces use only built-in Sandbox or the versioned runtime adapter. +- Extract SSH gateway authentication, key linking, and session creation; remove the old Crabbox SSH environment and fingerprint-header aliases plus the `stop` CLI alias. +- Remove unsupported provider stop/recovery compatibility so live lifecycle mutations are limited to Sandbox, runtime-v1, and GitHub Actions. +- Remove legacy app/product hosts, product path rewrites, Workers.dev exposure, the `ls` CLI alias, and obsolete provider cleanup warnings. +- Remove legacy Sandbox credential-policy storage migration, repair claims, and read retries; reject old generations, rotate stale SQL references on registration, and purge unreadable stored credentials during fenced cleanup. +- Extract terminal multiplex composition, upstream routing, lifecycle transitions, sharing authorization, multiplayer input, and clipboard uploads into one service; harden pasted filenames against dot-prefixed path-like input. +- Extract runtime-adapter desktop connection minting and durable access revalidation into one service; remove the live legacy `vncUrl` redirect fallback. +- Extract Sandbox diagnostics and checkpoint authorization, orchestration, backup/restore, and registry access into a directly tested session-resource service. +- Extract card creation, run claims, heartbeat/stall transitions, actions, projections, and SQL persistence into a directly tested lifecycle service and repository. +- Extract CRABBOX.md parsing, fetch/cache policy, failure fallback, summaries, and SQL persistence into a directly tested workflow service and repository. +- Extract control-plane policy, allowlist, repository administration, validation, audit ordering, and SQL persistence into a directly tested admin service and repository. +- Extract GitHub issue/PR reference validation, repository selection, GraphQL batching, public fallback, mapping, and rate-limit handling into a directly tested service. +- Extract superseded runtime-adapter stop recovery and confirmed-release CAS persistence into a directly tested release service and repository. +- Move interactive-session normalization, reservation retries, provisioning, recovery, audit, and durable result assembly into the creation service. +- Move GitHub Actions registration, work-state, and runner-connection persistence into one repository with shared service composition. +- Move OpenClaw stop eligibility, agent credential reads, session ID allocation, and audit persistence out of the Worker entry point into their owning repositories. +- Extract OpenClaw GitHub branch validation, lookup, creation, and concurrent-create recovery into a directly tested service. +- Move interactive-session summary and purpose authorization, validation, fenced persistence, archive refresh, and reread into the metadata service. +- Extract GitHub Actions stop persistence, runner disconnect, archive refresh, and terminal finalization ordering into a directly tested service. +- Extract browser session credential policy and browser-visible link origins into directly tested Worker services. +- Centralize failed provisioning results and provider-error redaction across managed Sandbox, standalone Sandbox, and runtime-adapter lifecycle paths. +- Extract OpenClaw nudge and stop validation, audit ordering, terminal delivery, and best-effort delivery records into a directly tested mutation service. +- Extract OpenClaw crabbox normalization, replay handling, branch preparation, timeout policy, and creation audit into a directly tested service. +- Extract interactive-session lineage normalization, parent visibility, and canonical root derivation into a directly tested service. +- Extract interactive-session reservation supervision, preparation rollback, activation, request evidence, and provisioning order into a directly tested service. +- Extract visible interactive-session reads and atomic session/replay reservation inserts into a directly tested repository. +- Move interactive-session reservation retry and idempotent replay recovery into the creation service. +- Extract runtime-adapter configuration, control-plane, token, and create-preflight policy into a directly tested module. +- Extract provisioning-result compare-and-set persistence, pending-adapter fallback, event recording, and terminal finalization ordering. +- Centralize interactive-session reservation row defaults, adapter preparation state, replay identity, and sandbox lease ownership. +- Centralize interactive-session create request defaults, profile policy, capability selection, and descriptive fields. +- Centralize interactive-session reservation tokens, sandbox lease ownership, and runtime-adapter create identity. +- Extract superseded runtime-adapter and Sandbox provision recovery from session creation. +- Move bounded interactive-session logs, event pagination/counts, and archive reads into the session repository. +- Centralize shared-session visibility and redaction of provider, terminal, lease, reconciliation, and control authority. +- Assemble visible interactive-session rows, recent logs, and archive metadata in the session repository. +- Move atomic interactive-session metadata/event persistence and terminal snapshot invalidation into the session repository. +- Extract sharing, multiplayer, and delegated-control mutations into a directly tested session metadata service. +- Extract terminal attach policy into a directly tested service and persist attach state plus evidence atomically. +- Extract interactive-session stop authorization, runtime routing, idempotency, cleanup sequencing, conflicts, and audits into a directly tested service. +- Extract runtime-adapter stop claim, provider outcome, retry evidence, create-resolution, and confirmed-release orchestration into a directly tested service. +- Move GitHub Actions stop transitions plus stop-state lookups into the session repository. +- Extract interactive-session ownership, management, multiplayer, and delegated-control authorization into one directly tested policy module. +- Move interactive-session archive cadence, D1 snapshots, R2 objects, cleanup, transcripts, and summaries into one archive module. +- Extract interactive-session capability, control, provider redaction, desktop, and Codex SSH presentation policy behind direct tests. +- Move terminal completion evidence, archive freshness checks, and finalization-marker persistence into one lifecycle module. +- Unify session event batching, message bounds, finalization invalidation, and best-effort archive refresh behind one service. +- Extract finalized-session admission, fenced transactional deletion, authorization filtering, and archive-object cleanup behind one service. +- Extract runtime-adapter reconciliation claims, transition projection, atomic evidence persistence, race recovery, and terminal finalization behind one service. +- Extract scheduled and targeted reconciliation admission, cadence limits, and terminal archive backfill behind one scheduler. +- Replace duplicate bounded-concurrency loops with one directly tested worker utility. +- Extract agent-session authentication and remove the legacy `X-Crabbox-Session-ID` alias. +- Extract GitHub Actions session validation, idempotent registration, token rotation, resume reset, runner replacement, and evidence ordering. +- Extract GitHub Actions work-state projection, heartbeat persistence, event suppression, terminal mapping, runner disconnect, and reread. +- Extract GitHub Actions runner-connect validation, lifecycle projection, heartbeat persistence, and durable connection evidence. +- Extract interactive terminal route selection, signed attach preservation, adapter authorization, and headers. +- Extract terminal WebSocket relay queues, output acknowledgements, message normalization, authorization polling, and peer close handling. +- Extract runtime-adapter lifecycle and terminal transport, coordinator binding selection, redirect refusal, and bounded response parsing. +- Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. +- Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. +- Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. +- Move OpenClaw room reads, reservation fencing, activation, rollback, cleanup polling, admission state, completion counts, and lineage reads into a directly tested repository boundary. +- Centralize repository normalization and OpenClaw request identity, semantic hashing, and durable replay lookup behind direct behavior tests. +- Centralize interactive-session types, capability defaults, hidden adapter identity, and database row/event/archive mapping in a directly tested model module. +- Give Worker users, allowlist roles, cookie sessions, trusted-proxy identities, GitHub OAuth/API membership, session-owned credentials, and secret encryption dedicated auth modules with behavioral coverage. +- Isolate Worker ingress authentication, trusted-proxy credential stripping, and independent service-route policy behind direct behavioral tests. +- Centralize Worker HTTP responses, security headers, status errors, JSON parsing, bearer authentication, and cookie handling behind a directly tested module. +- Give shared Worker models and the complete Kysely/D1 schema, dialect, factory, and batch execution dedicated foundation modules. +- Extract Worker environment and deployment/profile policy into testable foundation modules, replacing source inspection with behavioral coverage for public and client configuration. +- Centralize secure URL, origin, and literal-loopback validation across OAuth, trusted proxy, runtime adapter, and Fleet routing. - De-duplicate the Go CLI and SSH gateway around shared control-plane models, authentication, lifecycle semantics, API calls, terminal operations, and terminal-safe session rendering. - Unify managed terminal clients on the multiplex `/api/terminal/ws` protocol, remove direct PTY routes, and share one framed Go transport across the CLI and SSH gateway. - Connect Crabfleet lifecycle and terminal traffic to Crabbox through a Cloudflare service binding and deploy an identical route-scoped credential atomically across both coordinators. @@ -10,7 +82,6 @@ - Add root-fenced OpenClaw service supervision for Crabbox room trees, including current state, bounded transcript evidence, targeted terminal nudges, audited stop requests, and canonical browser URLs. - Allow MultiCodex to use a dedicated service capability without rotating the existing OpenClaw automation token. - Safely reserve room capacity and prepare missing service-requested branches from an explicit base branch before Crabbox provisioning. -- Add a deployment runtime allowlist that can expose only configured interactive backends, hide the selector for a single choice, and reject disabled runtimes at the API boundary. - Add deployment-configured Codex SSH handoffs for ready provider workspaces, with safe alias templating, manager-only session metadata, and copyable local setup commands that keep provider behavior outside Crabfleet. - Route generic runtime profiles to distinct versioned adapters through a validated deployment URL template, reject profile-rewriting responses, and preserve immutable lifecycle control-plane fences. - Add a deployment-configured runtime profile selector with generic labels, targets, capability previews, server-side allowlisting, and CLI/SSH profile overrides. @@ -25,12 +96,12 @@ - Bound Crabbox terminal output with negotiated acknowledgements on the multiplex terminal hub. - Enable the OpenClaw deployment's versioned Crabbox runtime adapter with a stable tenant namespace. - Add comprehensive documentation for durable GitHub Actions sessions, including registration, runner and viewer relay, work-state heartbeats, Codex steering, resumption, completion, cancellation, authentication, archives, and troubleshooting. -- Name versioned provider-backed workspace lifecycle actions Delete across Fleet, the Go CLI, and SSH while retaining explicit Stop wording for legacy sessions and `stop` as a CLI compatibility alias; keep the provider stop wire action internal; and fail closed without adopting or deleting a pre-existing adapter workspace on an explicit ID conflict. +- Name versioned provider-backed workspace lifecycle actions Delete across Fleet, the Go CLI, and SSH; keep the provider stop wire action internal; and fail closed without adopting or deleting a pre-existing adapter workspace on an explicit ID conflict. - Keep GitHub Actions sessions out of legacy workspace-stop reconciliation and let operators end their Crabfleet terminal session without claiming to cancel the underlying workflow run. - Add durable steerable GitHub Actions sessions with service registration, scoped runner URLs, work-state heartbeats, Fleet metadata, and a SessionControlDO PTY relay. - Add a tenant-namespaced versioned runtime lifecycle adapter with replayable idempotent create, monotonic workspace identities, CAS reconciliation, durable terminal finalization, confirmed provider release before failure/stop, presence-aware capability/expiry tracking, authenticated transient VNC redirects, and deployment-neutral configuration. - Reconcile runtime lifecycles and every adapter's terminal archives on cron and direct access, preserve partial capability-object defaults while honoring authoritative lists and explicit terminal withdrawal, make PTY availability server-authoritative, preserve opaque signed terminal and desktop URLs byte-for-byte, retain adapter failure evidence through confirmed release and exact session-version archive finalization, preflight adapter credentials before session allocation, bind every external lifecycle to its immutable registered control plane, generation-fence managed and standalone Sandbox credential ownership across crashes and late requests, repair incomplete equal-count archives, run teardown only after an exact cleanup CAS, use unique concurrent archive attempts, and transactionally remove D1 archive pointers before best-effort R2 object cleanup. -- Harden adapter and terminal boundaries by redacting connection credentials from durable messages, requiring byte-exact grammar-valid workspace identity echoes, rejecting malformed non-null expiries and create-only `stopping` results, keeping recurring WebSocket authorization provider-free, and paging credential cleanup with durable fair-progress cursors while retaining Sandbox failure evidence. +- Harden adapter and terminal boundaries by redacting connection credentials from durable messages, requiring byte-exact grammar-valid workspace identity echoes, rejecting malformed non-null expiries, keeping recurring WebSocket authorization provider-free, and paging credential cleanup with durable fair-progress cursors while retaining Sandbox failure evidence. - Fence ambiguous create replay during stop to the exact registered lifecycle, require immutable-request ownership claims before the stateless hook can provision a managed session ID, expose standalone Sandbox terminals through their own bearer-authenticated WebSocket route, atomically pair terminal events with archive-finalization markers, and prevent older equal-count session snapshots from replacing newer archive pointers. - Require an exact durable lease or provision/refresh claim for every Sandbox credential-policy transition, atomically activate standalone owners with their matching policy generation, redact structured and header-form provider credentials, fence slow reconciliation by the original session revision and completion time, and reject adapter base URLs containing raw query or fragment delimiters. - Atomically fence credential-policy cleanup against its durable owner and revalidate ownership before unregistering, keep versioned-adapter terminal credentials behind Worker-owned PTY routes, and rotate a fresh agent token into every managed Sandbox provision claim. @@ -39,9 +110,9 @@ - Reject stale same-generation credential-policy registrations, preflight and atomically stage failed managed Sandbox claims, require the provision bearer for standalone stop after backend removal, and backfill D1-only terminal archives when R2 is enabled later. - Proactively generation-wrap migrated legacy Sandbox credential policies under a live durable lease before cleanup, preserve live pre-token sessions, and use crash-safe cron retries that retain unattended session credentials. - Bound every runtime-adapter response stream, revalidate desktop authorization after minting, make legacy local stops atomic with scheduled crash recovery, and redact credentials before opaque provider identifiers. -- Recover active credential policies after a post-registration crash, redact provider identities from structured adapter errors, and propagate terminal dimensions through configured bridge and runner PTY routes without rewriting opaque adapter URLs. +- Recover active credential policies after a post-registration crash and redact provider identities from structured adapter errors. - Support an optional authoritative `GITHUB_REDIRECT_URI` deployment binding with strict HTTPS callback validation, canonical-origin login handoff, and callback host/path enforcement while retaining safe request-origin defaults. -- Replace native browser confirms and prompts with accessible Crabfleet dialogs for session cleanup, shutdown, and share-link fallback. +- Replace native browser confirms and prompts with accessible Crabfleet dialogs for session cleanup, shutdown, and share links; keep dialogs above drawer navigation on Escape. - Sharpen the app visual system with flatter controls, tighter surfaces, and restrained overlay elevation. - Add Crabfleet session supervision metadata, owner/session tree listing, transcript retrieval, PTY messaging, and summary updates for Codex-spawned Codex sessions. - Redesign Fleet as an operational command view with real readiness data, compact connection paths, clearer operator groups, and denser session cards. @@ -120,8 +191,7 @@ - Keep Escape routed to focused Codex terminals instead of closing the session drawer. - Enable the experimental Codex goals feature in provisioned interactive sessions. - Fix interactive Codex session provisioning to show the terminal immediately and stream live PTY bytes into Ghostty. -- Add a Cloudflare container runner backend for standalone interactive session provisioning. -- Add a built-in interactive provision endpoint with generic runtime and ClawFleet adapter backends. +- Add a built-in interactive provision endpoint with durable standalone Sandbox ownership. - Add standalone interactive Codex CLI sessions with Ghostty grid attach and an external runtime provision hook. - Document the real deployed control-plane status, runtime adapter boundary, workflow config, and test stack. - Close open side drawers with Escape. diff --git a/README.md b/README.md index 019645b..c5da95f 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Crabfleet gives OpenClaw maintainers a fleet dashboard where every Codex crabbox - **Cloudflare Sandbox containers** for standalone interactive Codex CLI workspaces with live PTY attach. - **Runtime descriptors** for card scheduling evidence and capability display. - **Versioned lifecycle adapter** for idempotent external workspace creation, bounded status reconciliation, provider-backed deletion, terminal attachment, and authenticated transient desktop connections. -- **Provision endpoint** at `/api/provision/interactive` that can use the built-in Sandbox backend or retain a legacy create-only adapter or ClawFleet integration, with durable ownership and a bearer-authenticated standalone PTY route. +- **Provision endpoint** at `/api/provision/interactive` for durable built-in Sandbox ownership and a bearer-authenticated standalone PTY route. - **SessionControlDO relay** for one outbound GitHub Actions runner and multiple authenticated Ghostty viewers per action session. - **R2 session archives** for periodically refreshed interactive-session event NDJSON, transcripts, and summaries, finalized at terminal completion. - **GitHub API** for OAuth, org/team membership, and issue/PR previews across enabled repos. @@ -154,7 +154,7 @@ merge: ### Prerequisites - Cloudflare account -- `crabfleet.openclaw.ai` route in Cloudflare; legacy OpenClaw app hosts redirect here +- `crabfleet.openclaw.ai` route in Cloudflare - GitHub OAuth app (optional but recommended) - Bootstrap token secret @@ -164,15 +164,14 @@ Pushes to `main` run `.github/workflows/deploy-worker.yml`, which checks, tests, deploys the generic product router, applies remote D1 migrations, and deploys the app Worker. Configure the repository secret `CLOUDFLARE_API_TOKEN` with permissions for Workers deploys and D1 migrations. -`crabfleet.openclaw.ai` is a Worker Custom Domain declared in the app Wrangler -config. The `crabfleet.ai` product Custom Domains, stale classic-route cleanup, -and `crabd.sh` DNS convergence are handled by +`crabfleet.openclaw.ai` is the only app Worker Custom Domain declared in the app +Wrangler config. The `crabfleet.ai` product Custom Domain and `crabd.sh` DNS +convergence are handled by `scripts/ensure-cloudflare-domains.mjs`; set `CLOUDFLARE_DNS_API_TOKEN` for manual deploys and when CI should manage those records. Without that DNS-scoped repository secret, CI skips domain convergence but still fails the -deploy unless the app health endpoint is reachable and both product hosts resolve -to `docs.crabfleet.ai`. The app Worker keeps the same public-docs redirect as a -defensive fallback, never the authenticated app. +deploy unless the app health endpoint is reachable and `crabfleet.ai` resolves +to `docs.crabfleet.ai`. The product router source and deploy configuration live in `src/product-router.ts` and `wrangler.product.jsonc`. @@ -184,8 +183,8 @@ CLOUDFLARE_DNS_API_TOKEN=... \ pnpm run deploy ``` -`pnpm deploy:product` deploys only the generic product Worker, then converges its -two public Custom Domains and stale classic-route cleanup. +`pnpm deploy:product` deploys only the generic product Worker, then converges +the canonical product Custom Domain. ### Environment Variables @@ -204,41 +203,27 @@ The Crabbox namespace cutover intentionally has no old-name compatibility. Exist - `GITHUB_ORG` – GitHub org for membership check (default: `openclaw`) - `GITHUB_TOKEN` – GitHub token for all enabled repo issue/PR previews and private repo `CRABBOX.md` refreshes (optional; public/default repo paths work without it) - `CRABBOX_TOKEN_ENCRYPTION_KEY` – Optional encryption key for per-session GitHub OAuth tokens; defaults to `GITHUB_CLIENT_SECRET` -- `CRABBOX_INTERACTIVE_PROVISION_URL` – Optional adapter endpoint for standalone Codex CLI workspaces -- `CRABBOX_INTERACTIVE_PROVISION_TOKEN` – Optional bearer token sent to the interactive provision endpoint; required when backend URLs below are configured and always required to stop an existing standalone Sandbox +- `CRABBOX_INTERACTIVE_PROVISION_TOKEN` – Required bearer token for the built-in Sandbox provision, PTY, and stop endpoints - `CRABBOX_STANDALONE_SANDBOX_TTL_SECONDS` – Optional built-in standalone Sandbox lifetime, default `14400`, bounded to 300–86400 seconds -- `CRABBOX_RUNTIME_ADAPTER_URL` – Optional fixed base URL for the versioned workspace lifecycle adapter; mutually exclusive with `CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE`, takes precedence over the legacy create-only runtime provision URL, and becomes immutable registration identity for each created lifecycle. Nested base paths are preserved; raw query or fragment delimiters are rejected. +- `CRABBOX_RUNTIME_ADAPTER_URL` – Optional fixed base URL for the versioned workspace lifecycle adapter; mutually exclusive with `CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE` and becomes immutable registration identity for each created lifecycle. Nested base paths are preserved; raw query or fragment delimiters are rejected. - `CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE` – Optional profile-routed alternative containing exactly one `{profile}` full path segment. Selected profile IDs must be lowercase DNS labels; the resolved URL is validated and persisted with the same immutable lifecycle fence as a fixed adapter URL. - `CRABBOX_COORDINATOR_ORIGIN` – Optional public origin corresponding to the `CRABBOX_COORDINATOR` service binding. Matching fixed or profile-routed lifecycle and terminal requests use the binding; other adapter origins use normal outbound fetch. - `CRABBOX_RUNTIME_ADAPTER_TOKEN` – Required bearer token for the versioned lifecycle adapter; sent only over HTTPS or literal loopback HTTP - `CRABBOX_RUNTIME_ADAPTER_NAMESPACE` – Required stable tenant namespace when the versioned adapter is enabled; a DNS-safe label of at most 32 characters used in every workspace ID and idempotency key - `CRABBOX_RUNTIME_ADAPTER_TTL_SECONDS` – Optional requested workspace TTL, default `14400` - `CRABBOX_RUNTIME_ADAPTER_IDLE_SECONDS` – Optional requested workspace idle timeout, default `1800` -- `CRABBOX_RUNTIME_PROVISION_URL` – Optional generic backend URL used by `/api/provision/interactive` -- `CRABBOX_RUNTIME_PROVISION_TOKEN` – Optional bearer token sent to the generic runtime backend -- `CRABBOX_CLOUDFLARE_RUNNER_URL` – Optional Crabbox Cloudflare container runner URL used by `/api/provision/interactive` -- `CRABBOX_CLOUDFLARE_RUNNER_TOKEN` – Optional bearer token sent to the Cloudflare runner -- `CRABBOX_CLOUDFLARE_RUNNER_INSTANCE_TYPE` – Optional runner instance type, default `standard-4` -- `CRABBOX_CLOUDFLARE_RUNNER_WORKDIR` – Optional base workdir for provisioned sandboxes, default `/workspace/crabbox` -- `CRABBOX_CLOUDFLARE_RUNNER_TTL_SECONDS` – Optional sandbox TTL, default `14400` -- `CRABBOX_CLOUDFLARE_RUNNER_IDLE_SECONDS` – Optional idle timeout, default `1800` -- `CRABBOX_PTY_BRIDGE_URL` – Optional WebSocket PTY bridge URL/template for live Ghostty attach; requires WSS except literal loopback WS and supports `{id}`, `{leaseId}`, `{repo}`, `{branch}`, and `{runtime}` -- `CRABBOX_PTY_BRIDGE_TOKEN` – Optional bearer token sent from Crabfleet to the PTY bridge -- `CRABBOX_CLAWFLEET_URL` – Optional ClawFleet dashboard/API URL used by `/api/provision/interactive` for `crabbox` sessions -- `CRABBOX_CLAWFLEET_TOKEN` – Optional bearer token sent to ClawFleet -- `CRABBOX_CLAWFLEET_PUBLIC_URL` – Optional public ClawFleet URL used when building attach/VNC links - `CRABBOX_OPENCLAW_TOKEN` – Internal bearer token for OpenClaw service crabbox and GitHub Actions session registration - `CRABBOX_MULTICODEX_TOKEN` – Optional dedicated bearer token for MultiCodex room supervision -- `CRABBOX_EMBED_TICKET_SECRET` – Crabfleet-only signing key for short-lived terminal embed tickets -- `CRABFLEET_SSH_GATEWAY_TOKEN` / `CRABBOX_SSH_GATEWAY_TOKEN` – Shared bearer token for the Go SSH gateway internal API +- `CRABBOX_EMBED_TICKET_SECRET` – Crabfleet-only signing key for short-lived, session-scoped terminal embed tickets +- `CRABFLEET_SSH_GATEWAY_TOKEN` – Shared bearer token for the Go SSH gateway internal API - `CRABFLEET_LOCAL_SANDBOX_BACKUPS` – Optional Cloudflare Sandbox checkpoint mode override; defaults to R2 binding uploads, set `0` for SDK presigned R2 uploads - `CRABFLEET_LABEL` – Optional tenant label shown in the app, default `Crabfleet` - `CRABFLEET_CANONICAL_URL` – Optional tenant app/API origin, default `https://crabfleet.openclaw.ai`; requires HTTPS except literal loopback HTTP - `CRABFLEET_PRODUCT_URL` – Optional tenant product/docs origin, default `https://crabfleet.ai`; requires HTTPS except literal loopback HTTP - `CRABFLEET_SSH_HOST` – Optional SSH command host shown in the app, default `crabd.sh` - `CRABFLEET_PREFERRED_REPO` – Optional first/default enabled repo, default `openclaw/crabfleet` -- `CRABFLEET_DEFAULT_RUNTIME` – Optional interactive runtime default, `container` or `crabbox`; defaults to `container` -- `CRABFLEET_INTERACTIVE_RUNTIMES` – Optional comma-separated allowlist of interactive runtime choices, `container`, `crabbox`, or both; defaults to `container,crabbox`. A single choice hides the runtime selector and becomes the default unless `CRABFLEET_DEFAULT_RUNTIME` explicitly names it. +- `CRABFLEET_DEFAULT_RUNTIME` – Optional interactive runtime default, `container` or `crabbox`; defaults to `container` when enabled or otherwise the only enabled runtime +- `CRABFLEET_INTERACTIVE_RUNTIMES` – Optional comma-separated allowlist of manual interactive runtimes, `container`, `crabbox`, or both; defaults to `container,crabbox` - `CRABFLEET_DEFAULT_PROFILE` – Optional opaque runtime-adapter profile, default `default` - `CRABFLEET_RUNTIME_PROFILES_JSON` – Optional bounded JSON array of generic profile descriptors (`id`, `label`, optional `target`, optional boolean `capabilities`, and optional `codexSsh`) shown to authenticated users when creating Crabbox sessions; when configured, `CRABFLEET_DEFAULT_PROFILE` must name one entry. `codexSsh.aliasTemplate` may use `{providerResourceId}`, `{workspaceId}`, `{sessionId}`, and `{profile}`. Optional `codexSsh.setupCommand` is an argv-like JSON string array: its first item and static items are shell-safe tokens, while any later item may be one complete placeholder. Crabfleet shell-quotes every substituted argument. - `CRABFLEET_DEV_LOGIN_ENABLED` – Explicit local-only development identity login gate; disabled unless exactly `true`, and still restricted to literal localhost requests @@ -316,7 +301,7 @@ wrangler d1 migrations apply DB --local ### SSH Gateway -The Worker exposes an internal SSH onboarding API guarded by `CRABFLEET_SSH_GATEWAY_TOKEN` or `CRABBOX_SSH_GATEWAY_TOKEN`. +The Worker exposes an internal SSH onboarding API guarded by `CRABFLEET_SSH_GATEWAY_TOKEN`. Run the Go gateway next to a host that can accept raw SSH: ```bash @@ -330,7 +315,7 @@ go run ./cmd/crabbox-ssh-gateway Unknown public keys get a short GitHub OAuth link through `ssh link@host`. Linked keys can run `whoami`, `list`, `new`, `attach SESSION_ID`, and `delete SESSION_ID`; `new` creates an interactive Codex session and attaches. Delete confirms runtime release for versioned lifecycle -adapters; legacy create-only and ClawFleet sessions stop locally and may need provider cleanup. +adapters and cleans up built-in Sandbox sessions through their durable ownership records. Production should expose the gateway at `crabd.sh` as a DNS-only `A` record. Use `ssh link@crabd.sh` once to connect a GitHub-backed SSH key, then run @@ -361,8 +346,6 @@ go run ./cmd/crabfleet restore go run ./cmd/crabfleet doctor ``` -`crabfleet stop ` remains a compatibility alias for `delete`. - ### CLI Release Tagged releases publish `crabfleet` with GoReleaser and dispatch the OpenClaw Homebrew tap updater: @@ -385,7 +368,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, the versioned adapter for Crabbox, or an intentionally configured legacy path. +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. ### Project Structure diff --git a/cmd/crabbox-ssh-gateway/main.go b/cmd/crabbox-ssh-gateway/main.go index 944b5f7..dcc4225 100644 --- a/cmd/crabbox-ssh-gateway/main.go +++ b/cmd/crabbox-ssh-gateway/main.go @@ -49,8 +49,9 @@ type keyAuth struct { } type sessionPTY struct { - cols uint32 - rows uint32 + cols uint32 + rows uint32 + resizes chan fleetapi.TerminalSize } func main() { @@ -59,10 +60,10 @@ func main() { var token string var hostKeyPath string var ephemeralHostKey bool - flag.StringVar(&addr, "addr", env(":2222", "CRABFLEET_SSH_ADDR", "CRABBOX_SSH_ADDR"), "SSH listen address") - flag.StringVar(&apiURL, "api", env("http://127.0.0.1:8787", "CRABFLEET_API_URL", "CRABBOX_API_URL"), "Crabfleet Worker URL") - flag.StringVar(&token, "token", env("", "CRABFLEET_SSH_GATEWAY_TOKEN", "CRABBOX_SSH_GATEWAY_TOKEN"), "Worker SSH gateway token") - flag.StringVar(&hostKeyPath, "host-key", env("", "CRABFLEET_SSH_HOST_KEY", "CRABBOX_SSH_HOST_KEY"), "SSH host private key path") + flag.StringVar(&addr, "addr", env(":2222", "CRABFLEET_SSH_ADDR"), "SSH listen address") + flag.StringVar(&apiURL, "api", env("http://127.0.0.1:8787", "CRABFLEET_API_URL"), "Crabfleet Worker URL") + flag.StringVar(&token, "token", env("", "CRABFLEET_SSH_GATEWAY_TOKEN"), "Worker SSH gateway token") + flag.StringVar(&hostKeyPath, "host-key", env("", "CRABFLEET_SSH_HOST_KEY"), "SSH host private key path") flag.BoolVar(&ephemeralHostKey, "ephemeral-host-key", false, "use a generated host key for local development only") flag.Parse() @@ -154,55 +155,99 @@ func handleConn(raw net.Conn, config *ssh.ServerConfig, client *apiClient) { func handleSession(channel ssh.Channel, requests <-chan *ssh.Request, perms *ssh.Permissions, client *apiClient) { defer channel.Close() - pty := sessionPTY{cols: 120, rows: 34} - for req := range requests { - switch req.Type { - case "pty-req": - var payload struct { - Term string - Cols uint32 - Rows uint32 - Width uint32 - Height uint32 - Modes string - } - ssh.Unmarshal(req.Payload, &payload) - if payload.Cols > 0 { - pty.cols = payload.Cols - } - if payload.Rows > 0 { - pty.rows = payload.Rows - } - req.Reply(true, nil) - case "window-change": - var payload struct { - Cols uint32 - Rows uint32 - Width uint32 - Height uint32 - } - ssh.Unmarshal(req.Payload, &payload) - if payload.Cols > 0 { - pty.cols = payload.Cols + pty := sessionPTY{ + cols: 120, + rows: 34, + resizes: make(chan fleetapi.TerminalSize, 1), + } + exitCh := make(chan uint32, 1) + commandStarted := false + for { + select { + case req, ok := <-requests: + if !ok { + return } - if payload.Rows > 0 { - pty.rows = payload.Rows + switch req.Type { + case "pty-req": + var payload struct { + Term string + Cols uint32 + Rows uint32 + Width uint32 + Height uint32 + Modes string + } + ssh.Unmarshal(req.Payload, &payload) + pty.resize(payload.Cols, payload.Rows, commandStarted) + req.Reply(true, nil) + case "window-change": + var payload struct { + Cols uint32 + Rows uint32 + Width uint32 + Height uint32 + } + ssh.Unmarshal(req.Payload, &payload) + pty.resize(payload.Cols, payload.Rows, commandStarted) + case "shell": + if commandStarted { + req.Reply(false, nil) + continue + } + commandStarted = true + req.Reply(true, nil) + go func(current sessionPTY) { + exitCh <- runCommand(context.Background(), channel, perms, client, "", current) + }(pty) + case "exec": + if commandStarted { + req.Reply(false, nil) + continue + } + var payload struct{ Command string } + ssh.Unmarshal(req.Payload, &payload) + commandStarted = true + req.Reply(true, nil) + go func(current sessionPTY, command string) { + exitCh <- runCommand( + context.Background(), + channel, + perms, + client, + command, + current, + ) + }(pty, payload.Command) + default: + req.Reply(false, nil) } - case "shell": - req.Reply(true, nil) - exit := runCommand(context.Background(), channel, perms, client, "", pty) - replyExit(channel, exit) - return - case "exec": - var payload struct{ Command string } - ssh.Unmarshal(req.Payload, &payload) - req.Reply(true, nil) - exit := runCommand(context.Background(), channel, perms, client, payload.Command, pty) + case exit := <-exitCh: replyExit(channel, exit) return + } + } +} + +func (pty *sessionPTY) resize(cols uint32, rows uint32, notify bool) { + if cols > 0 { + pty.cols = cols + } + if rows > 0 { + pty.rows = rows + } + if !notify || pty.resizes == nil || pty.cols == 0 || pty.rows == 0 { + return + } + size := fleetapi.TerminalSize{Cols: pty.cols, Rows: pty.rows} + select { + case pty.resizes <- size: + default: + select { + case <-pty.resizes: default: - req.Reply(false, nil) } + pty.resizes <- size } } @@ -311,7 +356,7 @@ func runCommand(ctx context.Context, out io.ReadWriter, perms *ssh.Permissions, } fmt.Fprintf(out, "session %s not found\n", fleettext.Safe(args[1])) return 1 - case "delete", "stop": + case "delete": if len(args) != 2 { fmt.Fprintln(out, "usage: delete SESSION_ID") return 2 @@ -612,7 +657,7 @@ func attach( id string, pty sessionPTY, ) uint32 { - err := api.Attach(ctx, id, terminal, pty.cols, pty.rows) + err := api.Attach(ctx, id, terminal, pty.cols, pty.rows, pty.resizes) if err != nil && !errors.Is(err, net.ErrClosed) && !strings.Contains(err.Error(), "closed") { fmt.Fprintf(terminal, "\nattach closed: %v\n", err) return 1 @@ -680,13 +725,13 @@ func loadHostKey(path string, allowEphemeral bool) (ssh.Signer, error) { return ssh.ParsePrivateKey(data) } if !allowEphemeral { - return nil, errors.New("CRABBOX_SSH_HOST_KEY or --host-key is required") + return nil, errors.New("CRABFLEET_SSH_HOST_KEY or --host-key is required") } _, privateKey, err := ed25519.GenerateKey(rand.Reader) if err != nil { return nil, err } - log.Print("using ephemeral SSH host key; set CRABBOX_SSH_HOST_KEY for production") + log.Print("using ephemeral SSH host key; set CRABFLEET_SSH_HOST_KEY for production") return ssh.NewSignerFromKey(privateKey) } diff --git a/cmd/crabbox-ssh-gateway/main_test.go b/cmd/crabbox-ssh-gateway/main_test.go index c2f952b..f201d1f 100644 --- a/cmd/crabbox-ssh-gateway/main_test.go +++ b/cmd/crabbox-ssh-gateway/main_test.go @@ -32,6 +32,26 @@ func TestSplitCommandKeepsQuotedValues(t *testing.T) { } } +func TestSessionPTYPublishesLatestLiveResize(t *testing.T) { + pty := sessionPTY{ + cols: 120, + rows: 34, + resizes: make(chan fleetapi.TerminalSize, 1), + } + pty.resize(100, 40, false) + select { + case size := <-pty.resizes: + t.Fatalf("resize published before attach: %#v", size) + default: + } + + pty.resize(132, 43, true) + pty.resize(144, 50, true) + if size := <-pty.resizes; size != (fleetapi.TerminalSize{Cols: 144, Rows: 50}) { + t.Fatalf("resize = %#v", size) + } +} + func TestSplitCommandPreservesBackslashesInSingleQuotes(t *testing.T) { args, err := splitCommand(`new 'fix regex \d+ in parser'`) if err != nil { @@ -107,7 +127,7 @@ func TestParseCreateAcceptsProfileOverride(t *testing.T) { } } -func TestDeleteCommandAndStopAliasUseWorkspaceStopAction(t *testing.T) { +func TestDeleteCommandUsesWorkspaceStopAction(t *testing.T) { var action string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/api/ssh/interactive-sessions/IS-7/actions" { @@ -139,23 +159,17 @@ func TestDeleteCommandAndStopAliasUseWorkspaceStopAction(t *testing.T) { "login": "operator", "role": "owner", }} - for _, command := range []string{"delete IS-7", "stop IS-7"} { - action = "" - var output bytes.Buffer - if exit := runCommand(context.Background(), &output, permissions, client, command, sessionPTY{}); exit != 0 { - t.Fatalf("command=%q exit=%d output=%q", command, exit, output.String()) - } - if action != "stop" { - t.Fatalf("command=%q action=%q, want stop", command, action) - } - if !strings.Contains(output.String(), "provider deletion was not confirmed") { - t.Fatalf("command=%q missing legacy cleanup warning: %q", command, output.String()) - } - if got := output.String(); !strings.Contains(got, "session: IS-7\nstatus: stopping\n") { - t.Fatalf("command=%q output=%q", command, got) - } + var output bytes.Buffer + if exit := runCommand(context.Background(), &output, permissions, client, "delete IS-7", sessionPTY{}); exit != 0 { + t.Fatalf("exit=%d output=%q", exit, output.String()) + } + if action != "stop" { + t.Fatalf("action=%q, want stop", action) } - for _, command := range []string{"delete", "delete IS-7 extra", "stop", "stop IS-7 extra"} { + if got := output.String(); !strings.Contains(got, "session: IS-7\nstatus: stopping\n") { + t.Fatalf("output=%q", got) + } + for _, command := range []string{"delete", "delete IS-7 extra"} { action = "" var output bytes.Buffer if exit := runCommand(context.Background(), &output, permissions, client, command, sessionPTY{}); exit != 2 { @@ -168,6 +182,16 @@ func TestDeleteCommandAndStopAliasUseWorkspaceStopAction(t *testing.T) { t.Fatalf("command=%q output=%q", command, got) } } + var stopOutput bytes.Buffer + if exit := runCommand(context.Background(), &stopOutput, permissions, client, "stop IS-7", sessionPTY{}); exit != 2 { + t.Fatalf("stop alias exit=%d output=%q", exit, stopOutput.String()) + } + if action != "" { + t.Fatalf("stop alias unexpectedly submitted action=%q", action) + } + if got := stopOutput.String(); !strings.HasPrefix(got, "unknown command: stop\n") { + t.Fatalf("stop alias output=%q", got) + } } func TestHelpNamesDeleteAsCanonicalCommand(t *testing.T) { diff --git a/cmd/crabfleet/main.go b/cmd/crabfleet/main.go index da257cf..e235b37 100644 --- a/cmd/crabfleet/main.go +++ b/cmd/crabfleet/main.go @@ -37,11 +37,11 @@ type cli struct { Login loginCmd `cmd:"" help:"Link this machine through SSH onboarding."` Whoami whoamiCmd `cmd:"" help:"Show the linked Crabfleet user."` - List listCmd `cmd:"" aliases:"ls" help:"List crabboxes as an owner/session tree."` + List listCmd `cmd:"" help:"List crabboxes as an owner/session tree."` New newCmd `cmd:"" help:"Create a repo-ready crabbox and attach."` Attach attachCmd `cmd:"" help:"Attach to a crabbox terminal."` Status statusCmd `cmd:"" help:"Show one crabbox lifecycle state."` - Delete deleteCmd `cmd:"" aliases:"stop" help:"End a crabbox session through its configured lifecycle."` + Delete deleteCmd `cmd:"" help:"End a crabbox session through its configured lifecycle."` Doctor doctorCmd `cmd:"" help:"Check API, auth, and linked lifecycle access."` Checkpoints checkpointsCmd `cmd:"" help:"List sandbox checkpoints."` Checkpoint checkpointCmd `cmd:"" help:"Create a sandbox checkpoint."` diff --git a/cmd/crabfleet/main_test.go b/cmd/crabfleet/main_test.go index 8d3874b..f8f7bd7 100644 --- a/cmd/crabfleet/main_test.go +++ b/cmd/crabfleet/main_test.go @@ -159,7 +159,7 @@ func TestDeleteCommandUsesProviderStopAction(t *testing.T) { } } -func TestCLIUsesDeleteCanonicalNameWithStopAlias(t *testing.T) { +func TestCLIUsesDeleteCanonicalNameWithoutStopAlias(t *testing.T) { var app cli parser, err := kong.New(&app, kong.Name("crabfleet"), kong.Vars{"version": version}) if err != nil { @@ -171,16 +171,33 @@ func TestCLIUsesDeleteCanonicalNameWithStopAlias(t *testing.T) { if app.Delete.ID != "IS-7" { t.Fatalf("delete id = %q", app.Delete.ID) } - var legacy cli - legacyParser, err := kong.New(&legacy, kong.Name("crabfleet"), kong.Vars{"version": version}) + var rejected cli + rejectedParser, err := kong.New(&rejected, kong.Name("crabfleet"), kong.Vars{"version": version}) if err != nil { t.Fatal(err) } - if _, err := legacyParser.Parse([]string{"stop", "IS-8"}); err != nil { + if _, err := rejectedParser.Parse([]string{"stop", "IS-8"}); err == nil { + t.Fatal("stop alias unexpectedly parsed") + } +} + +func TestCLIUsesListCanonicalNameWithoutAlias(t *testing.T) { + var app cli + parser, err := kong.New(&app, kong.Name("crabfleet"), kong.Vars{"version": version}) + if err != nil { + t.Fatal(err) + } + if _, err := parser.Parse([]string{"list"}); err != nil { + t.Fatal(err) + } + + var rejected cli + rejectedParser, err := kong.New(&rejected, kong.Name("crabfleet"), kong.Vars{"version": version}) + if err != nil { t.Fatal(err) } - if legacy.Delete.ID != "IS-8" { - t.Fatalf("stop alias id = %q", legacy.Delete.ID) + if _, err := rejectedParser.Parse([]string{"ls"}); err == nil { + t.Fatal("list alias unexpectedly parsed") } } diff --git a/docs/admin.md b/docs/admin.md index 98e8128..2b4ab1e 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -198,8 +198,9 @@ Proxy-only identity cannot link SSH keys. Use a separate OAuth-capable origin th ### Scoped Service Auth -- SSH gateway: `CRABFLEET_SSH_GATEWAY_TOKEN` or `CRABBOX_SSH_GATEWAY_TOKEN`. +- SSH gateway: `CRABFLEET_SSH_GATEWAY_TOKEN`. - OpenClaw service: `CRABBOX_OPENCLAW_TOKEN`. +- OpenClaw terminal embeds: `CRABBOX_EMBED_TICKET_SECRET`, retained only by Crabfleet. - Session agent: `CRABFLEET_AGENT_TOKEN` plus session ID. - Stateless provision hook: `CRABBOX_INTERACTIVE_PROVISION_TOKEN`. - Runtime adapter: `CRABBOX_RUNTIME_ADAPTER_TOKEN`. @@ -212,7 +213,7 @@ Worker secrets live in Cloudflare bindings, not D1/R2: - GitHub OAuth/client and optional deployment token. - Bootstrap token. -- Runtime adapter/provision/runner/ClawFleet tokens. +- Runtime adapter and standalone Sandbox provision tokens. - OpenClaw and SSH gateway service tokens. - OpenAI API key. - token-encryption key. @@ -347,7 +348,6 @@ Check the selected runtime and deployment backend: - built-in `SANDBOX` binding for Container; - `CRABBOX_RUNTIME_ADAPTER_URL` or `CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE`, plus token and namespace, for versioned Crabbox; -- legacy provision/runner/ClawFleet settings only when intentionally used. ### Delete Remains `stopping` diff --git a/docs/api.md b/docs/api.md index 2e2f6a7..4da239d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -249,23 +249,20 @@ Public read-only endpoint for a generated session share link. Returns the shared ### POST /api/provision/interactive -Provision hook used by `CRABBOX_INTERACTIVE_PROVISION_URL`. It accepts the same session request payload as the external adapter contract and returns normalized provision status. +Provision hook for built-in Cloudflare Sandbox workspaces. It accepts the managed session request shape and returns normalized provision status. Auth: -- If `CRABBOX_INTERACTIVE_PROVISION_TOKEN` is set, callers must send `Authorization: Bearer `. -- The token is required when `CRABBOX_RUNTIME_ADAPTER_URL`, `CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE`, `CRABBOX_RUNTIME_PROVISION_URL`, `CRABBOX_CLOUDFLARE_RUNNER_URL`, or `CRABBOX_CLAWFLEET_URL` is configured; backend-enabled deployments fail closed without it. +- Callers must send `Authorization: Bearer `. +- Provision, PTY, and standalone stop fail closed when the token is absent, including after the Sandbox binding is removed. Backends: - Versioned lifecycle adapters are deliberately excluded from this stateless hook. Create those workspaces through `POST /api/interactive-sessions`, which durably records ownership before calling the adapter. - Direct built-in Sandbox calls without a managed interactive-session row acquire a durable standalone ownership fence before credential-policy registration. Standalone IDs cannot use the case-insensitive `IS-` managed-session namespace. Retries with the same ID must match the original immutable request; abandoned claims and failed provisions enter the same generation-fenced cleanup path as managed sessions. - A request whose ID already belongs to a managed interactive session is rejected unless every immutable request field matches that row and the call wins an exact session-version ownership claim before allocating a Sandbox. Completion commits through the immutable lease, claim, agent-token, and status ownership fence while monotonically advancing the session version, so an intervening metadata edit does not discard the non-replayable result. -- `CRABBOX_RUNTIME_PROVISION_URL`: forwards the session payload to a legacy create-only runtime adapter. -- `CRABBOX_CLOUDFLARE_RUNNER_URL`: creates a Crabbox Cloudflare container sandbox and returns its lease reference. -- `CRABBOX_CLAWFLEET_URL`: creates a ClawFleet OpenClaw instance and returns console/noVNC links. -- ClawFleet handles `crabbox` sessions only; use `CRABBOX_RUNTIME_PROVISION_URL` or `CRABBOX_CLOUDFLARE_RUNNER_URL` for `container` sessions. -- If neither backend is configured, returns `pending_adapter` with a message that the route is live. +- Only `runtime=container` is accepted. Crabbox workspaces must use the versioned managed lifecycle. +- Without the `SANDBOX` binding, the request fails instead of selecting another provider. For a successful direct built-in Sandbox provision, `attachUrl` is an absolute `wss://` URL under `/api/provision/interactive/:id/pty`, and `expiresAt` is bounded by `CRABBOX_STANDALONE_SANDBOX_TTL_SECONDS` (default four hours, maximum one day). Connect with the same `Authorization: Bearer ` header used for provisioning. The Worker validates the unexpired standalone owner and exact active credential-policy generation, strips the bearer before opening the Sandbox terminal, proxies the WebSocket while periodically revalidating that ownership, and closes both peers after stop, expiry, or policy revocation. It never routes the connection through `interactive_sessions`. `POST /api/provision/interactive/:id/stop` always requires that configured bearer, even if runtime backend bindings were removed after creation, and atomically moves the exact owner plus every matching policy into durable cleanup; expiry follows the same path from cron and PTY access, and cleanup terminates the Sandbox terminal execution session before deleting its owner row. @@ -279,7 +276,7 @@ Crabfleet authenticates every adapter request with `Authorization: Bearer CRABBO - `DELETE /v1/workspaces/:id`: stop/release. Crabfleet enters `stopping` before calling the adapter and marks the session stopped only after `204`, `404`, or a valid exact-ID terminal response confirms release; malformed successful bodies remain `stopping`. Plain-text and malformed-JSON responses are read once and sanitized before their evidence is retained. An explicit stop whose ownership claim loses returns success only when the exact workspace is already stopping or terminal; otherwise it returns a lifecycle conflict. - `POST /v1/workspaces/:id/connections/desktop`: mint a current transient desktop URL. The request has no body. `expiresAt` is optional; when present it must be in the future and no more than 15 minutes away. Accepted HTTPS URLs are treated as opaque signed connection material and redirected byte-for-byte without URL normalization. After minting, Crabfleet re-reads the exact current session status, control grant, capabilities, and registered adapter identity before redirecting; a concurrent stop, revocation, capability withdrawal, or lifecycle replacement discards the URL and denies access. -`CRABBOX_RUNTIME_ADAPTER_NAMESPACE` is required and must remain stable for the deployment. It prevents workspace and idempotency collisions when an adapter serves more than one Crabfleet tenant. The adapter workspace `id` is an immutable lifecycle route key and remains separate from an opaque `providerResourceId`; the provider identity is never interpreted as a legacy lease or sandbox ID. Create, inspect, and stop responses must echo the byte-exact requested DNS-safe `id`; whitespace normalization is not accepted. Responses use `status`, `id`, optional `providerResourceId`, `attachUrl`, `capabilities`, `expiresAt`, and `message`. Only a literal `null` clears a previously stored expiry; a malformed non-null timestamp invalidates the response. A terminal URL implies terminal capability only when the response omits a terminal capability; an explicit `terminal: false` wins. Supported status values include `provisioning`, `ready`, `stopping`, `stopped`, `expired`, and `failed`. Create-only legacy adapters cannot return `stopping`, because they do not own a later reconciliation lifecycle. Every session-bound provider DELETE is gated on the persisted create ambiguity marker being clear. +`CRABBOX_RUNTIME_ADAPTER_NAMESPACE` is required and must remain stable for the deployment. It prevents workspace and idempotency collisions when an adapter serves more than one Crabfleet tenant. The adapter workspace `id` is an immutable lifecycle route key and remains separate from an opaque `providerResourceId`; the provider identity is never interpreted as a Sandbox lease ID. Create, inspect, and stop responses must echo the byte-exact requested DNS-safe `id`; whitespace normalization is not accepted. Responses use `status`, `id`, optional `providerResourceId`, `attachUrl`, `capabilities`, `expiresAt`, and `message`. Only a literal `null` clears a previously stored expiry; a malformed non-null timestamp invalidates the response. A terminal URL implies terminal capability only when the response omits a terminal capability; an explicit `terminal: false` wins. Supported status values include `provisioning`, `ready`, `stopping`, `stopped`, `expired`, and `failed`. Every session-bound provider DELETE is gated on the persisted create ambiguity marker being clear. Every create, inspect, delete, and desktop response body is consumed through one 64 KiB bounded stream reader before JSON or text parsing. Declared or chunked oversized bodies are cancelled and fail safely: ambiguous create remains reconcilable, delete remains pending, inspect retries later, and desktop access is denied. @@ -295,7 +292,7 @@ The wire format is a compact binary frame: ```text u16 magic 0x5943 -u8 version 1 +u8 version 2 u8 message_type u32 session_id_length utf8 session_id @@ -305,7 +302,7 @@ payload bytes Supported client actions: -- `Subscribe`: attach to a session with output/snapshot/event flags and optional initial cols/rows. +- `Subscribe`: attach to a session with output/snapshot/event flags. Its payload is exactly five little-endian `u32` fields: flags, snapshot minimum interval, snapshot maximum interval, initial cols, and initial rows. Zero dimensions select server defaults. - `Unsubscribe`: detach one session without closing the hub. - `Input` / `Key`: send terminal bytes when control is granted. - `Resize`: forward terminal dimensions to the upstream PTY. @@ -317,11 +314,10 @@ Server messages include `Welcome`, `Output`, `Event`, `Error`, `ControlRevoked`, Target resolution: -- `CRABBOX_PTY_BRIDGE_URL`: explicit bridge WebSocket URL/template. Templates support `{id}`, `{leaseId}`, `{repo}`, `{branch}`, and `{runtime}`. Crabfleet appends `sessionId`, `leaseId`, `repo`, `branch`, `runtime`, and `command` query parameters. +- Built-in Sandbox terminal: Crabfleet opens the session PTY through the `SANDBOX` binding and forwards the requested terminal dimensions. - Provider terminal connection: if the provision adapter returned a `wss://` URL, or literal loopback `ws://` URL, Crabfleet retains it server-side and proxies to it unchanged, including its path and signed query string. -- `CRABBOX_CLOUDFLARE_RUNNER_URL`: for `cloudflare:` leases, Crabfleet proxies to `/v1/sandboxes/:sandbox/pty` on the runner. -The hub appends terminal `cols` and `rows` only to configured bridge and Cloudflare runner endpoints, never to an adapter `attachUrl`. Crabfleet authenticates versioned-adapter terminal upgrades with `CRABBOX_RUNTIME_ADAPTER_TOKEN` only when the terminal shares the persisted and currently configured adapter origin; adapter URLs never carry reusable shell credentials. If `CRABBOX_PTY_BRIDGE_TOKEN` or `CRABBOX_CLOUDFLARE_RUNNER_TOKEN` is set, Crabfleet sends it as a bearer token only to the upstream bridge/runner. Clients never receive upstream credentials. +Crabfleet never rewrites an adapter `attachUrl`. It authenticates versioned-adapter terminal upgrades with `CRABBOX_RUNTIME_ADAPTER_TOKEN` only when the terminal shares the persisted and currently configured adapter origin; adapter URLs never carry reusable shell credentials. Clients never receive upstream credentials. ### POST /api/interactive-sessions/:id/clipboard @@ -329,7 +325,7 @@ Viewer+ with writable terminal control. Uploads a browser clipboard image/file b ### GET /api/interactive-sessions/:id/vnc -Viewer+ with writable session control. For `runtime-v1`, Crabfleet authenticates the browser session, asks the adapter to mint a current desktop connection, validates its HTTPS URL and optional bounded expiry, and issues a no-store redirect. Versioned-adapter desktop URLs are never persisted in D1 or returned by fleet state. API and CLI session views expose an absolute canonical Crabfleet browser URL for this cookie-authenticated route; the SSH gateway does not mint or receive the underlying adapter URL. Legacy adapters retain their existing validated absolute VNC URL behavior for browser and CLI clients. +Viewer+ with writable session control. For `runtime-v1`, Crabfleet authenticates the browser session, asks the adapter to mint a current desktop connection, validates its HTTPS URL and optional bounded expiry, and issues a no-store redirect. Versioned-adapter desktop URLs are never persisted in D1 or returned by fleet state. API and CLI session views expose an absolute canonical Crabfleet browser URL for this cookie-authenticated route; the SSH gateway does not mint or receive the underlying adapter URL. Built-in Sandbox sessions may expose their validated desktop URL while active. ### POST /api/openclaw/action-sessions @@ -420,9 +416,9 @@ Fields: - `purpose`: optional short mission label. - `summary`: optional list/closeout summary. -If `CRABBOX_RUNTIME_ADAPTER_URL` or `CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE` is configured, the Worker creates and reconciles the versioned adapter workspace and records its resolved lifecycle identity, status, capabilities, expiry, and terminal connection. Otherwise `CRABBOX_INTERACTIVE_PROVISION_URL` retains the legacy create-only behavior. Without an adapter the session is stored as `pending_adapter`. +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/bridge/runner 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, 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. 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. @@ -492,7 +488,7 @@ Actions: - `revoke_control`: owner/maintainer, 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, Stop, or End. Versioned adapters release the provider workspace before marking stopped, and asynchronous releases remain `stopping` until reconciliation confirms completion. Legacy create-only and ClawFleet sessions stop only in Crabfleet because those integrations expose no release lifecycle. 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`: 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. Response: @@ -506,7 +502,7 @@ Response: ## SSH Gateway The Go gateway terminates raw SSH and calls Worker APIs with `Authorization: Bearer -CRABBOX_SSH_GATEWAY_TOKEN`. These endpoints are not browser APIs. +CRABFLEET_SSH_GATEWAY_TOKEN`. These endpoints are not browser APIs. - `POST /api/ssh/auth`: checks a public-key fingerprint. Unknown keys receive a short `/ssh/link/:code` GitHub OAuth URL only when the gateway is in explicit link mode, e.g. `ssh link@host`. - `GET /api/ssh/state`: returns the same board/session state for the linked SSH user. @@ -656,7 +652,7 @@ Root stop request: Root stop closes descendant admission before rolling back pending reservations and driving the remaining root tree terminal in bounded batches, including a -legacy tree above the normal supervision limit. It returns only after the whole +tree above the normal supervision limit. It returns only after the whole tree is quiescent; a failed request leaves admission closed and can be retried safely. diff --git a/docs/architecture.md b/docs/architecture.md index 3c72510..2c0d593 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -115,14 +115,14 @@ Interactive sessions are the live execution plane. Supported paths: - **Built-in Sandbox:** Worker provisions a Cloudflare Sandbox, prepares the repo, starts a Codex-capable shell, and proxies PTY traffic. - **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. -- **Legacy provision hook:** create-only compatibility path. It can return terminal/VNC metadata but has no provider release lifecycle. -- **ClawFleet compatibility:** create-only Crabbox integration retained for deployments still using it. - **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. 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. +`CRABFLEET_INTERACTIVE_RUNTIMES` is the single manual-session runtime allowlist consumed by the API and browser create drawer. Deployments may expose built-in `container`, versioned-adapter `crabbox`, or both; a single enabled runtime becomes implicit in the UI. + ## Versioned Adapter `CRABBOX_RUNTIME_ADAPTER_URL`, or the mutually exclusive profile-routed `CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE`, enables the provider-neutral lifecycle contract: @@ -154,10 +154,10 @@ Browser, CLI, agent, and SSH gateway clients use the multiplex `/api/terminal/ws - rechecks D1 authorization without waiting on provider I/O; - forwards input only for the current controller; - closes subscriptions after control or terminal capability is revoked; -- appends dimensions only to known bridge/runner routes, never opaque signed adapter URLs; +- forwards initial dimensions directly to Sandbox terminals and leaves opaque signed adapter URLs unchanged; - keeps runtime bearer credentials out of browser responses. -Versioned adapter VNC uses the authenticated Worker route `/api/interactive-sessions/:id/vnc`, which mints a fresh provider desktop connection after authorization. Legacy sessions may expose a validated stored VNC URL. +Versioned adapter VNC uses the authenticated Worker route `/api/interactive-sessions/:id/vnc`, which mints a fresh provider desktop connection after authorization. ## Sandbox Credentials diff --git a/docs/github-actions-sessions.md b/docs/github-actions-sessions.md index 263d282..3e40ee4 100644 --- a/docs/github-actions-sessions.md +++ b/docs/github-actions-sessions.md @@ -374,9 +374,9 @@ The browser, CLI, and SSH surfaces warn that the GitHub Actions workflow run may continue. Cancel the run in GitHub when provider-side cancellation is required. -`github_actions` sessions are excluded from the legacy workspace-stop -reconciler. They do not have a provider workspace lease for that reconciler to -release. +`github_actions` sessions are excluded from runtime-adapter workspace +reconciliation. They do not have a provider workspace lease for that +reconciler to release. Registration after an earlier terminal state explicitly clears stale terminal and cleanup markers before accepting the resumed runner. @@ -545,7 +545,7 @@ GitHub Actions resume lifecycle. ### Repeated Legacy Stop Events This indicates a lifecycle regression. GitHub Actions sessions must be excluded -from legacy stopping reconciliation and must not carry a synthetic workspace +from non-adapter stopping reconciliation and must not carry a synthetic workspace lease. ### Action Succeeded but Session Is Not Complete @@ -564,6 +564,6 @@ disconnect alone. - Terminal input is interpreted by the runner integration. - Terminal states require explicit caller updates. - Cancellation is separate from provider workspace teardown. -- `github_actions` sessions never enter legacy workspace-stop reconciliation. +- `github_actions` sessions never enter runtime-adapter workspace reconciliation. - Crabfleet reports status and control; the caller owns task policy and external mutations. diff --git a/docs/index.md b/docs/index.md index b99cdb1..4afdcd8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,7 +32,7 @@ The web app at [crabfleet.openclaw.ai/app](https://crabfleet.openclaw.ai/app/) e ## What Crabfleet Does - **SSH-first onboarding.** Connect through `ssh link@crabd.sh`, complete GitHub sign-in, then use linked-key auth. -- **Crabbox control.** Create, attach, share, open WebVNC, delete provider-backed runtime workspaces, stop legacy sessions locally, and clean up retained Codex session history. +- **Crabbox control.** Create, attach, share, open WebVNC, delete provider-backed runtime workspaces, and clean up retained Codex session history. - **Fleet visibility.** The app groups all org Codex instances by person so OpenClaw can supervise live work. - **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. @@ -93,7 +93,7 @@ Cards represent task intent and policy: ### Runs -When a card enters Running, Crabfleet creates a `run_attempts` row, selects a runtime descriptor, records the selection reason and capabilities, and starts heartbeat/stall tracking. This is durable scheduling/control evidence, not an autonomous process launch. Live work is represented by interactive sessions, including built-in Sandbox, versioned Crabbox, legacy adapter, ClawFleet, and GitHub Actions-backed sessions. +When a card enters Running, Crabfleet creates a `run_attempts` row, selects a runtime descriptor, records the selection reason and capabilities, and starts heartbeat/stall tracking. This is durable scheduling/control evidence, not an autonomous process launch. Live work is represented by interactive sessions, including built-in Sandbox, versioned Crabbox, and GitHub Actions-backed sessions. ### Repo Workflows diff --git a/docs/quickstart.md b/docs/quickstart.md index e35e79b..bf1ba75 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -82,7 +82,7 @@ crabfleet new --repo openclaw/crabfleet "fix the failing check" The CLI omits `runtime` unless `--runtime` is passed, so the deployment chooses via `CRABFLEET_DEFAULT_RUNTIME` (`container` when enabled, otherwise the only runtime enabled by `CRABFLEET_INTERACTIVE_RUNTIMES`). The OpenClaw deployment supports built-in Cloudflare Sandbox sessions and versioned Crabbox workspaces. -End a session with `crabfleet delete `. Versioned lifecycle adapters confirm runtime release; legacy create-only and ClawFleet sessions stop only in Crabfleet and may require separate provider cleanup. Crabfleet retains the final status and logs until you clean up the dead session record. +End a session with `crabfleet delete `. Versioned lifecycle adapters confirm runtime release, while built-in Sandbox sessions clean up their durable lease and credential policy. Crabfleet retains the final status and logs until you clean up the dead session record. Useful follow-up commands: @@ -130,7 +130,7 @@ The Worker will: - Store a run attempt with selection reason and capabilities. - Move the card to Running and append events. -Click Attach to open the Ghostty WASM session grid. The grid immediately shows D1 event replay and switches to live PTY output through the terminal hub when the session has a sandbox or bridge. +Click Attach to open the Ghostty WASM session grid. The grid immediately shows D1 event replay and switches to live PTY output through the terminal hub when the session has a Sandbox or provider terminal. The card attempt itself is scheduling/control evidence. It does not launch an autonomous Codex process; live work appears as a Fleet interactive session. diff --git a/docs/runs.md b/docs/runs.md index d9c9f8d..befea2a 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -82,7 +82,7 @@ Attach opens a fullscreen Ghostty WASM grid. Current behavior: - Shows one or more Codex session tiles. - Includes standalone interactive Codex CLI sessions created from New session. - Uses the local `ghostty-web` bundle served by the Worker. -- Streams live PTY bytes through the multiplex `/api/terminal/ws` hub when a sandbox or bridge is configured. +- Streams live PTY bytes through the multiplex `/api/terminal/ws` hub when a Sandbox or versioned adapter terminal is available. - Replays D1 event logs into the terminal surface while a live PTY is unavailable. - Falls back to a text terminal if Ghostty cannot initialize. - Copies terminal selection, pastes clipboard text when the viewer has writable control, and uploads clipboard images/files for Cloudflare Sandbox sessions. @@ -100,34 +100,22 @@ Deployments can expose an allowlisted set of generic Crabbox profiles. The creat Interactive sessions also store `parentSessionId`, `rootSessionId`, `createdBy`, `purpose`, and `summary`. Built-in Sandbox sessions export `CRABFLEET_SESSION_ID`, `CRABFLEET_PARENT_SESSION_ID`, `CRABFLEET_ROOT_SESSION_ID`, `CRABFLEET_AGENT_TOKEN`, and `CRABFLEET_API_URL`; the Go CLI uses those values to list sibling/child sessions, create children, send PTY messages, fetch transcripts, and update summaries without an SSH key. -Adapter capability arrays are authoritative: omitting `terminal`, `pty`, or `ssh` withdraws terminal access. A valid WSS (or literal-loopback WS) terminal URL implies terminal access only when capabilities are omitted entirely or an object omits all terminal-related keys. `ptyAvailable` additionally requires a ready lifecycle state and a resolvable configured Sandbox, bridge, direct WebSocket, or Cloudflare runner route. +Adapter capability arrays are authoritative: omitting `terminal`, `pty`, or `ssh` withdraws terminal access. A valid WSS (or literal-loopback WS) terminal URL implies terminal access only when capabilities are omitted entirely or an object omits all terminal-related keys. `ptyAvailable` additionally requires a ready lifecycle state and a resolvable configured Sandbox or direct adapter WebSocket route. -Session events are mirrored into the `SESSION_LOGS` R2 binding when configured. Crabfleet writes NDJSON, Markdown transcript, and summary objects under `orgs/openclaw/interactive-sessions//`, while D1 keeps the compact event list and archive keys for the app, CLI, and SSH gateway. If the binding is enabled after D1-only terminal archives were finalized, cron and targeted reconciliation requeue their null-key snapshots and backfill the objects before cleanup. Cleanup transactionally removes the finalized D1 session, events, and archive pointers before best-effort R2 deletion, so a partial object-delete failure is an unreferenced leak rather than a dangling archive reference. Stops for local legacy sessions atomically commit the request event, stopped event, terminal state, and finalization marker; cron and targeted access repair older `stopping` rows left by interrupted deployments. +Session events are mirrored into the `SESSION_LOGS` R2 binding when configured. Crabfleet writes NDJSON, Markdown transcript, and summary objects under `orgs/openclaw/interactive-sessions//`, while D1 keeps the compact event list and archive keys for the app, CLI, and SSH gateway. If the binding is enabled after D1-only terminal archives were finalized, cron and targeted reconciliation requeue their null-key snapshots and backfill the objects before cleanup. Cleanup transactionally removes the finalized D1 session, events, and archive pointers before best-effort R2 deletion, so a partial object-delete failure is an unreferenced leak rather than a dangling archive reference. Live session lifecycle operations support only built-in Sandbox, the versioned runtime adapter, and GitHub Actions relay sessions. -Sandbox credential policies have a separate durable cleanup lifecycle. Registration commits a generation and expiring claim in D1 before any external POST. If the Durable Object accepted every alias before the Worker crashed, reconciliation verifies that matching generation and the exact live owner, clears the expired D1 claim, and promotes the group to active before cleanup scanning; transient lookup or ownership failures defer cleanup. The upgrade migration seeds active legacy policies for proactive repair: cron claims each exact live lease, atomically generation-wraps every retained raw Durable Object policy, and activates all lookup aliases. A raw lookup also runs this fenced repair synchronously and retries once, avoiding a credential gap before the first cron pass. A crash before D1 completion leaves an expiring repair claim; the next pass resumes the same generation idempotently, while stop can still stage cleanup. Raw records remain unserved but retained until this repair or authorized cleanup. Credential injection rechecks that complete active generation and its exact D1 owner, so raw legacy Durable Object records, expired standalone policies, and orphaned generations fail closed. A registration error for an expected live current lease clears into a retryable registration state; an owner transition instead stages that generation for cleanup. Stop, expiry, provisioning failure, and superseded-resource cleanup atomically pair the durable owner transition with policy staging, revoke the session agent token and terminal control, terminate standalone terminal execution sessions, wait out live registration claims, and revalidate that no live owner still expects the Sandbox before persisting a matching generation tombstone; this makes both lost owner CAS operations and late POSTs harmless across Worker termination. Bounded persisted scan/group cursors keep large cleanup backlogs fair. Failed or partial deletes remain `stopping` and retry from cron until every recorded policy lookup is gone, then enter normal terminal archive finalization with the original failure reason intact. A standalone terminal-destruction failure is recorded on that owner and retried without blocking other cleanup owners, runtime-adapter reconciliation, or terminal archives. +Sandbox credential policies have a separate durable cleanup lifecycle. Registration commits a generation and expiring claim in D1 before posting the exact owner-bound policy to `SessionControlDO`. Reconciliation promotes a complete accepted generation to active only while the matching Sandbox owner remains current; transient lookup or ownership failures defer cleanup. Credential injection requires that complete active generation and exact D1 owner, so expired standalone policies and orphaned generations fail closed. A registration error for an expected live lease clears into a retryable registration state; an owner transition instead stages the generation for cleanup. Stop, expiry, provisioning failure, and superseded-resource cleanup atomically pair the durable owner transition with policy staging, revoke the session agent token and terminal control, terminate standalone terminal execution sessions, wait out live registration claims, and revalidate that no live owner still expects the Sandbox before persisting a matching generation tombstone. Bounded persisted scan/group cursors keep large cleanup backlogs fair. Failed or partial deletes remain `stopping` and retry from cron until every recorded policy lookup is gone, then enter normal terminal archive finalization with the original failure reason intact. A standalone terminal-destruction failure is recorded on that owner and retried without blocking other cleanup owners, runtime-adapter reconciliation, or terminal archives. -Managed session creation first uses the built-in Sandbox when `runtime=container` and the `SANDBOX` binding is available. Otherwise a configured versioned adapter owns the durable lifecycle; a legacy create-only provision URL remains available for compatibility. If no usable path exists, the session stays `pending_adapter` and remains visible in the Ghostty grid. +Managed session creation first uses the built-in Sandbox when `runtime=container` and the `SANDBOX` binding is available. Otherwise a configured versioned adapter owns the durable lifecycle. If neither supported path exists, the session stays `pending_adapter` and remains visible in the Ghostty grid. -Crabfleet also ships a stateless provision hook at `/api/provision/interactive`. The OpenClaw deployment points `CRABBOX_INTERACTIVE_PROVISION_URL` at this in-process route. `CRABBOX_INTERACTIVE_PROVISION_TOKEN` is required when a backend is configured. Direct standalone Sandboxes reject the reserved `IS-` namespace, expire after the bounded `CRABBOX_STANDALONE_SANDBOX_TTL_SECONDS`, and stop through `/api/provision/interactive/:id/stop`. The hook can delegate to a legacy generic runtime backend, a Cloudflare runner, or a ClawFleet compatibility backend; versioned lifecycle workspaces are deliberately created through the managed session API instead. +Crabfleet also ships a built-in Sandbox provision hook at `/api/provision/interactive`. Every provision, PTY, and stop request requires `CRABBOX_INTERACTIVE_PROVISION_TOKEN`. Direct standalone Sandboxes reject the reserved `IS-` namespace, expire after the bounded `CRABBOX_STANDALONE_SANDBOX_TTL_SECONDS`, and stop through `/api/provision/interactive/:id/stop`. The hook accepts only `runtime=container`; external workspaces are deliberately created through the managed versioned lifecycle instead. -Cloudflare runner configuration: - -- `CRABBOX_CLOUDFLARE_RUNNER_URL`: Crabbox Cloudflare container runner base URL. -- `CRABBOX_CLOUDFLARE_RUNNER_TOKEN`: runner bearer token. -- `CRABBOX_CLOUDFLARE_RUNNER_INSTANCE_TYPE`: `lite`, `basic`, `standard-1`, `standard-2`, `standard-3`, or `standard-4`; default `standard-4`. -- `CRABBOX_CLOUDFLARE_RUNNER_WORKDIR`: base workspace path; default `/workspace/crabbox`. -- `CRABBOX_CLOUDFLARE_RUNNER_TTL_SECONDS`: default `14400`. -- `CRABBOX_CLOUDFLARE_RUNNER_IDLE_SECONDS`: default `1800`. -- `CRABBOX_PTY_BRIDGE_URL`: optional explicit PTY bridge WebSocket URL/template. Templates support `{id}`, `{leaseId}`, `{repo}`, `{branch}`, and `{runtime}`. -- `CRABBOX_PTY_BRIDGE_TOKEN`: optional bearer token sent only from Crabfleet to the bridge. - -Runner PTY contract: +Terminal contract: - Crabfleet accepts browser, CLI, agent, and SSH gateway WebSockets on `/api/terminal/ws` and multiplexes one or more subscribed sessions. -- Crabfleet connects upstream to the configured bridge with `Upgrade: websocket`. - Browser-to-Crabfleet messages use binary terminal frames for subscribe, input, resize, and stop. -- Runner-to-browser output is wrapped in terminal output frames with session IDs. -- The bridge receives `x-crabbox-session`, `x-crabbox-repo`, and `x-crabbox-runtime` headers plus session query parameters. +- Upstream output is wrapped in terminal output frames with session IDs. +- Crabfleet resolves each subscription to the built-in Sandbox, a versioned adapter terminal, or the GitHub Actions relay. GitHub Actions PTY contract: diff --git a/docs/spec-v2.md b/docs/spec-v2.md index 8d34367..c5bf2b9 100644 --- a/docs/spec-v2.md +++ b/docs/spec-v2.md @@ -119,7 +119,7 @@ Per-session summary: - `policy.hasGithubToken` - `policy.openAIBaseUrlHost` -`attachable` is true only for terminal-capable `ready`, `attached`, or `detached` sessions with current control and a resolvable Sandbox, configured bridge, valid WSS/literal-loopback WS URL, or configured Cloudflare runner route. +`attachable` is true only for terminal-capable `ready`, `attached`, or `detached` sessions with current control and a resolvable Sandbox or valid WSS/literal-loopback WS provider URL. ## Security diff --git a/docs/spec.md b/docs/spec.md index 25f3ae8..c8ef6e9 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -11,7 +11,7 @@ Status: living specification of the deployed product. Canonical OpenClaw app/API: -Public product/docs hosts: and +Public product/docs host: SSH onboarding and CLI gateway: `crabd.sh` @@ -161,17 +161,9 @@ Required properties: Ambiguous creates are replayed only with the original payload and key. A `workspace_id_conflict` proves non-ownership and never causes Crabfleet to adopt or delete the pre-existing workspace. -### Legacy Backends +### Provision Hook -Compatibility paths remain for: - -- create-only `CRABBOX_INTERACTIVE_PROVISION_URL`; -- `CRABBOX_RUNTIME_PROVISION_URL`; -- `CRABBOX_CLOUDFLARE_RUNNER_URL`; -- `CRABBOX_CLAWFLEET_URL`; -- explicit `CRABBOX_PTY_BRIDGE_URL`. - -These paths do not gain lifecycle guarantees they do not implement. Legacy sessions may stop only inside Crabfleet when the backend has no delete/release contract. +`POST /api/provision/interactive` provisions only built-in Cloudflare Sandbox workspaces. External workspaces use the versioned runtime-adapter lifecycle through managed interactive sessions. ### GitHub Actions @@ -208,7 +200,7 @@ Terminal attachability requires: - active lifecycle status; - current `terminal` capability; -- a resolvable built-in Sandbox, bridge, runner, or provider terminal; +- a resolvable built-in Sandbox or provider terminal; - current viewer authorization and control state. `ptyAvailable` is the Worker-authoritative result. Raw provider terminal credentials are never returned to clients. @@ -219,7 +211,7 @@ Scheduled reconciliation and foreground reads advance bounded lifecycle batches. ## Terminal And Control -The browser, CLI, session agents, and SSH gateway use one multiplex WebSocket protocol at `/api/terminal/ws`. Frames support subscribe, unsubscribe, input, resize, stop, ping, output, events, errors, control revocation, acknowledgements, and pong. +The browser, CLI, session agents, and SSH gateway use one multiplex WebSocket protocol at `/api/terminal/ws`. Wire version 2 is the only accepted version. Frames support subscribe, unsubscribe, input, resize, stop, ping, output, events, errors, control revocation, acknowledgements, and pong. Control rules: @@ -244,7 +236,7 @@ For versioned adapters, Crabfleet: 4. re-reads lifecycle, capability, control, and adapter identity; 5. redirects with `cache-control: no-store`. -The signed provider URL is not stored in D1 or returned through Fleet. Legacy backends may retain validated persisted VNC URLs. +The signed provider URL is not stored in D1 or returned through Fleet. ## Sharing And Supervision @@ -285,7 +277,7 @@ D1 is the source of truth for current app state. - Sandbox credential-policy registry; - checkpoint registry. -The Worker owns the general multiplex terminal hub and connects each subscription to its Sandbox, bridge, runner, or adapter backend. There is no `BoardDO` or `RunDO`. Board and Fleet state use D1 plus REST polling. The browser refreshes general state every 15 seconds; terminal bytes use WebSockets. +The Worker owns the general multiplex terminal hub and connects each subscription to its Sandbox, adapter, or GitHub Actions backend. There is no `BoardDO` or `RunDO`. Board and Fleet state use D1 plus REST polling. The browser refreshes general state every 15 seconds; terminal bytes use WebSockets. ### R2 @@ -355,7 +347,7 @@ The Worker serves a server-rendered TypeScript app shell with D1-backed REST sta The `crabfleet` CLI supports: - SSH linking and session creation; -- session listing/status/attach/stop/delete; +- session listing/status/attach/delete; - logs and transcripts; - supervision tree, message, and summary operations; - diagnostics; @@ -384,7 +376,7 @@ The repository contains: OpenClaw production uses: - canonical app/API/OAuth host `crabfleet.openclaw.ai`; -- public docs/product redirects on `crabfleet.ai` and aliases; +- public docs/product redirects on `crabfleet.ai`; - built-in Sandbox for container sessions; - the versioned Crabbox adapter with namespace `openclaw`; - D1, R2, and `SessionControlDO`; diff --git a/internal/fleetapi/client.go b/internal/fleetapi/client.go index bee2e49..224c72e 100644 --- a/internal/fleetapi/client.go +++ b/internal/fleetapi/client.go @@ -14,6 +14,8 @@ import ( "github.com/openclaw/crabfleet/internal/terminalws" ) +type TerminalSize = terminalws.Size + const maxResponseBytes = 4 * 1024 * 1024 const maxErrorBytes = 512 @@ -191,13 +193,14 @@ func (c *Client) Attach( terminal io.ReadWriter, cols uint32, rows uint32, + resizes <-chan TerminalSize, ) error { client, err := c.terminal(ctx, id, cols, rows) if err != nil { return err } defer client.Close() - return client.Attach(ctx, terminal) + return client.Attach(ctx, terminal, resizes) } func (c *Client) terminal(ctx context.Context, id string, cols uint32, rows uint32) (*terminalws.Client, error) { diff --git a/internal/fleetapi/types.go b/internal/fleetapi/types.go index a06f836..f5e48ca 100644 --- a/internal/fleetapi/types.go +++ b/internal/fleetapi/types.go @@ -121,12 +121,6 @@ func (s Session) LifecycleStopNote() string { if s.Runtime == "github_actions" && s.Status == "stopped" { return "GitHub Actions workflow run was not canceled and may continue on GitHub" } - if s.Adapter != "runtime-v1" && s.Runtime != "github_actions" { - switch s.Status { - case "stopping", "stopped", "expired": - return "provider deletion was not confirmed; legacy runtimes may require separate cleanup" - } - } return "" } diff --git a/internal/fleetapi/types_test.go b/internal/fleetapi/types_test.go index dd04248..2dc995b 100644 --- a/internal/fleetapi/types_test.go +++ b/internal/fleetapi/types_test.go @@ -25,12 +25,11 @@ func TestSessionAttachableRequiresAuthoritativePTYAvailability(t *testing.T) { } func TestSessionLifecycleStopNote(t *testing.T) { - if got := (Session{Status: "stopped"}).LifecycleStopNote(); !strings.Contains(got, "provider deletion") { - t.Fatalf("legacy stop note = %q", got) - } for _, session := range []Session{ {Status: "failed"}, - {Status: "stopped", Adapter: "runtime-v1"}, + {Status: "stopping"}, + {Status: "stopped"}, + {Status: "expired"}, } { if got := session.LifecycleStopNote(); got != "" { t.Fatalf("session %#v note = %q", session, got) diff --git a/internal/terminalws/client.go b/internal/terminalws/client.go index 4a04fe1..df63ffe 100644 --- a/internal/terminalws/client.go +++ b/internal/terminalws/client.go @@ -16,20 +16,31 @@ import ( const ( magic = 0x5943 - version = 1 + version = 2 maxFrameBytes = 16 * 1024 * 1024 - messageHello = 1 - messageWelcome = 2 - messageSubscribe = 10 - messageOutput = 20 - messageEvent = 22 - messageError = 23 - messageInput = 30 - messageControlRevoked = 53 - messageAck = 62 + messageHello = 1 + messageWelcome = 2 + messageSubscribe = 10 + messageUnsubscribe = 11 + messageOutput = 20 + messageSnapshot = 21 + messageEvent = 22 + messageError = 23 + messageInput = 30 + messageKey = 31 + messageResize = 32 + messageStop = 33 + messageControlRequest = 50 + messageControlDecision = 51 + messageControlGranted = 52 + messageControlRevoked = 53 + messagePing = 60 + messagePong = 61 + messageAck = 62 subscribeOutput = 1 << 0 + subscribeSnapshot = 1 << 1 subscribeEvents = 1 << 2 subscribeOutputAcknowledgements = 1 << 3 ) @@ -40,6 +51,11 @@ type Options struct { Rows uint32 } +type Size struct { + Cols uint32 + Rows uint32 +} + type Client struct { conn *websocket.Conn sessionID string @@ -152,11 +168,22 @@ func (c *Client) SendInput(ctx context.Context, payload []byte) error { }) } -func (c *Client) Attach(ctx context.Context, terminal io.ReadWriter) error { +func (c *Client) Resize(ctx context.Context, size Size) error { + if size.Cols == 0 || size.Rows == 0 { + return nil + } + return c.write(ctx, frame{ + messageType: messageResize, + sessionID: c.sessionID, + payload: resizePayload(size), + }) +} + +func (c *Client) Attach(ctx context.Context, terminal io.ReadWriter, resizes <-chan Size) error { ctx, cancel := context.WithCancel(ctx) defer cancel() - errCh := make(chan error, 2) + errCh := make(chan error, 3) go func() { buffer := make([]byte, 32*1024) for { @@ -177,6 +204,23 @@ func (c *Client) Attach(ctx context.Context, terminal io.ReadWriter) error { } } }() + go func() { + for { + select { + case <-ctx.Done(): + return + case size, ok := <-resizes: + if !ok { + resizes = nil + continue + } + if err := c.Resize(ctx, size); err != nil { + errCh <- err + return + } + } + } + }() go func() { for { current, err := c.read(ctx) @@ -285,6 +329,13 @@ func subscribePayload(cols uint32, rows uint32) []byte { return payload } +func resizePayload(size Size) []byte { + payload := make([]byte, 8) + binary.LittleEndian.PutUint32(payload[0:4], size.Cols) + binary.LittleEndian.PutUint32(payload[4:8], size.Rows) + return payload +} + func ackPayload(bytes uint32) []byte { payload := make([]byte, 4) binary.LittleEndian.PutUint32(payload, bytes) diff --git a/internal/terminalws/client_test.go b/internal/terminalws/client_test.go index 10f6dd3..9621155 100644 --- a/internal/terminalws/client_test.go +++ b/internal/terminalws/client_test.go @@ -4,15 +4,110 @@ import ( "bytes" "context" "encoding/binary" + "encoding/hex" "encoding/json" "io" "net/http" "net/http/httptest" + "os" "testing" "github.com/coder/websocket" ) +type protocolFixture struct { + Magic uint16 `json:"magic"` + Version byte `json:"version"` + Messages map[string]byte `json:"messages"` + SubscribeFlags map[string]uint32 `json:"subscribeFlags"` + Vectors struct { + OutputFrame string `json:"outputFrame"` + PingFrame string `json:"pingFrame"` + Subscribe string `json:"subscribe"` + Resize string `json:"resize"` + Ack string `json:"ack"` + } `json:"vectors"` +} + +func TestGoTerminalConstantsAndEncodersMatchSharedV2Protocol(t *testing.T) { + data, err := os.ReadFile("../../protocol/terminal-v2.json") + if err != nil { + t.Fatal(err) + } + var fixture protocolFixture + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatal(err) + } + if magic != fixture.Magic || version != fixture.Version { + t.Fatalf("protocol identity = %#x/%d", magic, version) + } + messages := map[string]byte{ + "Hello": messageHello, + "Welcome": messageWelcome, + "Subscribe": messageSubscribe, + "Unsubscribe": messageUnsubscribe, + "Output": messageOutput, + "Snapshot": messageSnapshot, + "Event": messageEvent, + "Error": messageError, + "Input": messageInput, + "Key": messageKey, + "Resize": messageResize, + "Stop": messageStop, + "ControlRequest": messageControlRequest, + "ControlDecision": messageControlDecision, + "ControlGranted": messageControlGranted, + "ControlRevoked": messageControlRevoked, + "Ping": messagePing, + "Pong": messagePong, + "Ack": messageAck, + } + if !mapsEqual(messages, fixture.Messages) { + t.Fatalf("messages = %#v", messages) + } + flags := map[string]uint32{ + "Output": subscribeOutput, + "Snapshot": subscribeSnapshot, + "Events": subscribeEvents, + "OutputAcknowledgements": subscribeOutputAcknowledgements, + } + if !mapsEqual(flags, fixture.SubscribeFlags) { + t.Fatalf("subscribe flags = %#v", flags) + } + output := encodeFrame(frame{ + messageType: messageOutput, + sessionID: "IS-123", + payload: []byte{0, 1, 2, 255}, + }) + if got := hex.EncodeToString(output); got != fixture.Vectors.OutputFrame { + t.Fatalf("output frame = %q", got) + } + if got := hex.EncodeToString(encodeFrame(frame{messageType: messagePing})); got != fixture.Vectors.PingFrame { + t.Fatalf("ping frame = %q", got) + } + if got := hex.EncodeToString(subscribePayload(144, 41)); got != fixture.Vectors.Subscribe { + t.Fatalf("subscribe payload = %q", got) + } + if got := hex.EncodeToString(resizePayload(Size{Cols: 132, Rows: 43})); got != fixture.Vectors.Resize { + t.Fatalf("resize payload = %q", got) + } + if got := hex.EncodeToString(ackPayload(65_535)); got != fixture.Vectors.Ack { + t.Fatalf("ack payload = %q", got) + } +} + +func mapsEqual[K comparable, V comparable](left map[K]V, right map[K]V) bool { + if len(left) != len(right) { + return false + } + for key, value := range left { + if right[key] != value { + return false + } + } + return true +} + func TestEndpointUsesTerminalHub(t *testing.T) { got, err := Endpoint("https://fleet.example/base?ignored=1") if err != nil { @@ -26,6 +121,7 @@ func TestEndpointUsesTerminalHub(t *testing.T) { func TestClientSubscribesSendsInputAndAcknowledgesOutput(t *testing.T) { receivedInput := make(chan []byte, 1) acknowledged := make(chan uint32, 1) + receivedResize := make(chan Size, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/terminal/ws" { t.Errorf("path = %q", r.URL.Path) @@ -82,17 +178,30 @@ func TestClientSubscribesSendsInputAndAcknowledgesOutput(t *testing.T) { return } - _, inputPayload, err := conn.Read(r.Context()) - if err != nil { - t.Error(err) - return - } - input, err := decodeFrame(inputPayload) - if err != nil { - t.Error(err) - return + for range 2 { + _, payload, err := conn.Read(r.Context()) + if err != nil { + t.Error(err) + return + } + current, err := decodeFrame(payload) + if err != nil { + t.Error(err) + return + } + switch current.messageType { + case messageInput: + receivedInput <- append([]byte(nil), current.payload...) + case messageResize: + receivedResize <- Size{ + Cols: binary.LittleEndian.Uint32(current.payload[0:4]), + Rows: binary.LittleEndian.Uint32(current.payload[4:8]), + } + default: + t.Errorf("unexpected message type = %d", current.messageType) + return + } } - receivedInput <- append([]byte(nil), input.payload...) if err := conn.Write(r.Context(), websocket.MessageBinary, encodeFrame(frame{ messageType: messageOutput, @@ -144,7 +253,9 @@ func TestClientSubscribesSendsInputAndAcknowledgesOutput(t *testing.T) { go func() { _, _ = inputWriter.Write([]byte("hello\n")) }() - if err := client.Attach(context.Background(), terminal); err != nil { + 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 input := <-receivedInput; string(input) != "hello\n" { @@ -153,6 +264,9 @@ func TestClientSubscribesSendsInputAndAcknowledgesOutput(t *testing.T) { if terminal.String() != "ready\n" { t.Fatalf("output = %q", terminal.String()) } + if size := <-receivedResize; size != (Size{Cols: 132, Rows: 43}) { + t.Fatalf("resize = %#v", size) + } if bytes := <-acknowledged; bytes != uint32(len("ready\n")) { t.Fatalf("acknowledged = %d", bytes) } diff --git a/protocol/terminal-v2.json b/protocol/terminal-v2.json new file mode 100644 index 0000000..6a26fa0 --- /dev/null +++ b/protocol/terminal-v2.json @@ -0,0 +1,38 @@ +{ + "magic": 22851, + "version": 2, + "messages": { + "Hello": 1, + "Welcome": 2, + "Subscribe": 10, + "Unsubscribe": 11, + "Output": 20, + "Snapshot": 21, + "Event": 22, + "Error": 23, + "Input": 30, + "Key": 31, + "Resize": 32, + "Stop": 33, + "ControlRequest": 50, + "ControlDecision": 51, + "ControlGranted": 52, + "ControlRevoked": 53, + "Ping": 60, + "Pong": 61, + "Ack": 62 + }, + "subscribeFlags": { + "Output": 1, + "Snapshot": 2, + "Events": 4, + "OutputAcknowledgements": 8 + }, + "vectors": { + "outputFrame": "435902140600000049532d31323304000000000102ff", + "pingFrame": "4359023c0000000000000000", + "subscribe": "0d00000000000000000000009000000029000000", + "resize": "840000002b000000", + "ack": "ffff0000" + } +} diff --git a/scripts/ensure-cloudflare-domains.mjs b/scripts/ensure-cloudflare-domains.mjs index 47db254..f20608b 100644 --- a/scripts/ensure-cloudflare-domains.mjs +++ b/scripts/ensure-cloudflare-domains.mjs @@ -1,15 +1,7 @@ const token = process.env.CLOUDFLARE_DNS_API_TOKEN || process.env.CLOUDFLARE_API_TOKEN; const productOnly = process.argv.includes("--product-only"); -const appHost = "crabfleet.openclaw.ai"; const cloudflareAccountId = "91b59577e757131d68d55a471fe32aca"; const productWorkerScript = "crabfleet-canonical-router"; -// OpenClaw app hosts are Worker Custom Domains in wrangler.jsonc; this script -// only removes stale classic routes for them and keeps other aliases tidy. -const openClawCustomDomainHosts = new Set([ - appHost, - "clawfleet.openclaw.ai", - "crabyard.openclaw.ai", -]); if (!token) { throw new Error("CLOUDFLARE_DNS_API_TOKEN or CLOUDFLARE_API_TOKEN is required"); @@ -112,17 +104,6 @@ async function ensureCrabfleetDocsRecord() { } } -async function removeOpenClawClassicRoutes() { - const openclaw = await zone("openclaw.ai"); - const routes = await request(`/zones/${openclaw.id}/workers/routes`); - for (const route of routes.filter((entry) => - openClawCustomDomainHosts.has(entry.pattern.replace(/\/\*$/, "")), - )) { - await request(`/zones/${openclaw.id}/workers/routes/${route.id}`, { method: "DELETE" }); - console.log(`deleted stale ${route.pattern} classic route ${route.id}`); - } -} - async function ensureCrabdSshRecord() { const crabd = await zone("crabd.sh"); const records = await request( @@ -171,10 +152,7 @@ async function ensureCrabdSshRecord() { } } -if (!productOnly) { - await removeOpenClawClassicRoutes(); -} -await ensureProductHosts("crabfleet.ai", ["crabfleet.ai", "www.crabfleet.ai"]); +await ensureProductHosts("crabfleet.ai", ["crabfleet.ai"]); await ensureCrabfleetDocsRecord(); if (!productOnly) { await ensureCrabdSshRecord(); diff --git a/scripts/generate-assets.mjs b/scripts/generate-assets.mjs index 64bb814..4791648 100644 --- a/scripts/generate-assets.mjs +++ b/scripts/generate-assets.mjs @@ -13,7 +13,10 @@ const specV2Path = new URL("../docs/spec-v2.md", import.meta.url); const logoPath = new URL("../src/assets/crabbox-logo.png", import.meta.url); const ogImagePath = new URL("../src/assets/crabfleet-og.png", import.meta.url); const ghosttyWebPath = new URL("../node_modules/ghostty-web/dist/ghostty-web.js", import.meta.url); -const ghosttyWasmPath = new URL("../node_modules/ghostty-web/dist/ghostty-vt.wasm", import.meta.url); +const ghosttyWasmPath = new URL( + "../node_modules/ghostty-web/dist/ghostty-vt.wasm", + import.meta.url, +); const ghosttyExternalPath = new URL( "../node_modules/ghostty-web/dist/__vite-browser-external-2447137e.js", import.meta.url, diff --git a/src/app/admin-drawer.jsx b/src/app/admin-drawer.jsx new file mode 100644 index 0000000..eee9009 --- /dev/null +++ b/src/app/admin-drawer.jsx @@ -0,0 +1,238 @@ +import { useEffect, useState } from "preact/hooks"; +import { normalizeAdminPolicy } from "./admin-state.js"; +import { Icon } from "./components.jsx"; +import { Drawer } from "./dialogs.jsx"; +import { preferredRepo } from "./utils.js"; + +export function AdminDrawer(props) { + const owner = props.state.user?.role === "owner"; + return ( + props.closeDrawer("admin")} + > +
+ ({ + label: `${item.value} - ${item.role}`, + value: item.value, + }))} + onAdd={(value, role) => props.addAllow(value, role)} + onRemove={props.removeAllow} + /> + ({ label: repo, value: repo }))} + onAdd={props.addRepo} + onRemove={props.removeRepo} + /> + + +
+
+ ); +} + +function AdminList({ title, placeholder, disabled, select, rows, onAdd, onRemove }) { + const [value, setValue] = useState(""); + const [role, setRole] = useState(select?.values?.[0]?.[0] || ""); + return ( +
+

{title}

+
+ setValue(event.currentTarget.value)} + /> + {select ? ( + + ) : null} + +
+
+ {rows.map((row) => ( +
+ {row.label} + +
+ ))} +
+
+ ); +} + +function PolicyBox({ disabled, state, updatePolicy }) { + const [cap, setCap] = useState(state.cap); + const [merge, setMerge] = useState(state.merge); + const [retention, setRetention] = useState(state.retention); + useEffect(() => { + setCap(state.cap); + setMerge(state.merge); + setRetention(state.retention); + }, [state.cap, state.merge, state.retention]); + return ( +
+

Policy

+ + + + +
+ + Secrets: per org, referenced only + + + VNC: Crabbox leases only + +
+
+ ); +} + +function WorkflowBox({ disabled, workflows, refreshWorkflow, preferred = preferredRepo }) { + const [repo, setRepo] = useState(preferred); + useEffect(() => setRepo(preferred), [preferred]); + return ( +
+

Workflows

+
+ setRepo(event.currentTarget.value)} + /> + +
+
+ {workflows.length ? ( + workflows.map((workflow) => { + const config = workflow.config || {}; + const detail = [ + config.runtime ? `runtime=${config.runtime}` : "", + config.policy ? `policy=${config.policy}` : "", + workflow.error || "", + ] + .filter(Boolean) + .join(" "); + return ( +
+ + {workflow.repo} - {workflow.status} + {detail ? ( + <> +
+ {detail} + + ) : null} +
+
+ ); + }) + ) : ( +
No workflow evaluations
+ )} +
+
+ ); +} diff --git a/src/app/admin-state.js b/src/app/admin-state.js new file mode 100644 index 0000000..850f7bb --- /dev/null +++ b/src/app/admin-state.js @@ -0,0 +1,8 @@ +export function normalizeAdminPolicy({ cap, retention, merge }) { + const rawCap = Number(cap); + return { + cap: Number.isFinite(rawCap) ? Math.min(200, Math.max(1, rawCap)) : 20, + retention, + merge, + }; +} diff --git a/src/app/app-data.js b/src/app/app-data.js new file mode 100644 index 0000000..69f2444 --- /dev/null +++ b/src/app/app-data.js @@ -0,0 +1,479 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import { api } from "./api.js"; +import { isGithubLoginCallback, loginReturnKey } from "./routing.js"; +import { linkedInteractiveSessionPlaceholder, preferredRepo } from "./utils.js"; + +export const defaultDeployment = { + label: "Crabfleet", + canonicalUrl: "https://crabfleet.openclaw.ai", + productUrl: "https://crabfleet.ai", + sshHost: "crabd.sh", + preferredRepo, + defaultRuntime: "container", + interactiveRuntimes: ["container", "crabbox"], + defaultProfile: "default", + runtimeProfiles: [], +}; + +export const defaultAuthMethods = { + github: false, + token: false, + devIdentity: false, + trustedProxy: false, +}; + +export const emptyState = { + cards: [], + interactiveSessions: [], + fleet: null, + allow: [], + repos: [], + workflows: [], + cap: 20, + retention: "30", + merge: "guarded", + deployment: defaultDeployment, +}; + +const skipAutoGithubLoginKey = "crabbox-skip-auto-github-login"; +const githubAutoLoginReadyKey = "crabbox-github-auto-login-ready"; + +export function initialAppState(initialSessionLink) { + if (!initialSessionLink.id) return emptyState; + return { + ...emptyState, + interactiveSessions: [ + linkedInteractiveSessionPlaceholder(initialSessionLink.id, { + sharedReadOnly: Boolean(initialSessionLink.token), + }), + ], + }; +} + +export function retainLinkedSession(nextState, linkedSession) { + if ( + !linkedSession || + (nextState.interactiveSessions || []).some((session) => session.id === linkedSession.id) + ) { + return nextState; + } + return { + ...nextState, + interactiveSessions: [linkedSession, ...(nextState.interactiveSessions || [])], + }; +} + +export function sharedSessionState(session, auth, deployment = defaultDeployment) { + return { + user: { subject: "shared", login: "shared link", role: "viewer" }, + auth, + org: "OpenClaw", + cap: 20, + retention: "30", + merge: "guarded", + allow: [], + repos: [session.repo], + workflows: [], + cards: [], + interactiveSessions: [session], + deployment, + }; +} + +export function shouldAutoGithubLogin({ + signedIn, + started, + methods, + shared, + tokenBypass, + skipped, + ready, +}) { + if (signedIn || started || !methods?.github || methods.devIdentity) return false; + if (methods.token && tokenBypass) return false; + if (shared?.id && shared?.token) return false; + return !skipped && ready; +} + +export function createAppPolling({ + runInitial, + runInterval, + runRetry = runInitial, + timers = globalThis, + pollIntervalMs = 15000, + retryDelayMs = 5000, +}) { + let intervalId = null; + let retryId = null; + + return { + start() { + if (intervalId !== null) return; + void runInitial(); + intervalId = timers.setInterval(() => void runInterval(), pollIntervalMs); + }, + scheduleRetry() { + if (retryId !== null) return; + retryId = timers.setTimeout(() => { + retryId = null; + void runRetry(); + }, retryDelayMs); + }, + clearRetry() { + if (retryId === null) return; + timers.clearTimeout(retryId); + retryId = null; + }, + stop() { + if (intervalId !== null) timers.clearInterval(intervalId); + intervalId = null; + if (retryId !== null) timers.clearTimeout(retryId); + retryId = null; + }, + }; +} + +export function createRequestFence() { + let generation = 0; + return { + next() { + generation += 1; + return generation; + }, + isCurrent(candidate) { + return candidate === generation; + }, + }; +} + +export function useAppData({ + initialSessionLink, + activeRunId, + runDrawerOpen, + sharedSessionId, + sharedToken, + onSignedOut, + onSharedSessionLoaded, + onSharedSessionRejected, +}) { + const [state, setState] = useState(() => initialAppState(initialSessionLink)); + const [signedIn, setSignedIn] = useState(false); + const [authMethods, setAuthMethods] = useState(defaultAuthMethods); + const [loginMessage, setLoginMessage] = useState(""); + const stateRef = useRef(state); + const signedInRef = useRef(signedIn); + const authMethodsRef = useRef(authMethods); + const activeRunRef = useRef({ id: activeRunId, open: runDrawerOpen }); + const sharedRef = useRef({ id: sharedSessionId, token: sharedToken }); + const callbacksRef = useRef({ + onSignedOut, + onSharedSessionLoaded, + onSharedSessionRejected, + }); + const mountedRef = useRef(false); + const autoLoginStarted = useRef(false); + const githubLoginCallback = useRef(isGithubLoginCallback()); + const stateRequestRef = useRef(null); + const stateRequestFence = useRef(createRequestFence()); + const sharedRequestRef = useRef(null); + const loadStateRef = useRef(null); + const loadSharedSessionRef = useRef(null); + const pollingRef = useRef(null); + + stateRef.current = state; + signedInRef.current = signedIn; + authMethodsRef.current = authMethods; + activeRunRef.current = { id: activeRunId, open: runDrawerOpen }; + sharedRef.current = { id: sharedSessionId, token: sharedToken }; + callbacksRef.current = { + onSignedOut, + onSharedSessionLoaded, + onSharedSessionRejected, + }; + + if (!pollingRef.current) { + pollingRef.current = createAppPolling({ + runInitial: () => loadStateRef.current?.(), + runInterval: () => { + if (signedInRef.current) return loadStateRef.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); + }); + }, + runRetry: () => loadStateRef.current?.(), + }); + } + + useEffect(() => { + mountedRef.current = true; + pollingRef.current.start(); + return () => { + mountedRef.current = false; + pollingRef.current.stop(); + }; + }, []); + + useEffect(() => { + if (!signedIn && !loginMessage) void maybeAutoGithubLogin(authMethods); + }, [signedIn, loginMessage, authMethods, sharedSessionId, sharedToken]); + + async function performLoadState(generation) { + try { + let nextState = await api("/api/state", { authOptional: true }); + if (!isCurrentStateRequest(generation)) return; + const linkedSessionId = sharedRef.current.id; + const linkedSession = linkedSessionId + ? (stateRef.current.interactiveSessions || []).find( + (session) => session.id === linkedSessionId, + ) + : null; + nextState = retainLinkedSession(nextState, linkedSession); + const activeRun = activeRunRef.current; + const activeCard = nextState.cards.find((card) => card.id === activeRun.id); + if (activeRun.id && activeRun.open && activeCard?.changes?.files?.length) { + const result = await api(`/api/cards/${encodeURIComponent(activeRun.id)}/actions`, { + method: "POST", + body: { action: "attach" }, + }); + if (!isCurrentStateRequest(generation)) return; + nextState.cards = nextState.cards.map((card) => + card.id === result.card.id ? result.card : card, + ); + } + pollingRef.current.clearRetry(); + setAuthMethods(nextState.auth || authMethodsRef.current); + setState(nextState); + setSignedIn(true); + setLoginMessage(""); + finishGithubLoginCallback(true); + } catch (error) { + if (!isCurrentStateRequest(generation)) return; + if (error.status === 401 || error.status === 403) { + const shared = sharedRef.current; + if (shared.id && shared.token) { + try { + await loadSharedSession(); + } catch (sharedError) { + await showSharedLinkError(sharedError); + } + return; + } + 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; + } + setLoginMessage(error.message); + pollingRef.current.scheduleRetry(); + } + } + + function loadState({ fresh = false } = {}) { + if (!fresh && stateRequestRef.current) return stateRequestRef.current.request; + const generation = stateRequestFence.current.next(); + const request = performLoadState(generation).finally(() => { + if (stateRequestRef.current?.request === request) stateRequestRef.current = null; + }); + stateRequestRef.current = { generation, request }; + return request; + } + + function refreshState() { + return loadState({ fresh: true }); + } + + function isCurrentStateRequest(generation) { + return mountedRef.current && stateRequestFence.current.isCurrent(generation); + } + + async function performLoadSharedSession(shared) { + let result; + try { + result = await api( + `/api/shared-sessions/${encodeURIComponent(shared.id)}?token=${encodeURIComponent(shared.token)}`, + { authOptional: true }, + ); + } catch (error) { + if (!sameSharedLink(sharedRef.current, shared)) 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; + } + + function loadSharedSession() { + const shared = { ...sharedRef.current }; + const key = sharedLinkKey(shared); + if (sharedRequestRef.current?.key === key) return sharedRequestRef.current.request; + const request = performLoadSharedSession(shared).finally(() => { + if (sharedRequestRef.current?.request === request) sharedRequestRef.current = null; + }); + sharedRequestRef.current = { key, request }; + return request; + } + + async function showSharedLinkError(error) { + await loadAuthMethods(); + if (!mountedRef.current) return; + callbacksRef.current.onSharedSessionRejected?.(); + setSignedIn(false); + setLoginMessage( + error?.status === 404 + ? "Shared session link is invalid or expired." + : error?.message || "Shared session could not be loaded.", + ); + } + + async function loadAuthMethods() { + try { + const result = await api("/api/auth", { authOptional: true }); + const methods = result.auth || authMethodsRef.current; + if (!mountedRef.current) return methods; + if (result.deployment) { + setState((current) => ({ ...current, deployment: result.deployment })); + } + setAuthMethods(methods); + return methods; + } catch { + const methods = { github: false, token: true, devIdentity: false, trustedProxy: false }; + if (mountedRef.current) setAuthMethods(methods); + return methods; + } + } + + async function beginLogin() { + try { + sessionStorage.removeItem(skipAutoGithubLoginKey); + } catch {} + preserveLoginReturnUrl(); + let methods = authMethodsRef.current; + if (!methods.github && !methods.token) methods = await loadAuthMethods(); + if (methods.github) { + location.href = "/login/github"; + return; + } + setLoginMessage("Sign in to request terminal control."); + } + + async function tokenLogin(token) { + try { + await api("/api/login/token", { method: "POST", body: { token }, authOptional: true }); + await refreshState(); + } catch (error) { + if (mountedRef.current) setLoginMessage(String(error.message || error)); + } + } + + async function devIdentityLogin(identity) { + try { + await api("/api/login/dev", { + method: "POST", + body: identity, + authOptional: true, + }); + await refreshState(); + } catch (error) { + if (mountedRef.current) setLoginMessage(String(error.message || error)); + } + } + + async function logout() { + try { + sessionStorage.setItem(skipAutoGithubLoginKey, "1"); + localStorage.removeItem(githubAutoLoginReadyKey); + } catch {} + autoLoginStarted.current = false; + await api("/api/logout", { method: "POST", authOptional: true }); + await refreshState(); + } + + async function maybeAutoGithubLogin(methods = authMethodsRef.current) { + let skipped; + let ready; + try { + skipped = sessionStorage.getItem(skipAutoGithubLoginKey) === "1"; + ready = localStorage.getItem(githubAutoLoginReadyKey) === "1"; + } catch { + return false; + } + const shouldStart = shouldAutoGithubLogin({ + signedIn: signedInRef.current, + started: autoLoginStarted.current, + methods, + shared: sharedRef.current, + tokenBypass: new URLSearchParams(location.search).get("auth") === "token", + skipped, + ready, + }); + if (!shouldStart) return false; + autoLoginStarted.current = true; + preserveLoginReturnUrl(); + location.href = "/login/github"; + return true; + } + + function preserveLoginReturnUrl() { + try { + if (sharedRef.current.id) sessionStorage.setItem(loginReturnKey, location.href); + } catch {} + } + + function finishGithubLoginCallback(remember) { + if (!githubLoginCallback.current) return; + githubLoginCallback.current = false; + if (remember) { + try { + localStorage.setItem(githubAutoLoginReadyKey, "1"); + } catch {} + } + if (!history.replaceState) return; + const url = new URL(location.href); + if (url.searchParams.get("login") !== "github") return; + url.searchParams.delete("login"); + history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`); + } + + loadStateRef.current = loadState; + loadSharedSessionRef.current = loadSharedSession; + + return { + state, + setState, + stateRef, + signedIn, + authMethods, + loginMessage, + setLoginMessage, + loadState, + refreshState, + loadSharedSession, + beginLogin, + tokenLogin, + devIdentityLogin, + logout, + }; +} + +function sharedLinkKey(shared) { + return `${shared.id || ""}\0${shared.token || ""}`; +} + +export function sameSharedLink(current, expected) { + return sharedLinkKey(current) === sharedLinkKey(expected); +} diff --git a/src/app/app-mutations.js b/src/app/app-mutations.js new file mode 100644 index 0000000..ca60938 --- /dev/null +++ b/src/app/app-mutations.js @@ -0,0 +1,412 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import { api } from "./api.js"; +import { canCleanInteractiveSession } from "./session-state.js"; +import { disposeTerminal } from "./terminal.js"; +import { + canDeleteInteractiveWorkspace, + issueNumber, + optimisticInteractiveSession, + titleFromPrompt, +} from "./utils.js"; + +const emptyRefPreview = { + number: "", + loading: false, + matches: [], + error: "", +}; + +export function replaceCardState(state, card) { + return { + ...state, + cards: state.cards.map((item) => (item.id === card.id ? card : item)), + }; +} + +export function upsertInteractiveSessionState(state, session) { + const sessions = state.interactiveSessions || []; + return { + ...state, + interactiveSessions: sessions.some((item) => item.id === session.id) + ? sessions.map((item) => (item.id === session.id ? session : item)) + : [session, ...sessions], + }; +} + +export function removeInteractiveSessionState(state, id) { + return { + ...state, + interactiveSessions: (state.interactiveSessions || []).filter((session) => session.id !== id), + }; +} + +export function interactiveStopDialog(session, id) { + const label = session ? `${session.repo} (${session.id})` : id; + const deletesWorkspace = canDeleteInteractiveWorkspace(session); + const endsWorkflowSession = session?.runtime === "github_actions"; + return { + kind: "danger", + eyebrow: deletesWorkspace + ? "Live workspace" + : endsWorkflowSession + ? "Live workflow terminal" + : "Live session", + title: deletesWorkspace + ? "Delete Crabbox workspace?" + : endsWorkflowSession + ? "End GitHub Actions terminal session?" + : "Stop Crabbox session?", + description: deletesWorkspace + ? "This releases the runtime workspace and cannot be undone. Its final status and logs stay visible in Crabfleet." + : endsWorkflowSession + ? "This ends the Crabfleet terminal session and disconnects it. It does not cancel the GitHub Actions workflow run, which may continue on GitHub. Final Crabfleet logs stay visible." + : "This stops Crabfleet access and releases the managed Sandbox resources. Final status and logs stay visible in Crabfleet.", + subject: label, + confirmLabel: deletesWorkspace + ? "Delete workspace" + : endsWorkflowSession + ? "End session" + : "Stop session", + }; +} + +export function interactiveShareDialog(shareUrl) { + return { + kind: "share", + eyebrow: "Read-only access", + title: "Share link ready", + description: "Copy this read-only link to share the session.", + value: shareUrl, + }; +} + +export async function presentInteractiveShareLink(id, interactiveSessionAction, openActionDialog) { + const result = await interactiveSessionAction(id, "share_link"); + if (result.shareUrl) openActionDialog(interactiveShareDialog(result.shareUrl)); + return result; +} + +export function useAppMutations({ + state, + setState, + stateRef, + refreshState, + setLoginMessage, + openActionDialog, + closeDrawer, + openDrawer, + setActiveRunId, + focusedSessionIdRef, + setFocusedSessionId, + sharedToken, + setSessionUrl, + openSessionGrid, +}) { + const [search, setSearchState] = useState(""); + const [refPreview, setRefPreview] = useState(emptyRefPreview); + const refPreviewTimer = useRef(null); + const refPreviewSeq = useRef(0); + + useEffect( + () => () => { + if (refPreviewTimer.current) clearTimeout(refPreviewTimer.current); + }, + [], + ); + + function findCard(id) { + return stateRef.current.cards.find((card) => card.id === id); + } + + function findInteractiveSession(id) { + return (stateRef.current.interactiveSessions || []).find((session) => session.id === id); + } + + function upsertCard(card) { + setState((current) => replaceCardState(current, card)); + } + + function upsertInteractiveSession(session) { + setState((current) => upsertInteractiveSessionState(current, session)); + } + + function removeInteractiveSession(id) { + setState((current) => removeInteractiveSessionState(current, id)); + } + + async function cardAction(id, action) { + const result = await api(`/api/cards/${encodeURIComponent(id)}/actions`, { + method: "POST", + body: { action }, + }); + upsertCard(result.card); + } + + async function attachCard(id) { + const result = await api(`/api/cards/${encodeURIComponent(id)}/actions`, { + method: "POST", + body: { action: "attach" }, + }); + upsertCard(result.card); + openSessionGrid(id); + } + + async function interactiveSessionAction(id, action) { + const result = await api(`/api/interactive-sessions/${encodeURIComponent(id)}/actions`, { + method: "POST", + body: { action }, + }); + upsertInteractiveSession(result.session); + if (action === "stop") return result; + openSessionGrid(id, { deepLink: true }); + return result; + } + + function deleteInteractiveSession(id) { + const session = findInteractiveSession(id); + openActionDialog({ + ...interactiveStopDialog(session, id), + action: () => interactiveSessionAction(id, "stop"), + }); + } + + async function cleanupInteractiveSessions(ids) { + const result = await api("/api/interactive-sessions/cleanup", { + method: "POST", + body: { ids }, + }); + setState(result.state); + const removed = new Set(result.removedIds || []); + if (removed.has(focusedSessionIdRef.current)) { + setFocusedSessionId(null); + if (!sharedToken) setSessionUrl(null, { grid: true }); + } + for (const id of removed) disposeTerminal(id); + return result; + } + + function cleanupInteractiveSession(id) { + const session = findInteractiveSession(id); + const label = session ? `${session.repo} (${session.id})` : id; + openActionDialog({ + kind: "danger", + eyebrow: "Dead session", + title: "Clean up Codex session?", + description: + "This permanently removes the session record, event history, and archived logs from Crabfleet.", + subject: label, + confirmLabel: "Clean up", + action: async () => { + if (session?.routePlaceholder) { + removeInteractiveSession(id); + if (focusedSessionIdRef.current === id) setFocusedSessionId(null); + if (!sharedToken) setSessionUrl(null, { grid: true }); + return; + } + await cleanupInteractiveSessions([id]); + }, + }); + } + + function cleanupDeadInteractiveSessions() { + const user = stateRef.current.user; + const ids = (stateRef.current.interactiveSessions || []) + .filter((session) => canCleanInteractiveSession(session, user)) + .map((session) => session.id); + if (!ids.length) return null; + openActionDialog({ + kind: "danger", + eyebrow: "Fleet cleanup", + title: `Clean up ${ids.length} dead Codex session${ids.length === 1 ? "" : "s"}?`, + description: + "This permanently removes their session records, event history, and archived logs from Crabfleet.", + subject: ids.length === 1 ? ids[0] : `${ids.length} stopped or failed sessions`, + confirmLabel: `Clean up ${ids.length}`, + action: () => cleanupInteractiveSessions(ids), + }); + } + + async function shareInteractiveSession(id) { + return presentInteractiveShareLink(id, interactiveSessionAction, openActionDialog); + } + + async function openRunDetails(id) { + closeDrawer("sessions"); + setActiveRunId(id); + let card = findCard(id); + if (!card) return; + try { + const result = await api(`/api/cards/${encodeURIComponent(id)}/actions`, { + method: "POST", + body: { action: "attach" }, + }); + upsertCard(result.card); + card = result.card; + } catch (error) { + setLoginMessage(error.message); + return; + } + openDrawer("run"); + } + + function setSearch(value) { + setSearchState(value); + scheduleRefPreview(value); + } + + function scheduleRefPreview(value) { + const number = issueNumber(value); + refPreviewSeq.current += 1; + if (refPreviewTimer.current) clearTimeout(refPreviewTimer.current); + if (!number) { + setRefPreview(emptyRefPreview); + return; + } + setRefPreview({ number, loading: true, matches: [], error: "" }); + const seq = refPreviewSeq.current; + refPreviewTimer.current = setTimeout(() => loadRefPreview(number, seq), 220); + } + + async function loadRefPreview(number, seq) { + try { + const result = await api(`/api/github/refs?number=${encodeURIComponent(number)}`); + if (seq !== refPreviewSeq.current) return; + setRefPreview({ number, loading: false, matches: result.matches || [], error: "" }); + } catch (error) { + if (seq !== refPreviewSeq.current) return; + setRefPreview({ + number, + loading: false, + matches: [], + error: error.message || "GitHub lookup failed", + }); + } + } + + async function createRefCard(index) { + const match = refPreview.matches[index]; + if (!match) return; + await api("/api/cards", { + method: "POST", + body: { + title: `${match.repo}#${match.number}: ${match.title}`, + prompt: `${match.source} ${match.url}\n\n${match.title}\n\n${match.body || ""}`, + repo: match.repo, + source: match.source, + runtime: "auto", + policy: "", + }, + }); + setRefPreview(emptyRefPreview); + setSearchState(""); + await refreshState(); + } + + async function createCard(form) { + const data = new FormData(form); + await api("/api/cards", { + method: "POST", + body: { + title: data.get("title") || titleFromPrompt(data.get("prompt")), + prompt: data.get("prompt"), + repo: data.get("repo"), + source: data.get("source"), + runtime: data.get("runtime"), + policy: data.get("policy"), + }, + }); + form.reset(); + closeDrawer("card"); + await refreshState(); + } + + async function createInteractiveSession(form) { + const data = new FormData(form); + const optimistic = optimisticInteractiveSession( + data, + state.user?.login, + state.deployment?.runtimeProfiles, + ); + upsertInteractiveSession(optimistic); + closeDrawer("interactive"); + setFocusedSessionId(optimistic.id); + openSessionGrid(optimistic.id); + try { + const result = await api("/api/interactive-sessions", { + method: "POST", + body: { + repo: data.get("repo"), + branch: data.get("branch"), + runtime: data.get("runtime"), + profile: data.get("profile"), + command: data.get("command"), + prompt: data.get("prompt"), + }, + }); + removeInteractiveSession(optimistic.id); + upsertInteractiveSession(result.session); + form.reset(); + form.elements.branch.value = "main"; + form.elements.command.value = "codex --yolo"; + setFocusedSessionId(result.session.id); + openSessionGrid(result.session.id, { deepLink: true }); + } catch (error) { + upsertInteractiveSession({ + ...optimistic, + status: "failed", + lastEvent: error.message || "session creation failed", + logs: [error.message || "session creation failed"], + }); + setLoginMessage(error.message || "session creation failed"); + } + } + + async function addAllow(value, role) { + setState(await api("/api/admin/allow", { method: "POST", body: { value, role } })); + } + + async function removeAllow(value) { + setState(await api(`/api/admin/allow/${encodeURIComponent(value)}`, { method: "DELETE" })); + } + + async function addRepo(repo) { + setState(await api("/api/admin/repos", { method: "POST", body: { repo } })); + } + + async function removeRepo(repo) { + setState(await api(`/api/admin/repos/${encodeURIComponent(repo)}`, { method: "DELETE" })); + } + + async function refreshWorkflow(repo) { + setState(await api("/api/admin/workflows/evaluate", { method: "POST", body: { repo } })); + } + + async function updatePolicy(policy) { + setState(await api("/api/admin/policy", { method: "PUT", body: policy })); + } + + return { + search, + setSearch, + refPreview, + findInteractiveSession, + upsertInteractiveSession, + cardAction, + attachCard, + interactiveSessionAction, + deleteInteractiveSession, + cleanupInteractiveSession, + cleanupDeadInteractiveSessions, + shareInteractiveSession, + openRunDetails, + createRefCard, + createCard, + createInteractiveSession, + addAllow, + removeAllow, + addRepo, + removeRepo, + refreshWorkflow, + updatePolicy, + }; +} diff --git a/src/app/app-navigation.js b/src/app/app-navigation.js new file mode 100644 index 0000000..b64f73d --- /dev/null +++ b/src/app/app-navigation.js @@ -0,0 +1,170 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import { appViewUrl, initialAppView, sessionRouteUrl } from "./routing.js"; +import { loadSessionLayout, saveSessionLayout } from "./session-layout.js"; +import { disposeAllTerminals, warmGhosttyModule } from "./terminal.js"; + +const drawerOrder = ["card", "interactive", "run", "sessions", "admin"]; + +export function normalizedAppView(value) { + return value === "board" ? "board" : "fleet"; +} + +export function topOpenDrawer(drawers) { + return drawerOrder.findLast((key) => drawers[key]) || null; +} + +export function sessionOpenTarget(id, currentId, sessionItemById, options = {}) { + const targetId = id === undefined ? currentId : id; + const deepLink = + options.deepLink ?? Boolean(targetId && sessionItemById.get(targetId)?.kind === "interactive"); + const urlSessionId = + targetId && deepLink && !String(targetId).startsWith("LOCAL-") ? targetId : null; + return { + targetId, + clearFocus: id === null, + urlSessionId, + grid: !urlSessionId, + }; +} + +export function useAppNavigation({ initialSessionLink, sessionItemByIdRef }) { + const [appView, setAppViewState] = useState(initialAppView); + const [drawers, setDrawers] = useState(initialSessionLink.route ? { sessions: true } : {}); + const [activeRunId, setActiveRunId] = useState(null); + const [focusedSessionId, setFocusedSessionId] = useState(initialSessionLink.id); + const [sharedSessionId, setSharedSessionId] = useState(initialSessionLink.id); + const [sharedToken, setSharedToken] = useState(initialSessionLink.token); + const [theme, setThemeState] = useState( + document.documentElement.dataset.theme === "light" ? "light" : "dark", + ); + const [sessionLayout, setSessionLayout] = useState(loadSessionLayout); + const focusedSessionIdRef = useRef(focusedSessionId); + const draggedSessionId = useRef(null); + + focusedSessionIdRef.current = focusedSessionId; + + useEffect(() => { + const onPopState = () => setAppViewState(initialAppView()); + window.addEventListener("popstate", onPopState); + return () => window.removeEventListener("popstate", onPopState); + }, []); + + useEffect(() => { + document.documentElement.dataset.theme = theme; + try { + localStorage.setItem("crabbox-theme", theme); + } catch {} + }, [theme]); + + function setSessionUrl(id, options = {}) { + if (!history.replaceState) return; + history.replaceState( + null, + "", + sessionRouteUrl(location.href, { + id, + grid: options.grid, + appView, + sharedSessionId, + sharedToken, + }), + ); + } + + function openDrawer(id) { + setDrawers((current) => ({ ...current, [id]: true })); + } + + function closeDrawer(id) { + setDrawers((current) => ({ ...current, [id]: false })); + if (id === "run") setActiveRunId(null); + if (id === "sessions") { + setFocusedSessionId(null); + if (!sharedToken) setSessionUrl(null); + disposeAllTerminals(); + } + } + + function closeAllDrawers() { + setDrawers({}); + setActiveRunId(null); + setFocusedSessionId(null); + if (!sharedToken) setSessionUrl(null); + disposeAllTerminals(); + } + + function setAppView(value) { + const next = normalizedAppView(value); + setAppViewState(next); + closeAllDrawers(); + if (!history.pushState) return; + history.pushState(null, "", appViewUrl(location.href, next)); + } + + function closeTopDrawer() { + const id = topOpenDrawer(drawers); + if (!id) return false; + closeDrawer(id); + return true; + } + + function showSessionGrid() { + setFocusedSessionId(null); + if (!sharedToken) setSessionUrl(null, { grid: true }); + setDrawers((current) => ({ ...current, sessions: true })); + } + + function openSessionGrid(id, options = {}) { + const target = sessionOpenTarget( + id, + focusedSessionIdRef.current, + sessionItemByIdRef.current, + options, + ); + if (target.targetId) setFocusedSessionId(target.targetId); + else if (target.clearFocus) setFocusedSessionId(null); + if (target.urlSessionId) setSessionUrl(target.urlSessionId); + else if (!sharedToken) setSessionUrl(null, { grid: target.grid }); + warmGhosttyModule(); + setDrawers((current) => ({ ...current, sessions: true })); + } + + function setTheme(value) { + setThemeState(value === "light" ? "light" : "dark"); + } + + function updateSessionLayout(updater) { + setSessionLayout((current) => { + const next = typeof updater === "function" ? updater(current) : updater; + saveSessionLayout(next); + return next; + }); + } + + return { + appView, + setAppView, + drawers, + activeRunId, + setActiveRunId, + focusedSessionId, + focusedSessionIdRef, + setFocusedSessionId, + sharedSessionId, + setSharedSessionId, + sharedToken, + setSharedToken, + theme, + setTheme, + sessionLayout, + setSessionLayout: updateSessionLayout, + draggedSessionId, + openDrawer, + closeDrawer, + closeAllDrawers, + closeTopDrawer, + showSessionGrid, + openSessionGrid, + setSessionUrl, + }; +} diff --git a/src/app/app-shell-state.js b/src/app/app-shell-state.js new file mode 100644 index 0000000..80469f4 --- /dev/null +++ b/src/app/app-shell-state.js @@ -0,0 +1,23 @@ +import { isFleetSessionAttachable } from "./utils.js"; + +export function appShellMetrics(state) { + 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, + }; +} + +export function appUserPresentation({ signedIn, user }) { + const trustedProxyUser = Boolean(signedIn && user?.subject?.startsWith("proxy:")); + const userLabel = + !signedIn && user?.subject === "shared" + ? "Sign in for control" + : user + ? `${user.login || user.email || user.subject} / ${user.role}` + : "Signed out"; + return { trustedProxyUser, userLabel }; +} diff --git a/src/app/app-shell.jsx b/src/app/app-shell.jsx new file mode 100644 index 0000000..34863ef --- /dev/null +++ b/src/app/app-shell.jsx @@ -0,0 +1,123 @@ +import { defaultDeployment } from "./app-data.js"; +import { appShellMetrics, appUserPresentation } from "./app-shell-state.js"; +import { BoardPage } from "./board.jsx"; +import { appLogo } from "./branding.js"; +import { Icon } from "./components.jsx"; +import { FleetPage } from "./fleet.jsx"; +import { DevIdentityPanel } from "./login.jsx"; +import { canOwn } from "./utils.js"; + +export function AppShell(props) { + const deployment = props.state.deployment || defaultDeployment; + const { active, queue, review, cli } = appShellMetrics(props.state); + const user = props.state.user; + const { trustedProxyUser, userLabel } = appUserPresentation({ + signedIn: props.signedIn, + user, + }); + return ( +
+ +
+
+
+

{props.appView === "board" ? "Board" : deployment.label}

+

+ {props.appView === "board" + ? "Prompt cards and run attempts, separated from the live crabbox fleet." + : "All visible Codex crabboxes grouped by person, with SSH, WebVNC, and session supervision."} +

+
+ +
+
+
+ ); +} diff --git a/src/app/board-state.js b/src/app/board-state.js new file mode 100644 index 0000000..583b5c3 --- /dev/null +++ b/src/app/board-state.js @@ -0,0 +1,17 @@ +export function visibleBoardCards(cards, { filter, current, query }) { + return cards.filter((card) => { + if (filter === "mine" && card.owner !== current) return false; + if (filter === "hot" && card.lane !== "Running") return false; + return matchesCard(card, query); + }); +} + +export function matchesCard(card, query) { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return true; + const changedPaths = (card.changes?.files || []).map((file) => file.path).join(" "); + return [card.id, card.title, card.repo, card.source, card.runtime, card.policy, changedPaths] + .join(" ") + .toLowerCase() + .includes(normalizedQuery); +} diff --git a/src/app/board.jsx b/src/app/board.jsx new file mode 100644 index 0000000..270767c --- /dev/null +++ b/src/app/board.jsx @@ -0,0 +1,197 @@ +import { visibleBoardCards } from "./board-state.js"; +import { canMaintain, canOwn, elapsed, lanes, statusLabel } from "./utils.js"; + +export function BoardPage(props) { + return ( +
+
+
+ props.setSearch(event.currentTarget.value)} + /> + +
+
+ {["all", "mine", "hot"].map((key) => ( + + ))} +
+ + + +
+ +
+ ); +} + +function RefPreview({ preview, canCreate, onCreate }) { + if (!preview.number) return