273 lines · 20.3 KB

In-browser code editor

Pressing . on any repository page, or opening /<owner>/<repo>/edit/<ref>, 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  ┌───────────────────────────────────────────┐
│ /<o>/<r>/edit/<ref>[?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/<version>/, 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.
  • 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:/<owner>/<repo>;
    • 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.<forge domain>. 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 or PAGES_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:<port> maps to editor.localhost:<port>.
  • 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 <user>-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:<path> 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 Pseudoterminals.
  • Tasks. gitfor tasks and every package.json script run through CustomExecution.
  • Ports. gitfor.container.openPort mints a one-hour preview ticket and opens <editor origin>/preview/<ticket>/. 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 /<owner>/<repo>/edit/<branch> (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/<org>/containers and <repo>/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=<name> and speak Yjs. src/editor-extension/room-client.ts works unchanged under Bun or Node.

Agents appear with a labelled cursor, <agent> (agent for <user>), in every human's editor. Their edits land live, and nothing is committed until someone commits the room.

Testing

# 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=<local d1 sqlite> \
  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.