Skip to content

feat(web): LevelCode in your browser — the same workbench and AI extension as a static site, signed in with a LevelCode account - #105

Draft
ndemianc wants to merge 17 commits into
developfrom
feat/web-edition
Draft

ndemianc wants to merge 17 commits into
developfrom
feat/web-edition

Conversation

@ndemianc

@ndemianc ndemianc commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

What

LevelCode in your browser. The same workbench and the same levelcode-ai extension as a static site: open the page, sign in with a LevelCode account (or bring a provider key), and chat with the agent — nothing to install, no server of ours that runs code.

Works in a tab Stays in the Mac app
The workbench: explorer, tabs, themes, search, Quick Open A terminal; run_command; auto-verify commands
Chat, inline completion, the agent (read / search / create / edit / delete) MCP servers that start local processes
Keep / Undo review of the agent's edits Installing extensions (no marketplace in the page)
Sign in with a LevelCode account (gateway) or your own provider key Importing another editor's settings, Settings Sync, update check
A scratch workspace saved in the browser (any modern browser); a folder from your computer (File System Access: Chrome, Edge) The Notepad++ and customization packs, Agent Sketch

The agent is only offered the tools a tab can honour — the shell tools are withheld from the model rather than offered and failing, and the system prompt is built from the same lines so it never promises one.

Companion PRs: backend systemu-net/thin.ly#447 (a callback the editor's page may be sent a code at, CORS for exactly its origins, GET /web_editor) and the account site's entry points systemu-net/onetime#98 (/web, "Open in browser", docs). All three are off until the backend is configured.

How it fits

docs/WEB.md is the reference (architecture, sign-in sequence, build, the three origins, headers, security model, what was not run). In short:

  • scripts/build-web.mjs → one static directory: Code-OSS's web client (vscode-web-min-ci), the browser build of levelcode-ai, the scratch-workspace extension, the declarative built-ins, index.html with its configuration baked in, _headers and an nginx fragment. Everything but the two HTML pages is under a build-addressed /_/<id>/ and cached for a year.
  • web/ — the page (main.js: the embedder), the sign-in callback, the scratch workspace (levelcode-scratch: over IndexedDB, with file and text search), the browser build of the extension (esbuild + shims), the dev server, the tests.
  • The extension is the same source. Its Node-only assumptions are handled in two places: small shims for fs/path/os/crypto/child_process (private state only), and extensions/levelcode-ai/host.js for the workspace (async reads through the editor's file system, capability flags). On the desktop every host function does what the call site did before, behind await; nothing outside host.js asks "am I in a browser?".
  • Sign-in leaves the tab and comes back to it (a pop-up from a webview is what Safari and phones refuse): ?signin=1 / "Sign in" → <account>/ai/login with the existing PKCE flow → /callback.html → back to the editor, which delivers the result to the extension. The PKCE verifier lives in SecretStorage, so it survives the reload.
  • The session store belongs to every tab. The refresh token is rotated on use; a tab that cached a snapshot would present a token another tab had rotated away and end the session the other just renewed. Reads go to storage every time; a change is read-modify-write of one key under a Web Lock. (Found while writing the docs; it has its own tests.)

Where to look

  • Desktop-affecting (review these first): extensions/levelcode-ai/{agent,extension,reviewSession}.js, providers/anthropic.js, new host.js — 399 insertions, 67 deletions. The changes are workspace reads and writes becoming await host.…, a capability filter on the tool list, and host.openAuth. Anthropic gets its browser-access header only when __LEVELCODE_BROWSER_HOST__ is set.
  • Security-relevant: web/main.js (leaveForSignIn — the one command the page offers an extension, only to <account>/ai/…; the secret store; drain), web/callback.js, web/lib/headers.mjs (the CSP), and docs/WEB.md#security-model.
  • scripts/test-extensions.sh now also runs web/test/unit/*.test.js, so test.yml gates them on every PR.

Verified (commit c6d838b)

Check Result
Unit gate, macOS (scripts/test-extensions.sh) 70 test files pass
Unit gate, Linux, Node 18, container with the network refused 70 test files pass (it found two real gaps macOS hid, both fixed)
End to end, headless Chrome, a release served with its own _headers, against a stand-in account site 28/28 (boot from static files; sign in through the real callback page; agent write / list / search / read / edit against the scratch workspace; no shell offered; Quick Open; Keep and Undo; reload keeps the session and files; a folder opened through a real FileSystemDirectoryHandle, listed, read and written by the agent)
Same-tab sign-in, release 20/20 (no second window; verifier survives the reload; no repeat; ?signin=1 on a signed-in editor does nothing; a second tab uses the same session; a stranded result is delivered, an old one dropped)
Webview and extension host on origins of their own (dev server, --product) 28/28 and 20/20
Bring your own key 5/5: straight from the tab to the provider, with the user's key and no LevelCode call
The real stack (below) 23/23
Mutation checks each guard in main.test.js and workspace.test.js goes red when its code is put back

The real stack, run by hand-driven script in a real headless Chrome: Rails from thin.ly#447 in test mode (email never sent, Stripe answered locally, a throwaway Postgres database and Redis db), the real /ai/login page from onetime#98 (its dev server), and a release of this editor built with --account http://localhost:5199 --api-url https://…:

  • not signed in anywhere → ?signin=1 → the real login page ("Connect the editor to your account…") → email code typed → back in the editor, clean address, "Signed in to LevelCode.", real exchange 200, real profile and models 200;
  • signed in on the account site first → its "Open in browser" link → straight back signed in, nothing typed (POST /ai/authorize_editor, then the exchange);
  • a chat message through the real gateway and model ("ready", gpt-oss-120b, under 0.1 credits) — the request crossed the CORS preflight from the editor's origin;
  • the agent creating hello.txt in the scratch workspace with the real model, both gateway calls 200;
  • a folder opened through the editor's command (the origin-private file system standing in for the OS picker), the agent reading main.py and writing copy.py beside it.

What running it for real found, all fixed in this PR or the companions:

  1. "Open Folder from Your Computer" did nothing useful from the scratch workspace. The workbench picks its own file browser or the browser's picker by the scheme of the default location, which in the scratch workspace is ours. The command now asks from the file scheme (5393933).
  2. The build id did not cover the scratch-workspace extension, the shims, media or skills, so a changed file would have been served stale for a year under an "immutable" prefix (9c86c94).
  3. The account app's dev server answered POST /ai/authorize_editor with the SPA shell, so "Open in browser" could not work under npm run dev (onetime#98); and the login page treated the editor's callback page as a custom-scheme app launch, with a fallback that says the editor "may not be installed on this device" (onetime#98).

The editor's own end-to-end checks use a stand-in backend (web/test/stub-backend.mjs) that applies the redirect and CORS rules the real one must; the real rules are thin.ly#447's specs (785 examples, CI green). The real-stack script needs the other repositories' internals and is not part of this repo; docs/WEB.md has the steps to do it by hand.

A cold first load fetches about 20 MB uncompressed (5 MB gzip) over 147 files from the editor's origin, plus the webview host page; the release directory is about 195 MB.

Not run

  • The desktop app with this branch (./scripts/run-dev.sh): the suites that run agent.js and reviewSession.js against stand-ins pass, but I did not drive the real editor. Please do before merging.
  • Safari, Firefox, phones — Chrome only.
  • The operating system's folder picker itself (it needs a person), and the browser's permission prompt for a persisted folder handle after a reload.
  • A deployed account site, another provider's CORS, and production hosting (DNS, wildcard certificate, CDN, the real headers).
  • A long session across the 8-hour access-token boundary in a tab: the logic is the desktop's, unchanged.
  • A .github/workflows job for the web build and its end-to-end checks: not added (a Linux bootstrap and Chrome in CI is a separate piece, and I would not ship one I had not run).

Decisions that are yours

  1. The editor's domain — not a subdomain of the account host (the account cookie is SameSite=Lax, a site boundary; see docs/WEB.md#deploying). Which registrable domain, and who holds it.
  2. The webview origin — keep Code-OSS's CDN (no setup; a dependency on its hosting of this build's published page) or self-host a wildcard origin (*.view.example.com + certificate).
  3. Isolating the extension host — worth it before any third-party extension is ever allowed; unnecessary while the page loads only LevelCode's.
  4. Where entry points point — every "Open LevelCode" link carries ?signin=1.
  5. The support statement — Chrome/Edge for folders, scratch everywhere else, phones unstated until run.

… can do

The agent read and wrote the workspace with synchronous fs and assumed it could start
programs. LevelCode is about to run in a browser tab, where the project is whatever the
editor's file system provider says it is and there is no shell.

host.js is the one place that difference lives. On the desktop every function does what the
call site did before, behind await; capabilities (shell, mcpStdio, ripgrep) are all true.
Where a capability is missing the tool is withheld rather than offered and left to fail, the
system prompt says what is missing, and content search falls back to a bounded scan.

resolveWorkspacePath stays (the suites pin it); resolveWorkspacePathAsync is the same
resolution for hosts that can only ask asynchronously. A workspace rooted at '/' now
contains its files.

All 63 suites pass on macOS.
…ch workspace and the browser build of levelcode-ai

A static Code-OSS web build plus three things of ours:

- web/main.js: the embedder entry a static host needs (Code-OSS's own workbench.ts is written
  for its server). Same URL-callback and workspace-URL contracts; secrets sealed with a
  non-extractable AES-GCM key in IndexedDB; opens the scratch workspace when no folder is given.
- web/workspace: the scratch workspace, a FileSystemProvider over IndexedDB shared by every tab.
- web/ai-extension: levelcode-ai bundled for the web worker extension host by esbuild with
  small shims (path, os, crypto incl. a synchronous SHA-256, child_process that refuses, and an
  in-memory fs made durable in IndexedDB). The desktop extension stays plain JS.

web/test/e2e.mjs drives a real headless Chrome: boot, sign in through the real PKCE flow and
callback page against a stand-in backend that applies the same redirect and CORS rules, ask the
agent to create a file, reload.
…heck covers the agent's file tools

Quick Open, the Search view and workspace.findFiles (the agent's list_files) wait forever for a
provider registered for the folder's scheme. The scratch workspace now registers a file and a
text search provider (proposed APIs, declared by this built-in extension).

e2e: list, search, read and edit through the real tool loop, Quick Open, and a wait for the run
to finish between prompts (the send button is the stop button while a run is active).
…nd unit suites for the stand-ins

- web/ai-extension/manifest.js: the desktop manifest minus the commands, settings and welcome
  copy that need a shell, a disk or a local process; a key the browser lets through (Ctrl+Shift+I
  is developer tools on Windows and Linux).
- unit suites in the gate (scripts/test-extensions.sh now also runs web/test/unit): path and
  crypto are compared with Node over many inputs (the comparison found three differences in the
  first path shim), fs for the behaviours and error codes the extension relies on, host.js for
  both modes, the manifest for dangling references.
- the e2e also runs with webviews and the extension host on origins of their own, where the
  extension's requests come from a per-session v--<hash> origin.
…t's HTTP policy

Builds Code-OSS's web client (or takes a prebuilt one), bundles levelcode-ai for the web
worker extension host, stages the scratch workspace and the declarative built-ins, and lays it
all out under a build-addressed prefix (/_/<id>) so it caches for a year. index.html is
rendered with its configuration baked in. _headers and an nginx fragment carry the same CSP
and cache policy; web/serve.mjs --dist serves a release with them, and the end-to-end check
runs under that policy.

Findings that shaped it, each checked in a real browser:
- the checkout's web packaging emits only the ~20 extensions with a browser entry, so every
  declarative one (grammars, themes, icons) is staged here and loaded as an additional built-in;
- a marketplace is on by default and would install third-party code into the page's origin:
  the release sets extensionsGallery to null;
- each webview needs its own subdomain, so a self-hosted webview origin has to be a wildcard;
  the default is Code-OSS's CDN at this build's own commit (pinned insider commit is a service
  worker version behind);
- the Copilot status item is created before the web-only hide rule runs: hidden by CSS;
- the scratch workspace is /scratch, so the window and Explorer are not called "/".
…d check

- web/ai-extension/copy.js: the three desktop sentences about the OS keychain and 'your
  machine' are rewritten for a tab at build time, and the build fails if one is no longer in
  the source, so the browser never ships a stale claim about where a secret lives.
- web/test/e2e-byok.mjs: set a key through the real command, ask the agent, and see the request
  go from the tab straight to the provider with that key and no LevelCode account call.
- the stand-in backend gains an OpenAI-compatible provider endpoint with the CORS real ones send.
…gainst a release

Undo of a file the agent created removes it, and so does everything else still pending from the
session; Keep clears the bar. The account card that sign-in leaves open is closed the way a user
would, before the chat is clicked.
A pop-up opened from a chain that begins in a webview is the first thing a strict browser (Safari, a
phone) refuses. The editor now takes its own tab to the account site's sign-in and the sign-in
returns to the same tab:

- host.openAuth: in the browser the extension asks the page to navigate (levelcode.web.openAuthUrl,
  an embedder command) with the address the editor's own opener would have used; without the command
  it opens as before. The desktop is untouched.
- web/main.js registers the command. It only goes to the account site's /ai/ pages: any extension in
  the page can call it, and it must not be an open redirect.
- callback.html returns the tab to the editor when this tab left it (a sessionStorage mark), and
  otherwise says to close the tab and offers a link in.
- At start-up the editor delivers a result the sign-in left in localStorage (a callback.html result
  written in the last two minutes: the one-time code lives for one), and ?signin=1 — the account
  site's "Open in browser" — starts the sign-in once and removes itself from the address.

Tests: unit (host.openAuth; main.js's leaveForSignIn, drain and start-up, mutation-checked) and an
end-to-end in headless Chrome (web/test/e2e-signin-tab.mjs): no second window, the verifier survives
the reload, no repeat, no sign-in for a signed-in editor, a result waiting for a gone editor is
delivered and an old one is dropped.
…ted it

The refresh token is rotated on use. The browser secret store loaded everything once and rewrote
the whole thing on every change, so a second tab held the token the first had already rotated away:
its renewal was refused, ended the session the first tab had just renewed, and wrote its
start-up snapshot back over the first tab's tokens.

Now nothing is cached. A read is a read of what is stored; a change reads, changes ONE key and writes,
under a lock shared by the tabs (Web Locks; without it a tab still applies its own changes one at a
time). A browser that refuses the write keeps the session in memory for that tab rather than
completing a sign-in that is not there.

Tests: unit (two tabs over one store, the rotated token, concurrent writers, unreadable store, no
key, refused write — each mutation-checked) and a second tab in the end-to-end.
// Rough token estimates (chars/4) for the static prompt segments, so the context popover can break
// down "what's filling the window" — system + tools are sent on every request, the rest is messages.
const SYSTEM_TOKENS_EST = Math.round(SYSTEM_BASE.length / 4);
const SYSTEM_TOKENS_EST = Math.round(SYSTEM_BASE_HOST.length / 4);
Comment thread web/test/e2e-byok.mjs
// The provider settings (custom, OpenAI-compatible, at the stand-in's address) are configuration defaults of
// the development server; a release bakes its own defaults, so this runs against the development layout.
import { spawn } from 'node:child_process';
import fs from 'node:fs';
Comment thread web/test/e2e.mjs
const r = e.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
})()`);
const clickText = async (re, label) => {
Comment thread web/test/e2e.mjs
await page.click(r.x, r.y);
await sleep(600);
};
const chatSession = () => {
…cker

Run for the first time against a real browser session and the real login page, the command did nothing useful from
the default scratch workspace: the workbench chooses between its own file browser and the browser's picker by the
scheme of the default location, and in the scratch workspace that scheme is ours — so it showed the scratch
workspace's own folders. The command now asks the dialog from the `file` scheme, which the web workbench serves with
the File System Access handles the user picks, and opens what came back in this tab.

Tests: a unit test of the command against a stand-in for vscode (mutation-checked), and a section of the
end-to-end check that opens a folder through a real FileSystemDirectoryHandle (the origin-private file system
stands in for the OS picker) and has the agent list, read and write there.
A release puts everything under /_/<id>/ and tells browsers it never changes. The id was a hash of the client, the
page and the extension's top-level .js files, so a changed scratch-workspace extension, shim, media file or skill
kept its id — and would have been served stale, from cache, for a year. The hash now covers the extension's whole
tree, its browser build, the scratch workspace and the page's configuration code.
The extension already supports an API host that is not the account origin (levelcode.cloud.apiUrl); the build can
now bake it in, which is what a local account site with the backend behind a tunnel needs. docs/WEB.md says how to
run the real stack locally and what to look for, and what was and was not run.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant