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/vscodesource is MIT. Gitpod's openvscode builds and VSCodium both produce MIT builds with an empty gallery. We setproduct.jsonto: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.1from npm is an MIT build ofmicrosoft/vscode, "Code - OSS", with no Microsoft marketplace or branding.scripts/build-editor.ts(bun run editor:assets, run bydevandbuild) vendors it intopublic/vscode-web/<version>/, without the GitHub and Microsoft auth extensions. It also bundlessrc/editor-extension/intopublic/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.
extensionsGallerypoints at Open VSX only whenEDITOR_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. KeepEDITOR_OPEN_VSX=offin 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.
/workbenchis the only dynamic page on the editor origin (src/server/editor/workbench.ts). It waits for the session overpostMessage, then boots the workbench with:folderUriset togitfor:/<owner>/<repo>;- the gitfor-fs extension added as a built-in;
- two embedder commands,
gitfor.sessionandgitfor.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_ORIGINto a dedicated host such ashttps://editor.<forge domain>. A subdomain of the forge's own domain is safe because forge cookies are__Host-and host-only.EDITOR_ORIGINmust not be the forge host, and must not be underSITES_DOMAINorPAGES_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 (editorOriginProbleminsrc/server/editor/origin-check.ts, unit-tested), and that rejection is logged and disables the editor. - In local dev,
localhost:<port>maps toeditor.localhost:<port>.
- In production, set
- 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-Sitemust besame-origin. - The iframe still carries a
sandboxattribute. It includesallow-same-origin, which is safe here because the frame is cross-origin to its parent.
- Forge write endpoints also reject requests from the editor origin, because
- CSP on
/workbench:script-src 'self' 'unsafe-eval'plus the bootstrap's hash, plus Open VSX;connect-srclimited to the forge API, its WebSocket and Open VSX;frame-ancestorsset to the forge origin;default-src 'none'.
- Handshake.
- The workbench announces
readywith 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.sourceis still the same frame after the await. - It posts the session to the exact editor origin. The workbench only accepts it from
parentwith the forge origin.
- The workbench announces
- Tokens. A
gitfor_edt_token lasts one hour and is scoped to one user, one repo and one ref, stored inauth_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
contentsandpullsscope 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/sessionreturns the repository, ref, head SHA, permission, and whether the ref is protected for this user.GET /api/editor/treereturns a recursive listing at the session's head. It is capped atMAX_TREE_ENTRIESand reportstruncated. It usesGitStore.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.gitattributesroutes throughfilter=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 throughcommitFileChanges. Path validation (GIT-7), branch protection, the archived-repo check, the stale-head 409, LFS and.gitattributesguards, size caps and rate limits all apply. - Protected branches. The commit goes to
<user>-patch-Nand a PR is opened, or the response carriespullErrorif 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.Docholdsfile:<path>texts and afilesmetadata 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,statusandcommitted. - No server-side awareness instance. The server doesn't keep an
Awarenessobject, 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
filesmap 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 arePseudoterminals. - Tasks.
gitfortasks and everypackage.jsonscript run throughCustomExecution. - Ports.
gitfor.container.openPortmints a one-hour preview ticket and opens<editor origin>/preview/<ticket>/. Access is re-checked on every request. The response is served under CSPsandbox(an opaque origin) with cookies andSet-Cookiestripped. - Plan gate. A plan with no
compute.editorallowance and a zero hard cap (thescratchplan, orfreeif its allowance is set to 0) can't use containers:container/statusreportsallowed: 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 asstartContainerso 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.
- It sleeps after
- Metering (
container-meter.ts, on top ofsrc/server/metering).- Starting reserves
compute.editorseconds withreserveUsage: the session cap, or whatever is left of the month's hard cap if that is less. The reservation id is theeditor_container_sessionsrow 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
commitUsageusing the exact seconds the container ran. Boots that fail release the hold and bill nothing. - The monthly allowance is the plan's
compute.editorincluded quantity (src/lib/plans.ts); thefreeplan makes it a hard cap. There is no separate env quota. editor_container_sessionskeeps one row per session for the listings at/settings/containers,/orgs/<org>/containersand<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.
- Starting reserves
Agents
Agents join the same room as humans, in one of three ways:
- MCP tools (
src/server/mcp/tools/editor.ts):join_edit_roomreads the live text, version and participants.apply_editsapplies ranged edits against abase_version. If the version is stale it returns the current text so the agent can rebase.leave_edit_roomandcommit_edit_roomleave 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.
- HTTP. Use
POST /api/v1/repos/:o/:r/editor-sessionswith a PAT, thenGET /api/editor/room/file,POST /api/editor/room/edit,GET /api/editor/room/status,POST /api/editor/room/leaveandPOST /api/editor/commit {fromRoom: true}. - WebSocket. Connect to
/api/editor/room?agent=<name>and speak Yjs.src/editor-extension/room-client.tsworks 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-minbuild of the OSS tree.