177 lines · 13.5 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)                       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"> without allow-same-origin. Its origin is opaque (null), so it can't read the forge's cookies, localStorage or DOM. Fetches from it are cross-origin and send no credentials.
  • /editor-frame is 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 CSP sandbox directive, 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 from roadmap/sites lands, the frame moves to editor.<sites-domain>. The protocol stays the same; only connect-src and the target origin change.
  • Tokens. mintEditorSession runs in the forge origin with the session cookie. It checks write or read access, then stores a random gitfor_edt_… token in auth_tokens (kind editor-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 with postMessage. 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 is Access-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/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

  • Changes are staged in the browser as a map of path to new content, or to null for a deletion. The frame shows a Changes list with a commit message box.
  • POST /api/editor/commit accepts { message, changes[], expectedHeadSha, newBranch?, openPull? }. It calls commitFileChanges in src/server/git/web-commit.ts, the multi-file core that commitFileChange now 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 through openPull. It returns the PR URL, and the parent navigates there.
  • The request is capped at MAX_CHANGES files and MAX_COMMIT_BYTES in 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 carries pullError.

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 FileSystemProvider for gitfor://<owner>/<repo>/<ref>/…, where readDirectory and stat come from the tree listing and readFile comes from /api/editor/blob.
  • keeps writes in an in-memory overlay, and implements a SourceControl provider whose "Commit" posts the overlay to /api/editor/commit.
  • receives the token through the same postMessage handshake, via workbench.html → extension postMessage.

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_sessions row (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 as src/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.Text in a Y.Doc. The DO relays Yjs updates and awareness (presence and cursors). Because it hibernates, an idle room costs nothing.
  • Joining requires the same editor-session token. 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:

  1. A user, or an automation running as a user, mints a session with mintEditorSession. A PAT holder can also call POST /api/editor/sessions once that is exposed through /api/v1. Either way the agent gets a ref-scoped, one-hour token instead of a broad PAT.
  2. The agent calls GET /api/editor/tree and GET /api/editor/blob to read, and POST /api/editor/commit to commit. It gets the same protections and the same automatic PR when the branch is protected.
  3. With live collaboration, the agent joins the EditorRoom as 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.ts tests 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.ts drives 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.