Skip to content

Commit b438b6c

Browse files
ndemiancclaude
andcommitted
feat(ai): auto-open the built-in browser when the agent starts a web 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>
1 parent a676f6a commit b438b6c

5 files changed

Lines changed: 167 additions & 2 deletions

File tree

‎extensions/levelcode-ai/agent.js‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const fs = require('fs');
1414
const path = require('path');
1515
const cp = require('child_process');
1616
const providers = require('./providers/index');
17-
const { formatVerifyFeedback, verifyOutcome, looksUnrunnable, sniffPort, looksReady } = require('./verify');
17+
const { formatVerifyFeedback, verifyOutcome, looksUnrunnable, sniffPort, sniffPreviewUrl, looksReady } = require('./verify');
1818
const { classifyCommand, dangerLabel } = require('./commandSafety');
1919
const { loadProjectRules } = require('./projectRules');
2020
const { loadServerConfig, buildAgentTools, toolCountsByServer, classifyMcpTool, explainMcpRefusal } = require('./mcpConfig');
@@ -385,7 +385,7 @@ async function runTool(tu, ctx) {
385385
const stops = ctx.commandStops; // shared registry so the Stop button / ■ can kill the process group
386386
// Only BACKGROUND commands get a registry entry (read_command_output reads it). Foreground
387387
// one-shots keep their old behavior + don't accumulate — the model already gets their output.
388-
const entry = bg ? { command: cmd, status: 'running', code: null, how: null, port: null, ready: false, ring: '', totalBytes: 0, lastReadOffset: 0, startedAt: Date.now() } : null;
388+
const entry = bg ? { command: cmd, status: 'running', code: null, how: null, port: null, ready: false, previewUrl: null, ring: '', totalBytes: 0, lastReadOffset: 0, startedAt: Date.now() } : null;
389389
if (entry && ctx.commandRuns) { ctx.commandRuns.set(runId, entry); }
390390
const onChunk = (chunk, stream) => {
391391
ctx.post({ type: 'termOutput', id: runId, chunk: chunk, stream: stream });
@@ -394,6 +394,14 @@ async function runTool(tu, ctx) {
394394
entry.totalBytes += chunk.length;
395395
if (!entry.port) { const p = sniffPort(chunk); if (p) { entry.port = p; ctx.post({ type: 'bgTask', id: runId, port: p }); } }
396396
if (!entry.ready && looksReady(chunk)) { entry.ready = true; ctx.post({ type: 'bgTask', id: runId, ready: true }); }
397+
// Auto-preview: the moment a background command advertises a LOCAL address, offer to show
398+
// it in the built-in browser. Fired at most ONCE per run — if the user closes the tab we
399+
// must not reopen it on the next log line, and a restart-on-save server would otherwise
400+
// spawn a tab per reload. The host decides whether to honour it (setting + dedupe).
401+
if (!entry.previewUrl && typeof ctx.openPreview === 'function') {
402+
const url = sniffPreviewUrl(chunk);
403+
if (url) { entry.previewUrl = url; dbg('preview.detected', { id: runId, url: url }); ctx.openPreview(url); }
404+
}
397405
}
398406
};
399407
const onExit = (code, ms, how) => {

‎extensions/levelcode-ai/extension.js‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,38 @@ function reapCommands() {
665665
for (const [, stop] of commandStops) { try { stop(); } catch (e) { /* already gone */ } }
666666
commandStops.clear();
667667
bgRuns.clear();
668+
previewedUrls.clear(); // the servers are gone; a fresh session may legitimately preview again
669+
}
670+
671+
// Auto-preview (see openPreview): URLs already shown this session, so a chatty server can't stack tabs
672+
// and — more importantly — so closing the tab is RESPECTED rather than undone by the next log line.
673+
const previewedUrls = new Set();
674+
675+
/**
676+
* Show a locally-served URL in the built-in Simple Browser, beside the chat.
677+
*
678+
* This exists because the browser was already there and nobody knew: LevelCode ships VS Code's
679+
* simple-browser, but you had to know the command name to find it. Opening it automatically the moment
680+
* the agent brings a site up turns an invisible feature into the obvious one.
681+
*
682+
* Three deliberate choices: `preserveFocus` so a server coming up never steals the caret from whoever
683+
* is typing; `Beside` so the site sits next to the work rather than replacing it; and dedupe-by-URL so
684+
* the user closing the tab is final. Never throws — a preview must not be able to fail a run.
685+
*/
686+
async function openPreview(url) {
687+
if (!aiConfig().get('preview.autoOpen', true)) { return; }
688+
if (!url || previewedUrls.has(url)) { return; }
689+
previewedUrls.add(url);
690+
try {
691+
await vscode.commands.executeCommand('simpleBrowser.api.open', vscode.Uri.parse(url), {
692+
preserveFocus: true,
693+
viewColumn: vscode.ViewColumn.Beside
694+
});
695+
post({ type: 'agentTool', icon: 'globe', text: '🌐 preview · ' + url });
696+
} catch (e) {
697+
// simple-browser disabled or the id moved upstream — log, never surface as a run failure.
698+
dbg('preview.failed', { url: url, error: String((e && e.message) || e) });
699+
}
668700
}
669701

670702
// Workspace checkpoints: a per-user-turn stack of file pre-images so the user can roll the workspace back
@@ -987,6 +1019,7 @@ async function agentFlow(text) {
9871019
toolPolicy: userScopedSetting(cfg.inspect('mcp.toolPolicy'), {})
9881020
},
9891021
contextLimit: contextLimitFor(req.providerId, capsModel(req.model)), // Auto → flagship window; the model SENT stays req.model
1022+
openPreview: openPreview, // background server advertised a local URL → show it in-editor
9901023
commandStops: commandStops, // runId → stop() (process-group kill); used by Stop button / ■
9911024
commandRuns: bgRuns, // runId → background-process registry (read_command_output reads it)
9921025
commandTimeout: cfg.get('commandTimeout', 120000), // backstop before a command is force-killed (0 = off)

‎extensions/levelcode-ai/package.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,11 @@
436436
"default": false,
437437
"description": "Treat editor warnings (not just errors) as verification failures the agent must fix. Off by default — only errors block."
438438
},
439+
"levelcode.ai.preview.autoOpen": {
440+
"type": "boolean",
441+
"default": true,
442+
"markdownDescription": "When the agent starts a web server in the background, automatically open the site in LevelCode's built-in browser, beside the chat.\n\nOnly **local** addresses are ever opened (`localhost` / `127.0.0.1`), and each address opens at most once per session — so closing the tab is respected. Your focus is never taken. Turn this off to open previews yourself with **Simple Browser: Show**."
443+
},
439444
"levelcode.ai.mcp.servers": {
440445
"type": "object",
441446
"default": {},
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Unit tests for verify.js's output sniffers — run: node test/verify.test.js
3+
*
4+
* These read a background command's STDOUT, which is whatever a repo's dev script chose to print —
5+
* i.e. untrusted text. One direction is load-bearing:
6+
*
7+
* sniffPreviewUrl decides what the editor's built-in browser is pointed at, automatically. If it
8+
* ever returns a REMOTE url, a hostile repo can navigate the user's editor anywhere just by logging
9+
* a line. Every "remote" case below must return null (or the local url found elsewhere in the text).
10+
* Erring toward opening nothing is always safe; erring toward opening is not.
11+
*--------------------------------------------------------------------------------------------*/
12+
// @ts-check
13+
'use strict';
14+
15+
const assert = require('assert');
16+
const { sniffPreviewUrl, sniffPort, looksReady } = require('../verify');
17+
18+
let n = 0;
19+
function test(name, fn) { fn(); n++; console.log(' ok - ' + name); }
20+
21+
// ---- 1. the security bound: only LOCAL addresses may ever be opened ---------------------------
22+
23+
// Real-looking lines a hostile or merely misconfigured repo could print. None may be opened.
24+
const REMOTE = [
25+
'Local: http://evil.example.com:3000/',
26+
' ➜ Network: https://attacker.test:8080/pwn',
27+
'Server started at http://169.254.169.254:80/latest/meta-data', // cloud metadata endpoint
28+
'listening: http://10.0.0.5:3000',
29+
'open http://sub.domain.co.uk:4200 to view',
30+
'http://localhost.evil.com:3000', // suffix trick — the host is NOT localhost
31+
'http://127.0.0.1.evil.com:3000' // same trick with the loopback literal
32+
];
33+
34+
test('SECURITY: a remote url in command output is never opened', () => {
35+
for (const line of REMOTE) {
36+
const got = sniffPreviewUrl(line);
37+
assert.ok(
38+
got === null || /^https?:\/\/(?:localhost|127\.0\.0\.1)(?::|\/|$)/.test(got),
39+
'opened a non-local address from ' + JSON.stringify(line) + ' -> ' + JSON.stringify(got)
40+
);
41+
}
42+
});
43+
44+
test('SECURITY: a local url still wins when a remote one is printed alongside it', () => {
45+
// Vite prints both; we must take the Local line and ignore Network, whatever its host.
46+
const out = sniffPreviewUrl(' ➜ Local: http://localhost:5173/\n ➜ Network: http://192.168.1.14:5173/');
47+
assert.strictEqual(out, 'http://localhost:5173/');
48+
});
49+
50+
// ---- 2. finding the address the user actually wants -------------------------------------------
51+
52+
test('PREVIEW: prefers the full printed url, keeping scheme, port and base path', () => {
53+
assert.strictEqual(sniffPreviewUrl(' ➜ Local: http://localhost:5173/'), 'http://localhost:5173/');
54+
assert.strictEqual(sniffPreviewUrl('ready - started server on http://localhost:3000/app'), 'http://localhost:3000/app');
55+
assert.strictEqual(sniffPreviewUrl('https://localhost:8443/'), 'https://localhost:8443/');
56+
assert.strictEqual(sniffPreviewUrl('running at http://127.0.0.1:4200'), 'http://127.0.0.1:4200');
57+
});
58+
59+
test('PREVIEW: bind addresses are rewritten to something a browser can actually resolve', () => {
60+
// 0.0.0.0 / [::] mean "every interface", not a destination — opening them literally often fails.
61+
assert.strictEqual(sniffPreviewUrl('Listening on http://0.0.0.0:8000'), 'http://localhost:8000');
62+
assert.strictEqual(sniffPreviewUrl('serving on http://[::]:9000/'), 'http://localhost:9000/');
63+
});
64+
65+
test('PREVIEW: falls back to localhost when only a port is announced', () => {
66+
// Plenty of servers never print a url — the Express boilerplate is exactly this line.
67+
assert.strictEqual(sniffPreviewUrl('Server running on port 3000'), 'http://localhost:3000');
68+
assert.strictEqual(sniffPreviewUrl('listening on :8080'), 'http://localhost:8080');
69+
});
70+
71+
test('PREVIEW: silence when nothing resembles a server', () => {
72+
for (const quiet of ['', ' ', 'building...', 'Compiled 42 modules', 'error TS2304: cannot find name', null, undefined]) {
73+
assert.strictEqual(sniffPreviewUrl(quiet), null, JSON.stringify(quiet) + ' should not open anything');
74+
}
75+
});
76+
77+
test('PREVIEW: garbage input returns null instead of throwing', () => {
78+
// This runs inside a stdout handler — a throw here would take down a live command stream.
79+
for (const junk of [{}, [], 42, true, Symbol.iterator.toString()]) {
80+
assert.doesNotThrow(() => sniffPreviewUrl(/** @type {any} */(junk)));
81+
}
82+
});
83+
84+
// ---- 3. the sniffers the preview builds on (previously untested) ------------------------------
85+
86+
test('PORT: the most specific pattern wins, so later logs cannot masquerade as the server', () => {
87+
assert.strictEqual(sniffPort('http://localhost:5173/'), '5173');
88+
assert.strictEqual(sniffPort('Server running on port 3000'), '3000');
89+
assert.strictEqual(sniffPort('no port here'), null);
90+
// A url earlier in the text takes precedence over a bare "port N" mentioned later.
91+
assert.strictEqual(sniffPort('http://localhost:5173/ ... connected to db on port 5432'), '5173');
92+
});
93+
94+
test('READY: recognises the common "it is up" lines, and nothing else', () => {
95+
for (const up of ['compiled successfully', 'Listening on :3000', 'server is running', 'ready in 412 ms', ' Local: http://x']) {
96+
assert.strictEqual(looksReady(up), true, JSON.stringify(up));
97+
}
98+
for (const notUp of ['', 'building...', 'error: failed to compile']) {
99+
assert.strictEqual(looksReady(notUp), false, JSON.stringify(notUp));
100+
}
101+
});
102+
103+
// ---- 4. source hygiene ------------------------------------------------------------------------
104+
105+
test('SOURCE: verify.js contains no raw control bytes', () => {
106+
// It shipped with a raw NUL in the diagKey separator, which made `file` report "data" and made grep
107+
// and diff skip the whole module in silence — you could not search your own source, and a reviewer
108+
// saw only "Binary file matches". The runtime value of an escape is identical, so nothing else
109+
// catches this. (Same defect was caught by review in the MCP modules.)
110+
const buf = require('fs').readFileSync(require('path').join(__dirname, '..', 'verify.js'));
111+
const bad = [];
112+
for (let i = 0; i < buf.length; i++) {
113+
const b = buf[i];
114+
if (b < 9 || (b > 13 && b < 32)) { bad.push(i); }
115+
}
116+
assert.deepStrictEqual(bad, [], 'raw control bytes at ' + bad.slice(0, 5).join(', ') + ' — use an escape');
117+
});
118+
119+
console.log('\nverify.js: ' + n + ' tests passed.');

‎extensions/levelcode-ai/verify.js‎

1.78 KB
Binary file not shown.

0 commit comments

Comments
 (0)