Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions extensions/levelcode-ai/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const fs = require('fs');
const path = require('path');
const cp = require('child_process');
const providers = require('./providers/index');
const { formatVerifyFeedback, verifyOutcome, looksUnrunnable, sniffPort, looksReady } = require('./verify');
const { formatVerifyFeedback, verifyOutcome, looksUnrunnable, sniffPort, sniffPreviewUrl, looksReady } = require('./verify');
const { classifyCommand, dangerLabel } = require('./commandSafety');
const { loadProjectRules } = require('./projectRules');
const { loadServerConfig, buildAgentTools, toolCountsByServer, classifyMcpTool, explainMcpRefusal } = require('./mcpConfig');
Expand Down Expand Up @@ -69,6 +69,11 @@ function buildSystem(menu) {
}
const TOOLS_TOKENS_EST = Math.round(JSON.stringify(TOOLS).length / 4);

// How much of a background command's accumulated output the sniffers re-read on each chunk. Generous
// next to any single log line, so a url split across chunk boundaries is still found, yet small enough
// that a noisy watcher which never prints an address costs nothing to keep scanning.
const SNIFF_TAIL = 8192;

const FILE_EXCLUDES = '{**/node_modules/**,**/.git/**,**/out/**,**/dist/**,**/.vscode-test/**,**/*.map}';

// ---- ripgrep search (self-contained) ---------------------------------------
Expand Down Expand Up @@ -385,15 +390,35 @@ async function runTool(tu, ctx) {
const stops = ctx.commandStops; // shared registry so the Stop button / ■ can kill the process group
// Only BACKGROUND commands get a registry entry (read_command_output reads it). Foreground
// one-shots keep their old behavior + don't accumulate — the model already gets their output.
const entry = bg ? { command: cmd, status: 'running', code: null, how: null, port: null, ready: false, ring: '', totalBytes: 0, lastReadOffset: 0, startedAt: Date.now() } : null;
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;
if (entry && ctx.commandRuns) { ctx.commandRuns.set(runId, entry); }
const onChunk = (chunk, stream) => {
ctx.post({ type: 'termOutput', id: runId, chunk: chunk, stream: stream });
if (entry) {
entry.ring = (entry.ring + chunk).slice(-100000); // bounded tail for read_command_output
entry.totalBytes += chunk.length;
if (!entry.port) { const p = sniffPort(chunk); if (p) { entry.port = p; ctx.post({ type: 'bgTask', id: runId, port: p }); } }
if (!entry.ready && looksReady(chunk)) { entry.ready = true; ctx.post({ type: 'bgTask', id: runId, ready: true }); }
// Sniff the accumulated TAIL, never the raw chunk: stdout arrives in arbitrary slices, so
// a line can straddle a boundary ("http://local" + "host:5173/") and match neither half.
// A few KB is far more than any single line needs, and bounding it keeps the rescan cheap
// for a chatty server that never prints an address at all. (Applies to all three sniffs —
// port and ready had the same latent gap.)
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 }); }
// Auto-preview: the moment a background command advertises a LOCAL address, offer to show
// it in the built-in browser. Fired at most ONCE per run — if the user closes the tab we
// must not reopen it on the next log line, and a restart-on-save server would otherwise
// spawn a tab per reload. The host decides whether to honour it (setting + dedupe).
if (!entry.previewUrl && typeof ctx.openPreview === 'function') {
const url = sniffPreviewUrl(tail);
if (url) {
entry.previewUrl = url;
dbg('preview.detected', { id: runId, url: url });
// Never let a preview reject inside a live stream handler — showing a browser tab
// must not be able to disturb a running command's output.
Promise.resolve(ctx.openPreview(url)).catch((e) => dbg('preview.rejected', { id: runId, error: String((e && e.message) || e) }));
}
}
}
};
const onExit = (code, ms, how) => {
Expand Down
43 changes: 42 additions & 1 deletion extensions/levelcode-ai/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const { registerInlineComplete } = require('./inlineComplete');
const { runAgent } = require('./agent');
const { findCompactionCut, estimateMsgTokens } = require('./agentMemory');
const { registerReview } = require('./reviewSession');
const { formatDiagnosticLines, diagKey } = require('./verify');
const { formatDiagnosticLines, diagKey, createPreviewGate } = require('./verify');
const { loadSkills, skillsMenu, getSkillBody } = require('./skills');
const { openCustomize } = require('./customize');
const { importFromVscode } = require('./importVscode');
Expand Down Expand Up @@ -665,6 +665,46 @@ function reapCommands() {
for (const [, stop] of commandStops) { try { stop(); } catch (e) { /* already gone */ } }
commandStops.clear();
bgRuns.clear();
previewGate.clear(); // the servers are gone; a fresh session may legitimately preview again
}

// Auto-preview (see openPreview): tracks which addresses have actually been SHOWN this session, so a
// chatty server can't stack tabs and — more importantly — so closing the tab is RESPECTED rather than
// undone by the next log line. A FAILED open stays retryable; see createPreviewGate.
const previewGate = createPreviewGate();

/**
* Show a locally-served URL in the built-in Simple Browser, beside the chat.
*
* This exists because the browser was already there and nobody knew: LevelCode ships VS Code's
* simple-browser, but you had to know the command name to find it. Opening it automatically the moment
* the agent brings a site up turns an invisible feature into the obvious one.
*
* Three deliberate choices: `preserveFocus` so a server coming up never steals the caret from whoever
* is typing; `Beside` so the site sits next to the work rather than replacing it; and dedupe-by-URL so
* the user closing the tab is final. Never throws — a preview must not be able to fail a run.
*
* The gate records a URL as shown only AFTER the open succeeds (PR #35 review): marking it up-front
* meant one transient failure — Simple Browser disabled for a moment — blacklisted that address for the
* whole session, so the preview silently never appeared again. See createPreviewGate.
*/
async function openPreview(url) {
if (!aiConfig().get('preview.autoOpen', true)) { return; }
if (!previewGate.shouldOpen(url)) { return; }
previewGate.begin(url);
try {
await vscode.commands.executeCommand('simpleBrowser.api.open', vscode.Uri.parse(url), {
preserveFocus: true,
viewColumn: vscode.ViewColumn.Beside
});
previewGate.succeeded(url); // it really appeared → never reopen, so closing the tab is final
post({ type: 'agentTool', icon: 'globe', text: '🌐 preview · ' + url });
} catch (e) {
// simple-browser disabled or the id moved upstream — log, never surface as a run failure. The
// URL stays retryable: nothing was shown, so a later run should be free to try again.
previewGate.failed(url);
dbg('preview.failed', { url: url, error: String((e && e.message) || e) });
}
}

// Workspace checkpoints: a per-user-turn stack of file pre-images so the user can roll the workspace back
Expand Down Expand Up @@ -987,6 +1027,7 @@ async function agentFlow(text) {
toolPolicy: userScopedSetting(cfg.inspect('mcp.toolPolicy'), {})
},
contextLimit: contextLimitFor(req.providerId, capsModel(req.model)), // Auto → flagship window; the model SENT stays req.model
openPreview: openPreview, // background server advertised a local URL → show it in-editor
commandStops: commandStops, // runId → stop() (process-group kill); used by Stop button / ■
commandRuns: bgRuns, // runId → background-process registry (read_command_output reads it)
commandTimeout: cfg.get('commandTimeout', 120000), // backstop before a command is force-killed (0 = off)
Expand Down
5 changes: 5 additions & 0 deletions extensions/levelcode-ai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,11 @@
"default": false,
"description": "Treat editor warnings (not just errors) as verification failures the agent must fix. Off by default — only errors block."
},
"levelcode.ai.preview.autoOpen": {
"type": "boolean",
"default": true,
"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`, or the IPv6 loopback `[::1]`. The bind addresses `0.0.0.0` and `[::]` count as local and are shown as `localhost`, since a browser can't resolve them. A remote URL printed by a dev script is ignored, never opened.\n\nEach address opens at most once per session, so closing the tab is respected, and your focus is never taken. Turn this off to open previews yourself with **Simple Browser: Show**."
},
"levelcode.ai.mcp.servers": {
"type": "object",
"default": {},
Expand Down
184 changes: 184 additions & 0 deletions extensions/levelcode-ai/test/verify.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/*---------------------------------------------------------------------------------------------
* Unit tests for verify.js's output sniffers — run: node test/verify.test.js
*
* These read a background command's STDOUT, which is whatever a repo's dev script chose to print —
* i.e. untrusted text. One direction is load-bearing:
*
* sniffPreviewUrl decides what the editor's built-in browser is pointed at, automatically. If it
* ever returns a REMOTE url, a hostile repo can navigate the user's editor anywhere just by logging
* a line. Every "remote" case below must return null (or the local url found elsewhere in the text).
* Erring toward opening nothing is always safe; erring toward opening is not.
*--------------------------------------------------------------------------------------------*/
// @ts-check
'use strict';

const assert = require('assert');
const { sniffPreviewUrl, sniffPort, looksReady, createPreviewGate } = require('../verify');

let n = 0;
function test(name, fn) { fn(); n++; console.log(' ok - ' + name); }

// ---- 1. the security bound: only LOCAL addresses may ever be opened ---------------------------

// Real-looking lines a hostile or merely misconfigured repo could print. None may be opened.
const REMOTE = [
'Local: http://evil.example.com:3000/',
' ➜ Network: https://attacker.test:8080/pwn',
'Server started at http://169.254.169.254:80/latest/meta-data', // cloud metadata endpoint
'listening: http://10.0.0.5:3000',
'open http://sub.domain.co.uk:4200 to view',
'http://localhost.evil.com:3000', // suffix trick — the host is NOT localhost
'http://127.0.0.1.evil.com:3000' // same trick with the loopback literal
];

test('SECURITY: remote-only output opens NOTHING', () => {
// Strictly null, not "null or some localhost url". An earlier version of this assertion allowed a
// localhost fallback, which quietly permitted the thing it was meant to forbid: the port would be
// re-extracted from the refused remote address and we'd open localhost:<their port> — a preview
// conjured entirely out of a line we ignored. If the fixture is remote-only, the answer is nothing.
for (const line of REMOTE) {
assert.strictEqual(sniffPreviewUrl(line), null,
'remote-only output must open nothing, got ' + JSON.stringify(sniffPreviewUrl(line)) +
' from ' + JSON.stringify(line));
}
});

test('SECURITY: a local url still wins when a remote one is printed alongside it', () => {
// Vite prints both; we must take the Local line and ignore Network, whatever its host.
const out = sniffPreviewUrl(' ➜ Local: http://localhost:5173/\n ➜ Network: http://192.168.1.14:5173/');
assert.strictEqual(out, 'http://localhost:5173/');
});

// ---- 2. finding the address the user actually wants -------------------------------------------

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');
});
Comment on lines +54 to +59

test('PREVIEW: bind addresses are rewritten to something a browser can actually resolve', () => {
// 0.0.0.0 / [::] mean "every interface", not a destination — opening them literally often fails.
assert.strictEqual(sniffPreviewUrl('Listening on http://0.0.0.0:8000'), 'http://localhost:8000');
assert.strictEqual(sniffPreviewUrl('serving on http://[::]:9000/'), 'http://localhost:9000/');
});

test('PREVIEW: falls back to localhost when only a port is announced', () => {
// Plenty of servers never print a url — the Express boilerplate is exactly this line.
assert.strictEqual(sniffPreviewUrl('Server running on port 3000'), 'http://localhost:3000');
assert.strictEqual(sniffPreviewUrl('listening on :8080'), 'http://localhost:8080');
});

test('PREVIEW: silence when nothing resembles a server', () => {
for (const quiet of ['', ' ', 'building...', 'Compiled 42 modules', 'error TS2304: cannot find name', null, undefined]) {
assert.strictEqual(sniffPreviewUrl(quiet), null, JSON.stringify(quiet) + ' should not open anything');
}
});

test('PREVIEW: garbage input returns null — not merely "does not throw"', () => {
// This runs inside a stdout handler, so not throwing is necessary but nowhere near sufficient:
// returning a non-null url would still pop a browser tab. Assert the value, not just the absence
// of an exception.
for (const junk of [{}, [], 42, true, Symbol.iterator.toString(), NaN, () => {}]) {
let got;
assert.doesNotThrow(() => { got = sniffPreviewUrl(/** @type {any} */(junk)); }, 'threw on ' + String(junk));
assert.strictEqual(got, null, 'opened something from junk input: ' + String(junk));
}
});

test('SPLIT: a url straddling two stdout chunks matches only once reassembled', () => {
// Why agent.js sniffs the accumulated ring TAIL rather than the raw chunk. runCommand streams
// arbitrary slices, so a dev server's address routinely arrives in two pieces — and each piece on
// its own is invisible to the sniffer, which would mean the preview silently never opened.
const first = ' ➜ Local: http://local';
const second = 'host:5173/\n';
assert.strictEqual(sniffPreviewUrl(first), null, 'the leading half must not match on its own');
assert.strictEqual(sniffPreviewUrl(second), null, 'the trailing half must not match on its own');
assert.strictEqual(sniffPreviewUrl(first + second), 'http://localhost:5173/', 'reassembled, it must');
});

// ---- 3. the sniffers the preview builds on (previously untested) ------------------------------

test('PORT: the most specific pattern wins, so later logs cannot masquerade as the server', () => {
assert.strictEqual(sniffPort('http://localhost:5173/'), '5173');
assert.strictEqual(sniffPort('Server running on port 3000'), '3000');
assert.strictEqual(sniffPort('no port here'), null);
// A url earlier in the text takes precedence over a bare "port N" mentioned later.
assert.strictEqual(sniffPort('http://localhost:5173/ ... connected to db on port 5432'), '5173');
});

test('READY: recognises the common "it is up" lines, and nothing else', () => {
for (const up of ['compiled successfully', 'Listening on :3000', 'server is running', 'ready in 412 ms', ' Local: http://x']) {
assert.strictEqual(looksReady(up), true, JSON.stringify(up));
}
for (const notUp of ['', 'building...', 'error: failed to compile']) {
assert.strictEqual(looksReady(notUp), false, JSON.stringify(notUp));
}
});

// ---- 3b. the preview gate: shown-once, but a FAILED open stays retryable ----------------------

test('GATE: a successful open is final — closing the tab is never undone', () => {
const g = createPreviewGate();
const url = 'http://localhost:3000';
assert.strictEqual(g.shouldOpen(url), true);
g.begin(url);
g.succeeded(url);
// The user may now close that tab. Nothing — no later run, no chatty log line — may reopen it.
assert.strictEqual(g.shouldOpen(url), false);
});

test('GATE: a FAILED open is retryable — the bug this gate exists for', () => {
// PR #35 review: the first version marked the URL as previewed BEFORE attempting to open it, so a
// single transient failure (Simple Browser disabled for a moment) blacklisted that address for the
// rest of the session — the preview then silently never appeared, with nothing to point at.
const g = createPreviewGate();
const url = 'http://localhost:5173';
g.begin(url);
g.failed(url);
assert.strictEqual(g.shouldOpen(url), true, 'a failed open must not suppress later attempts');
// …and a later attempt that works still closes the door exactly once.
g.begin(url); g.succeeded(url);
assert.strictEqual(g.shouldOpen(url), false);
});

test('GATE: an in-flight open blocks a concurrent duplicate', () => {
// Opening is async, so two runs advertising the same address could both pass the check and stack
// two tabs. This is why "have we shown it" alone is not enough state.
const g = createPreviewGate();
const url = 'http://localhost:8080';
g.begin(url);
assert.strictEqual(g.shouldOpen(url), false, 'must not open the same URL twice concurrently');
});

test('GATE: distinct URLs are independent, and clear() resets everything', () => {
const g = createPreviewGate();
g.begin('http://localhost:3000'); g.succeeded('http://localhost:3000');
assert.strictEqual(g.shouldOpen('http://localhost:4000'), true, 'a different port is a different site');
g.clear();
assert.strictEqual(g.shouldOpen('http://localhost:3000'), true, 'New Chat may legitimately preview again');
});

test('GATE: junk urls are never openable', () => {
const g = createPreviewGate();
for (const bad of [null, undefined, '']) { assert.strictEqual(g.shouldOpen(/** @type {any} */(bad)), false); }
});

// ---- 4. source hygiene ------------------------------------------------------------------------

test('SOURCE: verify.js contains no raw control bytes', () => {
// It shipped with a raw NUL in the diagKey separator, which made `file` report "data" and made grep
// and diff skip the whole module in silence — you could not search your own source, and a reviewer
// saw only "Binary file matches". The runtime value of an escape is identical, so nothing else
// catches this. (Same defect was caught by review in the MCP modules.)
const buf = require('fs').readFileSync(require('path').join(__dirname, '..', 'verify.js'));
const bad = [];
for (let i = 0; i < buf.length; i++) {
const b = buf[i];
if (b < 9 || (b > 13 && b < 32)) { bad.push(i); }
}
assert.deepStrictEqual(bad, [], 'raw control bytes at ' + bad.slice(0, 5).join(', ') + ' — use an escape');
});

console.log('\nverify.js: ' + n + ' tests passed.');
Binary file modified extensions/levelcode-ai/verify.js
Binary file not shown.