# In-browser code editor Pressing `.` on any repository page, or opening `///edit/`, opens a workspace editor for that ref. It plays the role github.dev plays on GitHub: browse the tree, edit many files, stage the changes in the browser and commit them in one go. It costs nothing while nobody is typing. ## Options considered | | (a) VS Code for the Web, static | (b) Monaco in the React app | (c) openvscode-server in a Container | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | | Runtime cost | Static assets only (R2 or Workers static assets). No compute while idle. | None beyond the app. | A container per session: CPU and RAM billed while it runs, plus cold starts of several seconds. | | Latency | One-time download of about 15–25 MB (cached and immutable), then local. | Monaco is about 3–5 MB on first load. | Every keystroke round-trips to the container. Cold starts are slow. | | Features | Full workbench: explorer, search, SCM view, multi-cursor, keybindings, settings sync, web extensions. | Just the editor widget. Tree, tabs, SCM and search must all be built in React. | Everything, including a terminal, tasks, debuggers and language servers. | | Extensions | Web extensions from Open VSX. | None. | Every Open VSX extension, including native ones. | | Licensing | VSCodium and openvscode builds are MIT. They must not ship the Microsoft marketplace or Microsoft branding, so we point `extensionsGallery` at Open VSX. | Monaco is MIT. | openvscode-server is MIT, with Open VSX only. | | Offline | Works offline once loaded. Edits stay staged in the page, and only reads of uncached blobs and commits need the network. | The same. | Doesn't work offline. | | Isolation | Must run on an isolated origin, because extensions run arbitrary JavaScript. | Runs in the app origin unless sandboxed. | The container is isolated, but its HTTP endpoint needs auth. | ### Licensing notes - The "Visual Studio Code" product (vscode.dev, github.dev) is under the Microsoft licence. Its marketplace terms forbid use from non-Microsoft products, so we can't ship Microsoft's build or connect to `marketplace.visualstudio.com`. - The `microsoft/vscode` source is MIT. Gitpod's openvscode builds and VSCodium both produce MIT builds with an empty gallery. We set `product.json` to: `extensionsGallery: { serviceUrl: "https://open-vsx.org/vscode/gallery", itemUrl: "https://open-vsx.org/vscode/item" }`. - Open VSX is run by the Eclipse Foundation. Each extension keeps its own licence. Some popular Microsoft extensions, such as Pylance, the C# Dev Kit and Remote-*, aren't on Open VSX and are licensed for Microsoft products only. Users won't get them, and we document that. ### Decision **We pick (a).** It is the github.dev model with no backend container, so it costs no compute for each idle session. That beats Codespaces on cost, and it beats Codespaces on latency because typing never leaves the browser. (b) is a fallback, not a target: we'd end up rebuilding the workbench badly. (c) becomes an opt-in **"Run in container"** mode for the one thing github.dev can't do, which is a terminal. See below. ## Architecture ``` forge origin (cookies) editor origin (no forge cookies) ┌──────────────────────────────┐ postMessage ┌───────────────────────────────────────────┐ │ ///edit/[?path=] │──session────▶│ /workbench (Code - OSS 1.91 web build) │ │ WorkspaceEditor.tsx │◀──refresh────│ gitfor-fs extension (ext-host worker) │ │ mintEditorSession() │ │ FileSystemProvider gitfor:, SCM, cursors│ └──────────────────────────────┘ └───────────────┬───────────────────────────┘ Bearer / WS subprotocol token ┌──────────────────────────────────────┼───────────────────────────┐ ▼ ▼ ▼ /api/editor/{session,tree,blob,commit} /api/editor/room (WS, Yjs) /api/editor/container/* │ EditorRoom DO per EditorContainer DO per commitFileChanges + protections (repo, branch) (account, repo, branch) ``` ### The workbench - **Build.** `vscode-web@1.91.1` from npm is an MIT build of `microsoft/vscode`, "Code - OSS", with no Microsoft marketplace or branding. `scripts/build-editor.ts` (`bun run editor:assets`, run by `dev` and `build`) vendors it into `public/vscode-web//`, without the GitHub and Microsoft auth extensions. It also bundles `src/editor-extension/` into `public/editor-ext/gitfor-fs/`. - **Serving.** Workers static assets serve both directories, about 57 MB in 719 immutable files, with the largest at 12 MB. The build output is gitignored. - **Extension gallery.** `extensionsGallery` points at Open VSX only when `EDITOR_OPEN_VSX=on`. Third-party extensions run with the session's access (as on desktop), so the gallery is opt-in per deployment. Web extensions install into the browser and run in the extension host worker. Keep `EDITOR_OPEN_VSX=off` in production (INT-P14): an installed extension can read and write everything the editor session can, including private repositories, and Open VSX listings are not vetted. Turn it on only for a trusted, single-tenant deployment. - **Webviews.** Webviews are served from the vendored build on the editor origin, not from `vscode-cdn.net`. - **Workbench page.** `/workbench` is the only dynamic page on the editor origin (`src/server/editor/workbench.ts`). It waits for the session over `postMessage`, then boots the workbench with: - `folderUri` set to `gitfor://`; - the gitfor-fs extension added as a built-in; - two embedder commands, `gitfor.session` and `gitfor.refreshSession`, which the extension calls to get and refresh its token. ### Isolation and authentication - **A separate origin, not an opaque frame.** Phase 1 used an opaque-origin sandbox. VS Code can't run there: it needs IndexedDB, web workers, the extension-host iframe and webviews, all of which fail or are crippled in an opaque origin. Instead the workbench runs on its own origin: - In production, set `EDITOR_ORIGIN` to a dedicated host such as `https://editor.`. A subdomain of the forge's own domain is safe because forge cookies are `__Host-` and host-only. `EDITOR_ORIGIN` must not be the forge host, and must not be under `SITES_DOMAIN`, which serve user content: a user site there could frame or impersonate the editor. It must also use https. An unsafe value is rejected the first time an isolate reads it (`editorOriginProblem` in `src/server/editor/origin-check.ts`, unit-tested), and that rejection is logged and disables the editor. - In local dev, `localhost:` maps to `editor.localhost:`. - **What the separate origin protects.** The forge's host-only, `__Host-` session cookies are never sent to that origin. The editor origin serves only `/workbench`, static assets and port previews; every forge page and API returns 404 there. - Forge write endpoints also reject requests from the editor origin, because `Sec-Fetch-Site` must be `same-origin`. - The iframe still carries a `sandbox` attribute. It includes `allow-same-origin`, which is safe here because the frame is cross-origin to its parent. - **CSP on `/workbench`:** - `script-src 'self' 'unsafe-eval'` plus the bootstrap's hash, plus Open VSX; - `connect-src` limited to the forge API, its WebSocket and Open VSX; - `frame-ancestors` set to the forge origin; - `default-src 'none'`. - **Handshake.** - The workbench announces `ready` with a per-load id. It repeats this until it gets an answer, because the forge page may still be hydrating. - The forge page mints a session and checks that `event.source` is still the same frame after the await. - It posts the session to the exact editor origin. The workbench only accepts it from `parent` with the forge origin. - **Tokens.** A `gitfor_edt_` token lasts one hour and is scoped to one user, one repo and one ref, stored in `auth_tokens`. It is re-resolved on every request and every socket check: - The user's current repo permission is applied, capped by the ceiling the token was minted with. - Signing out everywhere or resetting a password revokes it. - For tokens minted from a PAT, the PAT's live `contents` and `pulls` scope is re-read each time. Narrowing or revoking the PAT applies immediately. - **WebSocket auth.** Browsers can't set headers on WebSockets, so sockets carry the token as a second subprotocol next to `gitfor-editor.v1`. It never goes in the URL. ### Reads - `GET /api/editor/session` returns the repository, ref, head SHA, permission, and whether the ref is protected for this user. - `GET /api/editor/tree` returns a recursive listing at the session's head. It is capped at `MAX_TREE_ENTRIES` and reports `truncated`. It uses `GitStore.walk`, which bounds tree reads. - `GET /api/editor/blob?path=…` returns `{ kind: "text" | "binary" | "lfs" | "too-large", content? }`. **LFS pointers are never returned as editable text**: the frame shows a read-only notice, and the commit endpoint rejects writes to a path whose current blob is an LFS pointer, or that the root `.gitattributes` routes through `filter=lfs`. Symlinks and submodules are read-only too. This stops the editor from replacing anything git treats specially with plain text. All reads go through `limitBrowse` and the same `assertVisibleRevision` checks as the browse server fns. ### Writes - **The room is the staging area.** Edits live in the branch's room, shared by everyone in it. - **Commit.** `POST /api/editor/commit {message, fromRoom: true}` snapshots the room's pending changes and commits them through `commitFileChanges`. Path validation (GIT-7), branch protection, the archived-repo check, the stale-head 409, LFS and `.gitattributes` guards, size caps and rate limits all apply. - **Protected branches.** The commit goes to `-patch-N` and a PR is opened, or the response carries `pullError` if the PR couldn't be opened. Committed paths then revert in the room, because the work now lives in the PR. - **Explicit changes.** `{changes, expectedHeadSha}` is still accepted, for agents that don't use rooms. - **Large blobs.** Blobs are size-checked from the blob handle before any bytes are copied (`blob-read.ts`). Large files cost a metadata read plus an 8 KB binary sniff. ## Live collaboration `EditorRoom` (`src/server/editor/room.ts`) is one Durable Object per (repo, branch), using hibernating WebSockets. - **Document.** One `Y.Doc` holds `file:` texts and a `files` metadata map with state and kind. Files are seeded from the branch head on first open, and concurrent openers share one read. LFS pointers, symlinks, binaries and oversized files are seeded read-only. - **Protocol.** y-protocols sync and awareness, framed as in y-websocket, plus JSON control frames for `open`, `create`, `delete`, `resolve`, `status` and `committed`. - **No server-side awareness instance.** The server doesn't keep an `Awareness` object, because its timer would keep the DO awake. It relays awareness updates and caches each socket's last cursor in the socket attachment, so after hibernation the cursor is replayed to newcomers. - Identity fields are overwritten with the authenticated user, and each socket is pinned to one Yjs client id, so nobody can spoof or hide another participant. - **Server-owned metadata.** Client transactions that touch the `files` map are undone at once and the socket is closed. Texts over the size cap are truncated. Clean files are evicted to free capacity, and open editors reseed them transparently. - **Persistence.** Updates are appended to DO SQLite and compacted into one state update after 300 rows. The room survives eviction and hibernation, and clients merge offline edits on reconnect. - **Head moves.** When the branch head moves (on connect, on commit, or from outside pushes), clean files follow upstream. A file edited here that also changed upstream becomes a conflict. That blocks commit until someone chooses "Keep my version", so neither side's edit is lost silently. - **Authorization.** Every socket is re-authorized on connect and then every 60 seconds by an alarm (or on its next message after 60 seconds). Close codes: - `4401`: revoked. The client stops. - `4403`: lost write access. The client reconnects read-only. - `4408`: token expired. The client refreshes through the forge page. Read-only sockets can only send sync step 1. - **VS Code binding** (`binding.ts`): - Local edits become Y.Text operations. - Remote changes are applied as one minimal diff edit, so they stay correct even when they interleave with typing. - Cursors and selections are drawn as coloured, labelled decorations. - Auto-save is on, and saving is a no-op because the room is the source of truth. ## Run in container (opt-in) This is the one thing github.dev can't do. `EditorContainer` (`container-do.ts`) is one Cloudflare Container per (account, repo, branch), using the `editor-container/Dockerfile` image (node 24, bun, git, python). It is enabled by `EDITOR_CONTAINERS=on` and started only when the user clicks "Run in container", which needs write access. - **Checkout.** A read-only Artifacts token clones the branch, and the remote is then reset to the public URL. Commits still go through the forge, so protections apply. Room edits are synced into the checkout before every terminal and task. - **Terminals.** PTY terminals run over a WebSocket (`/api/editor/container/terminal`) with resize support. In VS Code they are `Pseudoterminal`s. - **Tasks.** `gitfor` tasks and every `package.json` script run through `CustomExecution`. - **Ports.** `gitfor.container.openPort` mints a one-hour preview ticket and opens `/preview//`. Access is re-checked on every request. The response is served under CSP `sandbox` (an opaque origin) with cookies and `Set-Cookie` stripped. - **Plan gate.** A plan with no `compute.editor` allowance and a zero hard cap (the `scratch` plan, or `free` if its allowance is set to 0) can't use containers: `container/status` reports `allowed: false`, start and the terminal socket answer 402 "Containers are available on paid plans", the extension hides the status bar item and registers no tasks, and the forge hides "Open in container" (`getRepoHeader().containersAllowed`). - **Entry points.** The tree view's "Open in editor" goes to `///edit/` (same as the `.` hotkey); "Open in container" adds `?container=1`, which the page passes to the extension as `startContainer` so it boots a container and opens a terminal right after activation. - **Lifecycle.** - It sleeps after `EDITOR_CONTAINER_IDLE_MINUTES` (default 15) without terminal or preview I/O. - It is hard-stopped at `EDITOR_CONTAINER_CAP_HOURS` (default 8). - The alarm checks once a minute. - **Metering** (`container-meter.ts`, on top of `src/server/metering`). - Starting reserves `compute.editor` seconds with `reserveUsage`: the session cap, or whatever is left of the month's hard cap if that is less. The reservation id is the `editor_container_sessions` row id, so a session can never be held twice. A start is refused with 402 when less than a minute is left, or when the hold doesn't fit (spending limit, hard cap). - Every stop (user, idle, cap, quota, exited, restarted, admin) settles the hold with `commitUsage` using the exact seconds the container ran. Boots that fail release the hold and bill nothing. - The monthly allowance is the plan's `compute.editor` included quantity (`src/lib/plans.ts`); the `free` plan makes it a hard cap. There is no separate env quota. - `editor_container_sessions` keeps one row per session for the listings at `/settings/containers`, `/orgs//containers` and `/settings/containers`, where users stop their own containers and org owners and repo admins stop any. A row whose Durable Object has forgotten it is settled directly from the listing's Stop. ## Agents Agents join the same room as humans, in one of three ways: 1. **MCP tools** (`src/server/mcp/tools/editor.ts`): - `join_edit_room` reads the live text, version and participants. - `apply_edits` applies ranged edits against a `base_version`. If the version is stale it returns the current text so the agent can rebase. - `leave_edit_room` and `commit_edit_room` leave and commit. - Each call mints an editor token for the caller and goes through `/api/editor`, so agents pass exactly the same checks as browsers. 2. **HTTP.** Use `POST /api/v1/repos/:o/:r/editor-sessions` with a PAT, then `GET /api/editor/room/file`, `POST /api/editor/room/edit`, `GET /api/editor/room/status`, `POST /api/editor/room/leave` and `POST /api/editor/commit {fromRoom: true}`. 3. **WebSocket.** Connect to `/api/editor/room?agent=` and speak Yjs. `src/editor-extension/room-client.ts` works unchanged under Bun or Node. Agents appear with a labelled cursor, ` (agent for )`, in every human's editor. Their edits land live, and nothing is committed until someone commits the room. ## Testing ```sh # Static checks bun run type-check && bun run lint && bun run knip && bunx vite build # API: token protocol, guards, room (two Yjs clients + agent), PAT narrowing, revocation, container EDITOR_E2E_URL=http://localhost:3000 GITFOR_TOKEN=... GITFOR_WRITER_TOKEN=... GITFOR_E2E_D1= \ bun run e2e/editor-api.e2e.ts # Browsers: two headless Chromes + an agent co-edit in VS Code, cursors, SCM commit, revoked user kicked EDITOR_E2E_URL=http://localhost:3000 GITFOR_TOKEN=... GITFOR_WRITER_TOKEN=... GITFOR_E2E_D1=... \ E2E_LOGIN=... E2E_PASSWORD=... E2E_WRITER_LOGIN=... E2E_WRITER_PASSWORD=... bun run e2e/editor-browser.e2e.ts ``` `GITFOR_E2E_D1` lets the scripts flip settings that no public API exposes: requiring PRs on a protection rule, narrowing a PAT, and revoking a user's sessions. ## Known limits - **Open VSX:** only web extensions run, because there's no Node extension host without a container. Pylance, the C# Dev Kit and Remote-* aren't available. - **Conflicts:** they are per file. A file edited both in the room and upstream must be resolved explicitly. There is no three-way merge in the room yet. - **Container sync:** it is one-way, room to container. Files a terminal changes in the container don't flow back into the room. - **Workbench version:** it is pinned to 1.91.1, the newest prebuilt MIT web build on npm. A newer one needs our own `vscode-web-min` build of the OSS tree.