{"id":51530706,"url":"https://github.com/acoyfellow/cloudshell","last_synced_at":"2026-07-09T02:01:06.697Z","repository":{"id":344021646,"uuid":"1180129584","full_name":"acoyfellow/cloudshell","owner":"acoyfellow","description":"Your personal terminal in the cloud. Deploy in seconds, access anywhere.","archived":false,"fork":false,"pushed_at":"2026-05-01T13:18:23.000Z","size":1349,"stargazers_count":18,"open_issues_count":1,"forks_count":1,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-01T15:06:52.603Z","etag":null,"topics":["browser-terminal","cloud-development","cloudflare","containers","remote-development","terminal","tmux","typescript"],"latest_commit_sha":null,"homepage":"https://cloudshell.coey.dev","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/acoyfellow.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-12T18:21:43.000Z","updated_at":"2026-05-01T13:18:27.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/acoyfellow/cloudshell","commit_stats":null,"previous_names":["acoyfellow/cloudshell"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/acoyfellow/cloudshell","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/acoyfellow%2Fcloudshell","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/acoyfellow%2Fcloudshell/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/acoyfellow%2Fcloudshell/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/acoyfellow%2Fcloudshell/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/acoyfellow","download_url":"https://codeload.github.com/acoyfellow/cloudshell/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/acoyfellow%2Fcloudshell/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35283905,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-09T02:00:07.329Z","response_time":57,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["browser-terminal","cloud-development","cloudflare","containers","remote-development","terminal","tmux","typescript"],"created_at":"2026-07-09T02:01:05.423Z","updated_at":"2026-07-09T02:01:06.689Z","avatar_url":"https://github.com/acoyfellow.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cp align=\"center\"\u003e\n  \u003cimg src=\"./static/favicon.svg\" alt=\"Cloudshell logo\" width=\"72\" /\u003e\n\u003c/p\u003e\n\n\u003ch1 align=\"center\"\u003eCloudshell\u003c/h1\u003e\n\n\u003cp align=\"center\"\u003e\u003cstrong\u003eExperimental\u003c/strong\u003e\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  A terminal-first cloud workstation with a SvelteKit frontend and a Cloudflare Worker + Containers backend.\n\u003c/p\u003e\n\n## What it is\n\nCloudshell gives each user one shared workstation filesystem and lets them open isolated runtime sessions on top of it.\n\nThe core model is:\n\n- `user workstation` = one shared filesystem per user\n- `session` = one isolated runtime container\n- `tab` = one independent shell inside the active session\n\n## What ships today\n\n- SvelteKit app for auth, routing, and UI\n- Better Auth backed by D1\n- Cloudflare Worker backend for session orchestration\n- Cloudflare Containers for isolated runtime sessions\n- Shared per-user workstation files across sessions\n- Per-tab shell state inside each session\n- Left-side files drawer wired to the same filesystem the terminal sees\n- Ports and tools workspace for forwarding, sharing, SSH keys, and backups\n- npm-bundled terminal stack with no CDN dependency\n\n## Repo layout\n\n```text\nsrc/            SvelteKit app\nworker/         Cloudflare Worker + container runtime\nshared/         Shared helpers used by app and worker\nmigrations/     Better Auth / D1 migrations\nold/            Archived pre-rebuild app for reference\nalchemy.run.ts  Dev + deploy orchestration\n```\n\n## Local development\n\n1. Create a local env file.\n\n```bash\ncp .env.example .env\n```\n\n2. Fill in the required values:\n\n- `ALCHEMY_PASSWORD`\n- `BETTER_AUTH_SECRET`\n\nOptional:\n\n- `BETTER_AUTH_URL`\n- `BETTER_AUTH_TRUSTED_ORIGINS`\n- `TERMINAL_TICKET_SECRET`\n- `AWS_ACCESS_KEY_ID`\n- `AWS_SECRET_ACCESS_KEY`\n- `R2_ACCOUNT_ID`\n- `CLOUDFLARE_ACCOUNT_ID`\n- `ALLOWED_EMAILS` (comma-separated email allow-list for signup; empty/unset denies all signups, existing accounts can still log in)\n\n3. Start the app.\n\n```bash\nbun install\nbun run dev\n```\n\nDefault local URLs:\n\n- app: `http://localhost:5173`\n- worker: `http://localhost:1338`\n\n## Useful scripts\n\n```bash\nbun run dev\nbun run build\nbun run check\nbun run test\nbun run deploy\nbun run destroy\n```\n\nDatabase helpers:\n\n```bash\nbun run db:generate\nbun run db:migrate\nbun run db:local\nbun run db:remote\n```\n\n## Architecture notes\n\n- Better Auth uses D1 for user and auth records.\n- Runtime metadata stays in KV/R2, not D1.\n- Terminal access uses app-origin websocket routing in production and a signed direct-worker ticket in local dev.\n- `@xterm/xterm` and `@xterm/addon-fit` are bundled from npm. There is no CDN-loaded terminal dependency. We previously used a custom emulator (`cloudterm`) but swapped to xterm.js because reproducing every VT-protocol corner (DA1/DA2/XTVERSION query replies, charset designation, scroll regions, wide-char widths) is a long-tail surface area best left to the canonical implementation.\n\n### Terminal handshake contract\n\nThe browser does not connect to the container directly.\n\n1. `src/routes/api/cloudshell/terminal-connection/+server.ts` issues a short-lived ticket for the active user, session, and tab.\n2. `src/routes/ws/terminal/+server.ts` proxies the websocket through `src/lib/server/worker.ts`.\n3. The app-host proxy validates the ticket and forwards the terminal identity headers to the worker hop:\n   - `X-User-Id`\n   - `X-User-Email`\n   - `X-Session-Id`\n   - `X-Tab-Id`\n4. `worker/effect/services.ts` forwards the same identity context into the container request.\n5. `worker/container/main.go` uses that user/session/tab identity to attach the correct terminal process.\n\nIf any hop drops that identity, the browser typically sees a websocket close like `1006` instead of a usable terminal.\n\n### MCP auth broker (agent capability proxy)\n\nCloudshell is agnostic to what MCP server you wire up — it is the broker,\nnot the authority. The CLI inside the container never holds an upstream\nOAuth token; the cloudshell app holds them server-side in a per-user\nDurable Object and attaches them to outbound calls.\n\n```\nbrowser (your device)          cloudshell.coey.dev              upstream MCP server\n │                              │                                │\n │  Better Auth session         │                                │\n │─── POST /oauth/mcp/start ──▶│                                │\n │     (click Connect in UI     │  runAuthStart in UserAgent DO: │\n │      or mcp login in shell)  │  PKCE, Dynamic Client          │\n │                              │  Registration, state nonce     │\n │                              │─── authorize URL ─────────────▶│\n │◀── popup window ─────────────│                                │\n │                                                                │\n │  ─── user approves in popup, upstream redirects ──────────────▶│\n │                                                                │\n │◀── GET /api/mcp/oauth/callback?state=\u0026code= ──────────────────│\n │                              │  runAuthCallback in DO:        │\n │                              │  exchange code, save tokens    │\n │◀── popup posts result ───────│                                │\n │                                                                │\n │  container shell                                               │\n │  $ mcp call \u003cserver\u003e tools/list                               │\n │   │                          │                                │\n │   │── POST /api/mcp/bridge ─▶│                                │\n │   │    X-Cloudshell-Ticket   │  verifyBridgeTicket            │\n │   │                          │  DO.getAccessTokenFor()        │\n │   │                          │─── Authorization: Bearer ─────▶│\n │   │                          │◀── tool result (JSON / SSE) ───│\n │   │◀───────── result ────────│                                │\n```\n\nKey properties, each tied to source:\n\n- **Token never enters the container.** `worker/mcp-bridge.ts`\n  `bridgeMcpRequest()` reads the bearer from the DO by RPC, attaches it\n  to the outbound fetch, streams the response back. The container only\n  sees the MCP JSON-RPC response body.\n- **Auth separation.** Browser path uses Better Auth session cookies\n  (`src/routes/api/mcp/oauth/*`). Container path uses a short-lived\n  capability ticket scoped `['mcp-bridge']`\n  (`shared/terminal-ticket.ts`, `verifyCapabilityTicket`). Terminal\n  identity tickets (no scope) can't be substituted — every path has\n  an exact-scope check.\n- **Bridge tickets auto-delivered.** When the browser opens a terminal\n  tab, `src/lib/components/cloudshell/terminal-pane.svelte`\n  `deliverBridgeTicket()` mints a ticket against `/api/cloudshell/\n  ticket/mint-bridge` and sends it down the terminal WebSocket as a\n  `{type: \"bridge_ticket\"}` control frame.\n  `worker/container/main.go` writes it to `~/.cloudshell/bridge-ticket`\n  on receive; `worker/container/mcp-cli.mjs` reads it on invocation.\n- **Per-user isolation.** Each user has one `CloudshellUserAgent`\n  Durable Object (`worker/user-agent.ts`) addressed by Better Auth\n  user ID. All OAuth state (client info, code verifiers, state nonces,\n  tokens) lives in that DO's SQLite. Deleting the user wipes everything;\n  two users cannot see each other's connections.\n- **Based on `cloudflare/agents`.** The token store is\n  `DurableObjectOAuthClientProvider` from the agents SDK, driven by\n  `@modelcontextprotocol/sdk/client/auth.js`. Cloudshell writes a\n  thin per-user DO around it, plus the HTTP + UI plumbing above.\n\nUsage from the shell:\n\n```sh\n# one-time: open a terminal in the browser. Bridge ticket lands\n# at ~/.cloudshell/bridge-ticket automatically.\n\n$ mcp login https://mcp.apify.com\nOpen this URL in your browser to authorize https://mcp.apify.com:\n  https://console.apify.com/authorize/oauth?...\nWaiting for authorization... (Ctrl-C to cancel)\n✓ Connected to https://mcp.apify.com.\n\n$ mcp list\nhttps://mcp.apify.com    2026-04-19T...\n\n$ mcp call https://mcp.apify.com tools/list\n{ \"jsonrpc\": \"2.0\", ..., \"result\": { \"tools\": [...] } }\n```\n\nUsage from the UI: visit the Tools panel, enter an MCP server URL in\n\"Connect MCP server\", approve in the popup. Connection appears in the\nlist and is immediately usable from any terminal.\n\nDesign notes and honest trade-offs (see also\n`~/cloudflare/auth-research/experiments/28-cloudshell-mcp-broker/`):\n\n- Tokens in DO SQLite are plaintext today, not end-to-end encrypted.\n  `cloudflare/workers-oauth-provider` (the OAuth **server** library)\n  encrypts `props` at rest; we could port that pattern to the client\n  side if a threat model requires it.\n- `parseServerIdFromState` in `worker/mcp-oauth.ts` parses the state\n  nonce format `nonce.serverId`. This matches agents SDK 0.11's\n  `DurableObjectOAuthClientProvider.state()` output; if that format\n  changes we break silently. A more robust fix would ask the\n  provider for the serverId directly.\n- Container `sleepAfter` is currently 5 min. Bridge tickets are\n  1 hour. Bridge ticket lifetime dominates; loss of a container\n  doesn't invalidate the ticket. Tighten if the threat model needs it.\n\n### Post-Containers-GA deploy footgun (2026-04-18) — FIXED in 0.91.2\n\n\u003e **Short version:** this was an alchemy bug, fixed upstream on 2026-01-09 in\n\u003e [PR #1298](https://github.com/sam-goodwin/alchemy/commit/a8996eed3f92105fc29eeb865d9fbae8c2f9c24d).\n\u003e If you're on `alchemy \u003e= 0.78`, skip to the next section. If you're pinned\n\u003e below that, either upgrade or use the manual-delete workaround below.\n\u003e\n\u003e **Root cause:** Cloudflare's `POST /accounts/:id/containers/applications`\n\u003e endpoint used to return `errors[].code === 1000` when a Durable Object\n\u003e already had a container app bound to it. That code is now `1608`. Alchemy\n\u003e `0.77.5` and earlier looked for `1000`, so the adopt-on-conflict recovery\n\u003e branch in `createContainerApplication` never fired. Every subsequent deploy\n\u003e threw `Failed to create container application: ... DURABLE_OBJECT_ALREADY_HAS_APPLICATION`\n\u003e and required a manual `DELETE /containers/applications/\u003cid\u003e` before it\n\u003e could proceed. Alchemy `0.78+` matches on `1608` and cleanly calls\n\u003e `updateContainerApplication` on the existing record — deploys adopt the\n\u003e existing app in place, preserving its ID and DO bindings.\n\u003e\n\u003e **How to tell you're hitting it:** every deploy, not just drift. Error message\n\u003e on the deploy step is `DURABLE_OBJECT_ALREADY_HAS_APPLICATION`. The\n\u003e container app ID stays the same across manual deletes (the name is\n\u003e deterministic, derived from project + resource ID).\n\n### Manual workaround if you need it\n\nIf the terminal returns `1006` / \"Terminal unavailable\" indefinitely **after all five\nhops look healthy**, check the account's container applications inventory before\nspending more time in the code. This has bitten this project for a long time.\n\nThe symptom:\n- Worker log shows `Error checking 8080: The operation was aborted` repeating\n- Runtime reports `\"Container crashed while checking for ports, did you start the\n  container and setup the entrypoint correctly?\"`\n- `wrangler tail` shows `Container state running attempt=1` / `attempt=2` but\n  `waitForPort` never resolves\n- A minimal parity container deployed alongside (see `worker/parity-container/`)\n  WORKS fine on the exact same Worker → DO path\n\nThe cause:\nWhen the project has been redeployed across breaking changes — alchemy renames, DO\nclass renames, package upgrades — the Cloudflare Containers control plane can end\nup with **stale container applications mapped to the same Durable Object class**\nas the live one. Symptoms on the inventory:\n- Multiple `cloudshell-*` apps for the same logical container\n- One or more apps reporting `instances \u003e max_instances` (e.g. `9/5`)\n- Names from a prior code shape (e.g. `cloudshell-cloudshellterminal` when the\n  current code uses `Container('sandbox', ...)` which alchemy names\n  `cloudshell-sandbox-runner`)\n\nWhen this state exists, Docker pushes on new deploys succeed, but the DO class is\nstill routing to a wedged old app. Changes to `Dockerfile`, `startup.sh`, Worker\ncode, or the `@cloudflare/containers` package version do not take effect because\nthe runtime is serving a stale image behind the scenes.\n\nCheck and clean up:\n\n```bash\n# List all container apps on the account\ncurl -sS -H \"Authorization: Bearer $CLOUDFLARE_API_TOKEN\" \\\n  \"https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/containers/applications\" \\\n  | jq '.result[] | select(.name | startswith(\"cloudshell\"))\n        | {name, id, instances, max: .max_instances}'\n```\n\nIf you see duplicates, orphans, or `instances \u003e max`, delete them:\n\n```bash\ncurl -sS -X DELETE -H \"Authorization: Bearer $CLOUDFLARE_API_TOKEN\" \\\n  \"https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/containers/applications/\u003cID\u003e\"\n```\n\nThen redeploy — alchemy recreates the active app cleanly and the new image takes\neffect immediately.\n\n**Do not delete `cloudshell-sandbox-runner` if it is the only healthy app and\nyour terminal works.** Only the orphans and the stuck entries.\n\n#### How this diagnosis was reached (historical — kept for the next tool update)\n\nHaving this as a documented pattern saves the next person the four-commit chase we\ndid the first time:\n\n1. **Reproduced 1006 in prod.** WS hung in `readyState: 0 (CONNECTING)` for 10+ seconds, never opened.\n2. **Bisected with existing probe endpoints.** `/ws/hello`, `/ws/terminal-probe`,\n   `/ws/proxy-hello` all opened and echoed cleanly — confirming Browser→SvelteKit\n   and SvelteKit→Worker hops were fine. Only `/ws/terminal` failed.\n3. **Enabled `wrangler tail`.** Revealed the Worker-side failure:\n   `waitForPort(8080)` on the container DO kept aborting; `Container crashed\n   while checking for ports`.\n4. **Shipped three plausible fixes.** (a) `containerFetch(request, port)` override\n   missing on the terminal Container subclass (was genuinely required post-GA);\n   (b) `set -e` in `startup.sh` can abort before `exec /server`; (c) upgrade\n   `@cloudflare/containers 0.1.1 → ^0.3.2` + explicit `enableInternet = true`.\n   All correct changes. **None moved the tail output.** Identical failure pattern\n   after each deploy.\n5. **Enabled the parity container bisect.** Set `TERMINAL_PARITY_SECRET` in CI so\n   `CloudShellParityTerminal` (9-line Dockerfile, node + `ws`, no FUSE, no Go)\n   provisioned. Hit `wss://cloudshell-api.coey.dev/terminal?secret=...`. It\n   opened cleanly and responded with `parity-ready` in \u003c400ms — tail showed\n   `Port 8080 is ready`. That conclusively proved the Worker→DO→container path\n   was healthy; the problem was inside the cloudshell image path.\n6. **Disabled FUSE entirely in `startup.sh`.** The only meaningful difference\n   between cloudshell and parity was FUSE + bash + Go. **Still same 1006.**\n   At this point nothing in the source tree could explain why code changes had\n   no effect on runtime behavior.\n7. **Inspected the account-level container apps inventory.** Found three\n   cloudshell-named apps, two with `instances \u003e max_instances` (9/5 and 9/5),\n   one with a pre-refactor name (`cloudshell-cloudshellterminal`) that no\n   longer matches anything in the current `alchemy.run.ts`. That explained why\n   our committed code changes were being ignored: the DO class was still\n   serving traffic from a wedged historical container app that Alchemy's state\n   didn't know about.\n8. **Deleted the stale apps, redeployed.** Alchemy recreated the active app\n   clean, the new image took effect, and the terminal came back up.\n\nThe tell for next time: **if code changes make it through CI and deploy, but\nthe behavior in tail is byte-identical to before the change, suspect container\nstate before suspecting code.**\n\n## Verification\n\nBefore shipping changes:\n\n```bash\nbun run check\nbun run test\nbun run build\n```\n\nFor worker container tests:\n\n```bash\ncd worker/container \u0026\u0026 go test ./...\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Facoyfellow%2Fcloudshell","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Facoyfellow%2Fcloudshell","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Facoyfellow%2Fcloudshell/lists"}