feat(ai): auto-open the built-in browser when the agent starts a web server#35
Merged
Conversation
…server
LevelCode has shipped VS Code's Simple Browser all along — but you had to know the
command name to find it, so nobody did. This makes it appear on its own: the
moment a background command advertises a local address, the site opens beside the
chat, and the agent keeps working while you watch the page update.
That pairs with how edits already land: the agent writes to disk immediately
(apply-then-review), so the dev server's watcher fires HMR and the preview updates
before you ever click Keep.
SECURITY — the reason sniffPreviewUrl is not just "find a URL". Its input is a
command's stdout, i.e. whatever a repo's dev script chose to print. Opening any
URL found there would let a hostile repo aim the editor's browser anywhere just by
logging a line ("Local: http://evil.example.com"). So the host must be LOCAL;
remote addresses are ignored, never opened. 0.0.0.0 and [::] are bind addresses
rather than destinations, so they're rewritten to localhost, which resolves.
Three UX choices worth stating:
- preserveFocus — a server coming up must never steal the caret from whoever is
typing, and one usually comes up mid-run.
- ViewColumn.Beside — the site sits next to the work instead of replacing it.
- dedupe by URL, once per session — so CLOSING the tab is final. Without it, a
chatty server's next log line would reopen what the user just dismissed, and a
restart-on-save server would stack a tab per reload.
Failure is swallowed: a preview must never be able to fail an agent run.
Off via levelcode.ai.preview.autoOpen.
Also fixes two things found on the way:
- verify.js shipped a RAW NUL byte in diagKey's separator, so `file` called it
"data" and grep/diff skipped the entire module in silence — the same defect
review caught in the MCP modules. Replaced with the backslash-u0000 escape
(identical runtime value) and pinned by a byte-level test.
- verify.js had NO tests at all, despite the plan claiming it was unit-tested.
It now has a suite, since auto-open depends on its sniffers.
Verified: 23 suites, 0 failures (verify.test.js is new: 10 cases). Both new
behaviours mutation-checked — dropping the local-host check opens
"http://evil.example.com:3000/" and fails the suite; not rewriting 0.0.0.0 fails
too. Simple Browser's api.open signature confirmed against the shipped extension
source, not assumed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR adds an auto-preview UX to extensions/levelcode-ai so that when the agent starts a background dev server and its stdout advertises a local address, LevelCode automatically opens that URL in the built-in Simple Browser beside the chat (without stealing focus), while continuing the agent run.
Changes:
- Introduces
sniffPreviewUrl()inverify.jsand wires it into the agent’s background command streaming to detect previewable local URLs. - Adds a host-side
openPreview()implementation (dedupe-by-URL per session,preserveFocus,ViewColumn.Beside) and passes it into the agent context. - Adds a new setting
levelcode.ai.preview.autoOpen(defaulttrue) and a newverify.test.jssuite covering both preview sniffing and the “no raw control bytes” invariant.
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| extensions/levelcode-ai/test/verify.test.js | Adds unit tests for verify.js sniffers and a byte-level control-character check. |
| extensions/levelcode-ai/package.json | Adds levelcode.ai.preview.autoOpen setting (default enabled) and documents the local-only security bound + dedupe behavior. |
| extensions/levelcode-ai/extension.js | Implements openPreview() using Simple Browser and session dedupe; clears preview state when reaping commands. |
| extensions/levelcode-ai/agent.js | Detects a preview URL from background command output and calls the host openPreview() once per run. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
openPreview added the URL to the "already previewed" set BEFORE attempting to open it. If simpleBrowser.api.open threw — Simple Browser disabled, or briefly unavailable — the URL stayed marked, so no later run in that session would retry and the preview silently never appeared, with nothing for the user to point at. The marking wasn't arbitrary, though: opening is async, so two runs advertising the same address could otherwise both pass the check and stack two tabs. So the fix keeps BOTH properties instead of just moving the line — three states rather than one Set: never-tried, in-flight, shown. begin() -> in-flight, blocks a concurrent duplicate succeeded() -> shown; never reopen, so closing the tab stays final failed() -> forget the attempt entirely; a later run may retry Extracted as createPreviewGate() in verify.js rather than left inline, because extension.js requires vscode and cannot be unit-tested — this is precisely the kind of state-machine bug that needs a test, and inline it would have had none. Verified: 23 suites, 0 failures (verify.test.js now 15 cases, 5 new). The fix is mutation-checked against the original bug: re-adding the URL to `shown` inside begin() fails with "a failed open must not suppress later attempts". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…l bound (PR #35 review) 1. The SECURITY test allowed sniffPreviewUrl to return a localhost URL for the remote-only fixtures, so it was weaker than the sentence above it claimed — and it was permitting a real leak, not a theoretical one. sniffPort's first pattern matches ANY http(s) URL, so after refusing a remote host the fallback re-extracted the port out of that same refused address: "Local: http://evil.example.com:3000/" -> "http://localhost:3000" A preview conjured entirely out of a line we had deliberately ignored. The fallback now blanks remote URLs first, so remote-only output yields null. The assertion is strict equality against null, matching its stated contract. A bare port with no host ("running on port 3000") still works — that is the Express-boilerplate case the fallback exists for — and mixed Local/Network output still picks the local one. 2. The setting description understated the bound: it said localhost / 127.0.0.1, while the implementation also accepts the IPv6 loopback [::1] and treats the bind addresses 0.0.0.0 and [::] as local (rewriting them to localhost, which a browser can actually resolve). Now says so. Verified: 23 suites, 0 failures. Mutation-checked — restoring the raw-text fallback reproduces the leak above and fails the suite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1. runCommand streams arbitrary stdout slices, so a dev server's address
routinely arrives split across a boundary — "http://local" + "host:5173/" —
and neither half matches. The preview would then silently never open, which is
the worst failure mode for this feature because there is nothing to point at.
Simulated against the real streaming path:
per-chunk (old): null
tail (new): "http://localhost:5173/"
The sniffs now read a bounded tail of entry.ring, which was already being
maintained for read_command_output. 8 KB is far more than any single log line
needs, and bounding it keeps the rescan cheap for a noisy watcher that never
prints an address at all.
Applied to ALL THREE sniffs, not just the preview: port and ready had the same
latent gap, and fixing only the new one would have left the identical bug in
place next to it.
2. ctx.openPreview is async and was called bare. Now wrapped so a rejection can
never escape into a live stream handler — showing a browser tab must not be
able to disturb a running command's output.
3. The junk-input test asserted only doesNotThrow. For a stdout handler that is
necessary but not sufficient: a non-null return would still pop a browser tab.
It now asserts the value is null, over a wider set of junk.
Verified: 23 suites, 0 failures; verify.test.js is 16 cases. The split-chunk case
is pinned with the real sniffer — each half must not match, the join must.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 5 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
extensions/levelcode-ai/agent.js:407
- The sniffer now only scans
entry.ring.slice(-SNIFF_TAIL). If a single stdout chunk is larger thanSNIFF_TAILand the server URL/port appears earlier in that chunk, it can be sliced away and never detected (regression vs scanning the whole chunk). Consider sniffing againstpreviousTail + chunkso you still handle boundary-splits without missing URLs that occur near the start of a large chunk.
const tail = entry.ring.slice(-SNIFF_TAIL);
if (!entry.port) { const p = sniffPort(tail); if (p) { entry.port = p; ctx.post({ type: 'bgTask', id: runId, port: p }); } }
if (!entry.ready && looksReady(tail)) { entry.ready = true; ctx.post({ type: 'bgTask', id: runId, ready: true }); }
Comment on lines
+54
to
+59
| test('PREVIEW: prefers the full printed url, keeping scheme, port and base path', () => { | ||
| assert.strictEqual(sniffPreviewUrl(' ➜ Local: http://localhost:5173/'), 'http://localhost:5173/'); | ||
| assert.strictEqual(sniffPreviewUrl('ready - started server on http://localhost:3000/app'), 'http://localhost:3000/app'); | ||
| assert.strictEqual(sniffPreviewUrl('https://localhost:8443/'), 'https://localhost:8443/'); | ||
| assert.strictEqual(sniffPreviewUrl('running at http://127.0.0.1:4200'), 'http://127.0.0.1:4200'); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
LevelCode has shipped VS Code's Simple Browser all along — but you had to know the command name to find it, so effectively nobody did. This makes it appear on its own: the moment a background command advertises a local address, the site opens beside the chat, and the agent keeps working while you watch the page update.
That compounds with how edits already land. The agent writes to disk immediately (apply-then-review — Keep/Undo happens after), so the dev server's watcher fires HMR and the preview updates before you ever click Keep. That's the "composing in real time" effect, with no extra machinery.
Security — why
sniffPreviewUrlisn't just "find a URL"Its input is a command's stdout: whatever a repo's dev script chose to print. If we opened any URL found there, a hostile repo could aim the editor's browser anywhere just by logging a line:
So the host must be local — remote addresses are ignored, never opened. This is the load-bearing property, and it's mutation-checked: removing the host check opens
http://evil.example.com:3000/and fails the suite.Also handled:
http://localhost.evil.com:3000andhttp://127.0.0.1.evil.com:3000are not local (the check is anchored).0.0.0.0and[::]mean "every interface", not a destination, so they're rewritten tolocalhost, which actually resolves.Local:andNetwork:; the local one wins regardless of what the network line says.Three UX choices worth stating
preserveFocus: trueViewColumn.BesideFailure is swallowed: a preview must never be able to fail an agent run. Off via
levelcode.ai.preview.autoOpen.Two pre-existing bugs found on the way
verify.jsshipped a raw NUL byte indiagKey's separator, sofilereported it asdataand grep and diff skipped the entire module in silence. I hit this trying to read the file — the same defect class review caught in the MCP modules, but this one predates that work. Replaced with the escape (identical runtime value) and pinned by a byte-level test.verify.jshad no tests at all, despitePLAN.mdclaiming it was "pure, unit-tested: 24 + 8 + 13 cases". It now has a suite, since auto-open depends on its sniffers.Verification
test/verify.test.jsis new (10 cases).0.0.0.0fails.simpleBrowser.api.open(uri, {preserveFocus, viewColumn})signature was confirmed against the shipped extension source, not assumed.Not covered by CI: the actual "tab opens beside the chat" path — that needs a built app and a live agent run. The sniffing and the security bound are unit-tested; the host call is verified by reading against the real Simple Browser source.
Try it
Ask the agent to start a dev server (
npm run dev). When it prints its address, the site should open beside the chat without taking focus. Close the tab and it stays closed.🤖 Generated with Claude Code