Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

## Unreleased

- 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.
- 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.
- Make Sandbox terminal intent monotonic under stop/failure races, fence initial provision completion against managed retries and refresh ownership without rejecting concurrent metadata writes, atomically version terminal metadata with its event and finalization marker, keep live credential-registration failures retryable, clean both sides of interrupted refreshes, expire standalone Sandboxes with authenticated stop, and exactly reserve managed session IDs from standalone use.
- Keep standalone Sandbox terminals behind a lifetime-authorized Worker WebSocket proxy and terminate their execution sessions during durable cleanup, fail closed legacy unfenced credential policies, isolate and persist per-owner cleanup failures, reserve managed IDs case-insensitively across upgrades, preserve SSH-link state across canonical OAuth redirects, reject lost runtime-stop claims, redact arbitrary escaped connection URLs, make terminal completion/release revisions monotonic, and retain single-read redacted adapter create/DELETE evidence through final archives.
- 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 direct bridge and runner PTY routes without rewriting opaque adapter URLs.
- 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.
- 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.
Expand Down
29 changes: 23 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,13 @@ Crabfleet gives OpenClaw maintainers a fleet dashboard where every Codex crabbox
- **D1 + Kysely** for typed persistence: users, sessions, allowlists, repos, cards, events, run attempts, interactive sessions, diffs, and repo workflow evaluations.
- **Ghostty WebAssembly** for the fullscreen attach grid and run log replay.
- **Cloudflare Sandbox containers** for standalone interactive Codex CLI workspaces with live PTY attach.
- **Runtime adapter descriptors** for Container and Crabbox selection, capability display, interactive provision handoff, and guarded takeover.
- **Provision endpoint** at `/api/provision/interactive` that can use the built-in Sandbox backend or delegate to a generic runtime adapter or ClawFleet.
- **Runtime adapter descriptors** for Container and Crabbox selection, capability display, interactive lifecycle handoff, and guarded takeover.
- **Versioned lifecycle adapter** for idempotent external workspace creation, bounded status reconciliation, provider-backed stop, 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.
- **R2 session archives** for crabbox event NDJSON, transcripts, and summaries.
- **GitHub API** for OAuth, org/team membership, and issue/PR previews across enabled repos.

Autonomous card execution, Crabbox VNC transport, Durable Object fanout, and merge automation are adapter targets, not faked in the current Worker.
Autonomous card execution, Durable Object fanout, and merge automation are adapter targets, not faked in the current Worker. External interactive terminal and desktop transport require a configured lifecycle adapter or bridge.
The OpenClaw fleet/orchestrator backend should run on the Hetzner `openclaw-clawsweeper` host alongside ClawSweeper; the Worker stays the app/API front door.

## Quick Start
Expand Down Expand Up @@ -164,12 +165,18 @@ The Crabbox namespace cutover intentionally has no old-name compatibility. Exist
- `CRABBOX_BOOTSTRAP_TOKEN` – Optional owner break-glass token for setup/recovery
- `GITHUB_CLIENT_ID` – GitHub OAuth app client ID (optional)
- `GITHUB_CLIENT_SECRET` – GitHub OAuth app secret (optional)
- `GITHUB_REDIRECT_URI` – GitHub OAuth callback URL registered on the app (defaults to the request origin callback)
- `GITHUB_REDIRECT_URI` – Optional authoritative GitHub OAuth callback URL; when set it must be an absolute HTTPS URL with no credentials, query, or fragment and the exact `/auth/github/callback` path. Requests on another host restart login on this configured origin. When absent, the callback defaults to the HTTPS request origin (or literal-loopback HTTP for local development).
- `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
- `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_STANDALONE_SANDBOX_TTL_SECONDS` – Optional built-in standalone Sandbox lifetime, default `14400`, bounded to 300–86400 seconds
- `CRABBOX_RUNTIME_ADAPTER_URL` – Optional base URL for the versioned workspace lifecycle adapter; 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_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`
Expand All @@ -178,18 +185,28 @@ The Crabbox namespace cutover intentionally has no old-name compatibility. Exist
- `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; supports `{id}`, `{leaseId}`, `{repo}`, `{branch}`, and `{runtime}`
- `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/Discord service crabbox creation
- `CRABFLEET_SSH_GATEWAY_TOKEN` / `CRABBOX_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_DEFAULT_PROFILE` – Optional opaque runtime-adapter profile, default `default`
- `CRABFLEET_DEV_LOGIN_ENABLED` – Explicit local-only development identity login gate; disabled unless exactly `true`, and still restricted to literal localhost requests
- `OPENAI_API_KEY` – Required for built-in Cloudflare Sandbox Codex CLI sessions; injected by the Worker outbound path for Cloudflare Sandbox requests

### Verify Deployment

The app Worker includes a once-per-minute cron trigger for bounded runtime lifecycle and terminal-archive reconciliation. Keep the `triggers.crons` entry when deriving deployment configuration; direct session, PTY, and VNC access also performs CAS-guarded targeted refreshes.

```bash
curl -I https://crabfleet.openclaw.ai/healthz
# Should return: 200 OK
Expand Down
85 changes: 67 additions & 18 deletions cmd/crabbox-ssh-gateway/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,21 +49,28 @@ type stateResponse struct {
}

type interactiveSession struct {
ID string `json:"id"`
ParentSessionID string `json:"parentSessionId"`
RootSessionID string `json:"rootSessionId"`
Repo string `json:"repo"`
Branch string `json:"branch"`
Runtime string `json:"runtime"`
Status string `json:"status"`
Owner string `json:"owner"`
CreatedBy string `json:"createdBy"`
Purpose string `json:"purpose"`
Summary string `json:"summary"`
AttachURL string `json:"attachUrl"`
VNCURL string `json:"vncUrl"`
LastEvent string `json:"lastEvent"`
LogArchive logArchive `json:"logArchive"`
ID string `json:"id"`
ParentSessionID string `json:"parentSessionId"`
RootSessionID string `json:"rootSessionId"`
Repo string `json:"repo"`
Branch string `json:"branch"`
Runtime string `json:"runtime"`
Status string `json:"status"`
Owner string `json:"owner"`
CreatedBy string `json:"createdBy"`
Purpose string `json:"purpose"`
Summary string `json:"summary"`
Capabilities *sessionCapabilities `json:"capabilities"`
PtyAvailable *bool `json:"ptyAvailable"`
LeaseID string `json:"leaseId"`
AttachURL string `json:"attachUrl"`
VNCURL string `json:"vncUrl"`
LastEvent string `json:"lastEvent"`
LogArchive logArchive `json:"logArchive"`
}

type sessionCapabilities struct {
Terminal bool `json:"terminal"`
}

type card struct {
Expand Down Expand Up @@ -346,7 +353,10 @@ func runCommand(ctx context.Context, out io.ReadWriter, perms *ssh.Permissions,
fmt.Fprintf(out, "error: %v\n", err)
return 1
}
fmt.Fprintf(out, "session: %s\nrepo: %s\nstatus: %s\nattach: ssh crabfleet attach %s\n", session.ID, session.Repo, session.Status, session.ID)
fmt.Fprintf(out, "session: %s\nrepo: %s\nstatus: %s\n", session.ID, session.Repo, session.Status)
if attachable(session) {
fmt.Fprintf(out, "attach: ssh crabfleet attach %s\n", session.ID)
}
if session.VNCURL != "" {
fmt.Fprintf(out, "vnc: %s\n", terminalSafe(session.VNCURL))
}
Expand All @@ -356,7 +366,7 @@ func runCommand(ctx context.Context, out io.ReadWriter, perms *ssh.Permissions,
}
return 0
}
if create.detach {
if create.detach || !attachable(session) {
return 0
}
return client.attach(ctx, auth.fingerprint, session.ID, out, pty)
Expand Down Expand Up @@ -470,12 +480,51 @@ func runCommand(ctx context.Context, out io.ReadWriter, perms *ssh.Permissions,
}
}

func terminalCapable(session interactiveSession) bool {
return session.Capabilities == nil || session.Capabilities.Terminal
}

func attachable(session interactiveSession) bool {
if !terminalCapable(session) || !ptyAttachable(session) {
return false
}
switch session.Status {
case "ready", "attached", "detached":
return true
default:
return false
}
}

func ptyAttachable(session interactiveSession) bool {
if session.PtyAvailable != nil {
return *session.PtyAvailable
}
if strings.HasPrefix(session.LeaseID, "sandbox:") || strings.HasPrefix(session.LeaseID, "cloudflare:") {
return true
}
return strings.HasPrefix(session.AttachURL, "/api/interactive-sessions/") || validWebSocketAttachURL(session.AttachURL)
}

func validWebSocketAttachURL(raw string) bool {
target, err := url.Parse(raw)
if err != nil || target.Host == "" || target.User != nil {
return false
}
if target.Scheme == "wss" {
return true
}
host := target.Hostname()
return target.Scheme == "ws" && (host == "localhost" || host == "127.0.0.1" || host == "::1")
}

func printHelp(out io.Writer, user user) {
fmt.Fprintf(out, "Crabfleet SSH\nlogin: %s\nrole: %s\n\n", terminalSafe(displayUser(user)), terminalSafe(user.Role))
fmt.Fprintln(out, "commands:")
fmt.Fprintln(out, " whoami")
fmt.Fprintln(out, " list")
fmt.Fprintln(out, " new [--repo owner/repo] [--branch main] [--runtime crabbox|container] [--parent id] [--purpose text] [--command codex] [--vnc] [prompt]")
fmt.Fprintln(out, " --runtime overrides the deployment default")
fmt.Fprintln(out, " attach SESSION_ID")
fmt.Fprintln(out, " vnc SESSION_ID")
fmt.Fprintln(out, " logs SESSION_ID")
Expand Down Expand Up @@ -745,7 +794,7 @@ func parseCreate(args []string, client *apiClient, fingerprint string) createArg
var vnc bool
fs.StringVar(&req.Repo, "repo", "", "repo")
fs.StringVar(&req.Branch, "branch", "main", "branch")
fs.StringVar(&req.Runtime, "runtime", "crabbox", "runtime")
fs.StringVar(&req.Runtime, "runtime", "", "runtime override; defaults to deployment")
fs.StringVar(&req.Command, "command", "", "command")
fs.StringVar(&req.ParentSessionID, "parent", "", "parent session")
fs.StringVar(&req.RootSessionID, "root", "", "root session")
Expand Down
Loading