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) isolated origin (no cookies)
┌──────────────────────────────┐ postMessage ┌────────────────────────────────────┐
│ /<o>/<r>/edit/<ref> │────token───▶│ /editor-frame (sandboxed iframe) │
│ WorkspaceEditor.tsx │◀──committed─│ workbench + FileSystemProvider │
│ mintEditorSession() server fn│ │ fetch /api/editor/* with Bearer │
└──────────────────────────────┘ └────────────────────────────────────┘
│
/api/editor/{session,tree,blob,commit}
▼
src/server/editor/api.ts → commitFileChanges()
Isolation and authentication
- The frame is
<iframe sandbox="allow-scripts allow-forms allow-modals allow-downloads">withoutallow-same-origin. Its origin is opaque (null), so it can't read the forge's cookies,localStorageor DOM. Fetches from it are cross-origin and send no credentials. /editor-frameis served with a strict CSP:default-src 'none'; script-src 'sha256-…'; style-src 'sha256-…'; connect-src <forge origin>; frame-ancestors 'self'; sandbox allow-scripts allow-modals. Because of the CSPsandboxdirective, the document stays opaque-origin even if it is opened as a top-level page. The page can only talk to the forge API, and only the forge can embed it. When the sites domain fromroadmap/siteslands, the frame moves toeditor.<sites-domain>. The protocol stays the same; onlyconnect-srcand the target origin change.- Tokens.
mintEditorSessionruns in the forge origin with the session cookie. It checks write or read access, then stores a randomgitfor_edt_…token inauth_tokens(kindeditor-session) with a one-hour TTL. That token is bound to one user, one repository and one ref. The parent passes it to the frame withpostMessage. Both sides check the message origin, and the frame only accepts its parent. On every request/api/editor/*re-checks the user's current repository permission, so removing a collaborator takes effect at once. Signing out everywhere, resetting a password or revoking the minting PAT also ends the session. The API never reads cookies. - The
/api/editor/*CORS policy isAccess-Control-Allow-Origin: *with no credentials. That is safe because a request is authorized only by the bearer token, and a browser never attaches one ambiently.
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
- Changes are staged in the browser as a map of path to new content, or to
nullfor a deletion. The frame shows a Changes list with a commit message box. POST /api/editor/commitaccepts{ message, changes[], expectedHeadSha, newBranch?, openPull? }. It callscommitFileChangesinsrc/server/git/web-commit.ts, the multi-file core thatcommitFileChangenow delegates to. As a result, GIT-7 path validation, branch protection (assertDirectUpdate), the archived-repository check, optimistic concurrency, push triggers and rate limits all apply unchanged.- Protected branches. If a direct update would be rejected, or if the user picks "new branch",
the server commits to
<user>-patch-<n>and opens a pull request against the original branch throughopenPull. It returns the PR URL, and the parent navigates there. - The request is capped at
MAX_CHANGESfiles andMAX_COMMIT_BYTESin total. The body is read through a capped stream, and duplicate or file-versus-directory paths are rejected. - If opening the PR fails, or the session can't open PRs (a PAT without
pulls: write), the commit still lands on the new branch and the response carriespullError.
Phase 1 frame vs the VS Code workbench
The frame protocol (token over postMessage, plus /api/editor/*) is deliberately independent of the
UI. Phase 1 ships a dependency-free "lite" workbench inline in /editor-frame, with an explorer, tabs,
an editor, staged changes and commit. It works today and passes the e2e test.
Phase 2 replaces the frame body with the VSCodium web build, served from R2 under an immutable,
hash-named prefix, plus a built-in web extension gitfor-fs. That extension:
- registers a
FileSystemProviderforgitfor://<owner>/<repo>/<ref>/…, wherereadDirectoryandstatcome from the tree listing andreadFilecomes from/api/editor/blob. - keeps writes in an in-memory overlay, and implements a
SourceControlprovider whose "Commit" posts the overlay to/api/editor/commit. - receives the token through the same
postMessagehandshake, viaworkbench.html→ extensionpostMessage.
Run in container (opt-in)
The pain point is that github.dev has no terminal. We add a button that starts an openvscode-server
Container (a Cloudflare Container bound to a Durable Object per (account, repo, branch)):
- It clones the ref with a short-lived deploy-style token. It pushes only through the normal git smart-HTTP path, so receive-pack hooks and protections still apply.
- Idle shutdown. The DO tracks the last WebSocket activity and calls
container.stop()after 15 minutes idle (sleepAfter). There is also a hard cap of 8 hours per session. - Billing hooks. Each start and stop writes a
container_sessionsrow (in the migration range 1100–1199) with the account, instance type and duration. A per-account monthly minute quota is checked before starting, using the same pattern assrc/server/lfs/quota.ts. This lets organization owners cap spend, unlike the Codespaces model of paying per hour by surprise. - The browser frame stays (a). The container only adds a terminal and tasks panel that talks to it over a WebSocket.
This isn't implemented in this branch. It needs the Containers binding and billing tables, and it is gated behind an account setting.
Live collaboration (stretch)
There is one EditorRoom Durable Object per (repo, branch), using the hibernation WebSocket API.
ProjectRoom already shows the pattern:
- Each open file is a
Y.Textin aY.Doc. The DO relays Yjs updates and awareness (presence and cursors). Because it hibernates, an idle room costs nothing. - Joining requires the same
editor-sessiontoken. The DO checks it on upgrade. - Commits still go through
/api/editor/commit, sent by whoever clicks Commit. The room then broadcasts the new head so other participants rebase their staged state.
Agents
Agents drive the same session the same way the frame does, so humans and agents see one source of truth:
- A user, or an automation running as a user, mints a session with
mintEditorSession. A PAT holder can also callPOST /api/editor/sessionsonce that is exposed through/api/v1. Either way the agent gets a ref-scoped, one-hour token instead of a broad PAT. - The agent calls
GET /api/editor/treeandGET /api/editor/blobto read, andPOST /api/editor/committo commit. It gets the same protections and the same automatic PR when the branch is protected. - With live collaboration, the agent joins the
EditorRoomas another Yjs peer, so a human watching the editor sees the agent's cursor and edits in real time before anything is committed. With Run in container, the agent can also run tests in the shared terminal.
Testing
scripts/e2e-editor.tstests the protocol: CSP, CORS, token-only auth, tree and blob reads, LFS and path guards, multi-file commits, stale heads, PRs for protected branches, and permission revocation.scripts/e2e-editor-browser.tsdrives headless Chrome over CDP. It signs in, presses., checks that the frame is opaque-origin and can't read cookies, edits and commits inside the frame, and then checks the commit.