@oonacode/vscode-ext — OonaCode for VS Code
A VS Code extension that is a thin client of the local OonaCode host service ("the brain"),
exactly like the desktop app, the oona CLI and the local web UI.
It does not bundle or start an agent runtime. It discovers the running host service through the
discovery file (%LOCALAPPDATA%\OonaCode\host\host.json on Windows), authenticates with the
per-install bearer token from that file, and hosts the shared @oonacode/web-ui React app in a
webview. If the brain is not running, it says so and offers to start it — it never silently does
nothing.
What it adds to the editor
| Command |
id |
What it does |
| OonaCode: Open OonaCode Panel |
oonacode.open |
Opens (or reveals) the webview panel with the shared chat UI |
| OonaCode: New Session in This Folder |
oonacode.newSessionHere |
Creates a session whose cwd is the workspace folder that owns the active editor, then reveals it in the panel (#session=<id>) |
| OonaCode: Open Web UI in Browser |
oonacode.openInBrowser |
Opens http://127.0.0.1:<port>/#token=… in the default browser |
| OonaCode: Start / Restart Host Service |
oonacode.restartBrain |
Spawns the host service detached and waits until it is healthy |
oonacode.open also takes an optional session id
(vscode.commands.executeCommand('oonacode.open', sessionId)), which the panel turns into the
shared UI's #session=<id> deep link.
A status bar item on the right shows the connection state — OonaCode when connected,
OonaCode: brain not running / auth failed / error otherwise (with a warning background) —
and runs oonacode.open when clicked. All diagnostics go to the OonaCode output channel;
bearer tokens are never logged (maskToken redacts them everywhere).
Settings
| Setting |
Default |
Scope |
Meaning |
oonacode.home |
"" |
machine |
Override the OonaCode home used to find the discovery file (same as OONACODE_HOME) |
oonacode.autoStartBrain |
false |
window |
Start the host service automatically on activation when it is down |
oonacode.hostCommand |
"oona" |
machine |
Command used to start the brain (oona, an absolute path, or a bin.js run with Node) |
oonacode.hostArgs |
["serve"] |
machine |
Arguments for that command |
oonacode.refreshIntervalMs |
5000 |
window |
Discovery + health poll interval |
oonacode.openPanelOnStartup |
false |
window |
Open the panel on startup when the brain is reachable |
oonacode.webUiDist |
"" |
machine |
Absolute path to a built @oonacode/web-ui dist (auto-detected when empty) |
The four machine-scoped settings are the ones a repository must never be able to set: they name
a program to execute (hostCommand/hostArgs), decide where the bearer token is read from
(home), and decide which HTML runs in a scripted webview (webUiDist). "scope": "machine"
means a checked-in .vscode/settings.json is ignored for them — only user/machine settings apply.
The extension also declares capabilities.untrustedWorkspaces.supported: false, so it does not
activate at all until the workspace is trusted, and on win32 it refuses a host command line
containing cmd.exe metacharacters (&, |, ^, <, >, ", %, !).
How it connects
- Discovery —
src/discovery.ts reuses the shared resolveOonaPaths() / parseDiscovery()
from @oonacode/shared (no path logic is reimplemented) to read host.json, then classifies it:
ok · missing · invalid · stale (the recorded pid is gone).
- Health — an authenticated
GET /api/v1/health. Health is the one route the host answers
without a token, and it does so with a reduced body ({ ok, appVersion }, no hostId) — so
a 200 without hostId is what a rejected token actually looks like, and it is reported as
its own state ("the token in the discovery file was rejected") exactly like a 401/403. A
different hostId means another process grabbed the port. All of these are distinct from
"not running".
The baseUrl recorded in the discovery file is only honoured when it is an http(s) URL on a
loopback host at the port the same file records; anything else falls back to
http://127.0.0.1:<port> (parseDiscovery in @oonacode/shared does not validate that field,
and it decides where the bearer token is sent).
- State machine —
src/connection.ts folds those outcomes into
unknown | checking | connected | not_running | unauthorized | error, which drives the status
bar, the command guards and the panel.
- Client —
src/host.ts is a typed REST client over HOST_ROUTES/HOST_ROUTE_METHODS plus a
small /api/v1/ws client over HostFrame/ClientFrame. Sockets come from
src/ws-client.ts, which prefers the runtime's own WebSocket and otherwise uses a built-in,
dependency-free RFC 6455 client (the extension bundles to one CommonJS file, so ws is not an
option, and an editor whose extension host predates Node 22 has no WebSocket global).
How the panel embeds the shared UI
src/panel.ts + src/webview-html.ts:
- the built
dist/index.html of @oonacode/web-ui is read from disk (src/web-ui-dist.ts looks at
the oonacode.webUiDist setting → require.resolve('@oonacode/web-ui/package.json') →
<extension>/media/web-ui → <extension>/../web-ui/dist → <extension>/node_modules/...);
- every local
src=/href= is rewritten through webview.asWebviewUri(), and crossorigin is
stripped;
- a strict CSP is injected:
default-src 'none', scripts only from webview.cspSource plus one
nonce, and connect-src narrowed to the exact loopback HTTP + WS origin of the running brain;
- the connection is injected as
window.oonacode — the same DesktopBridge contract the Electron
preload implements — so the shared UI needs no VS Code specific code. openExternal and
pickDirectory round-trip to the extension over postMessage (openExternal only accepts
http(s)/mailto);
enableScripts: true, retainContextWhenHidden: true, and localResourceRoots pinned to the
dist directory. Because a live stream must survive it, revealing an already-open panel never
re-assigns webview.html (that reloads the document and throws the transcript away): the panel
only re-renders for a different connection, a fallback view, or a new #session= deep link.
The transport is proxied (this is what makes the panel work at all)
A webview document's origin is vscode-webview://<uuid>. The host service allow-lists
app://oonacode and loopback http(s) origins only, answers anything else with
403 forbidden_origin, and emits no CORS headers at all — so a fetch() or a /api/v1/ws upgrade
made from the document can never succeed, no matter what the CSP allows.
So the injected bridge replaces window.fetch and window.WebSocket with shims:
- anything aimed at the brain's origin is tunnelled to the extension host over
postMessage
(hostFetch, wsOpen/wsSend/wsClose), where the panel performs it as a non-browser
client — no Origin header, so the host's allow-list is satisfied;
- anything aimed elsewhere falls through to the real
fetch/WebSocket, where the CSP still rules;
- the panel resolves every proxied path against the brain's base URL and refuses anything that
would leave that origin (
//evil.example.com/x and /\evil.example.com/x included), allows only
the /api/v1/ws path for sockets, caps the number of open sockets, and forwards only accept and
content-type from the document.
Consequently the real bearer token is never written into the webview: the document is handed an
inert stand-in (vscode-webview-proxy-<nonce>) and the extension attaches the real
Authorization: Bearer … itself. A token rotation therefore needs no re-render — the panel just
starts using the new one — and an attacker-authored dist cannot read a usable token out of the
page.
When the web UI build is missing, the panel renders a built-in fallback view with the connection
state and buttons for "Open web UI in browser" / "Start host service" / "Retry" — never a blank
panel.
Running it in the Extension Development Host
# once: build the contracts and the shared UI the panel embeds
pnpm --filter @oonacode/shared build
pnpm --filter @oonacode/web-ui build
# build the extension bundle (dist/extension.js)
pnpm --filter @oonacode/vscode-ext build
# start the brain in another terminal (or let the extension start it)
pnpm --filter @oonacode/host-service dev
Then open packages/vscode-ext in VS Code and press F5 ("Run Extension"). VS Code
launches a second window (the Extension Development Host) with the extension loaded; the status bar
should show OonaCode and OonaCode: Open OonaCode Panel should render the chat UI.
For an edit/reload loop run pnpm --filter @oonacode/vscode-ext dev (tsup --watch) and use
Developer: Reload Window in the development host.
Packaging with vsce
pnpm --filter @oonacode/web-ui build # the UI the panel embeds
pnpm --filter @oonacode/vscode-ext package # build + copy media/web-ui + vsce package
package runs tsc --noEmit, bundles src/extension.ts to a single CommonJS
dist/extension.js with tsup, copies the built web UI into media/web-ui
(scripts/copy-web-ui.mjs, needed because vsce does not follow pnpm workspace symlinks), and then
calls npx @vscode/vsce package --no-dependencies, producing oonacode-vscode.vsix. Install it
with code --install-extension oonacode-vscode.vsix.
vsce is intentionally not a dependency of this package — it is fetched on demand by the
package script.
Notes
- CommonJS on purpose. This is the one package in the monorepo without
"type": "module": the
VS Code extension host loads main with require(). The sources are still ESM TypeScript and are
bundled to CJS by tsup, so the repo conventions (.js import extensions, named exports, shared
contracts) are unchanged.
- No VS Code at test time. Every module takes the editor API as a parameter typed by
VSCodeApi (src/vscode-api.ts, a structural subset of @types/vscode), so pnpm test runs the
discovery, state-machine, webview-rewriting and command-mapping logic in plain Node against a fake.
The injected bridge is not exempt: test/bridge-script.test.ts executes it in a node:vm
realm with a fake acquireVsCodeApi and drives it the way the real web UI does, and
test/ws-client.test.ts runs the built-in WebSocket client against a real node:http upgrade.
- Not published. The extension is not on the VS Code Marketplace / Open VSX yet; install the
.vsix locally. A JetBrains plugin (the other half of requirement R11) is not built.