Skip to content

Commit d6d6ba0

Browse files
committed
agent: a model-written command is shown as it will run; the shell gets the model's label
Approval and command cards show the command and its explanation through host.displaySafe: the desktop's identity, and in a tab the shell's forDisplay, which makes bidi overrides, zero-width and other invisible characters visible so that what is approved is what runs. The shell is still handed the real string. SpawnOptions carries the model's label (at most 300 characters) for the tab that mirrors the agent's commands.
1 parent 73d9da8 commit d6d6ba0

3 files changed

Lines changed: 48 additions & 7 deletions

File tree

‎extensions/levelcode-ai/agent.js‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,7 @@ function runCommand(root, command, onChunk, onExit, onStart, timeoutMs, opts) {
459459
let child;
460460
// detached:true → the shell becomes its own process-group leader, so process.kill(-pid) kills the
461461
// whole tree (the shell AND the server/children it spawned), not just the shell (which orphans the server).
462-
try { child = host.spawn(command, { cwd: root, folders: opts && opts.folders, commit: opts && opts.commit }); }
462+
try { child = host.spawn(command, { cwd: root, folders: opts && opts.folders, commit: opts && opts.commit, explanation: opts && opts.explanation }); }
463463
catch (e) { if (onExit) { onExit(-1, 0, 'error'); } resolve('ERROR: ' + ((e && e.message) || e)); return; }
464464
const killGroup = (sig) => { try { if (child.pid) { process.kill(-child.pid, sig); } else { child.kill(sig); } } catch (e) { try { child.kill(sig); } catch (e2) { /* already gone */ } } };
465465
const stop = () => { if (settled || stopped) { return; } stopped = true; killGroup('SIGTERM'); setTimeout(() => { if (!settled) { killGroup('SIGKILL'); } }, 1500); };
@@ -603,11 +603,11 @@ async function runTool(tu, ctx) {
603603
// remote|shell, publish, system writes), which always asks. Manual mode asks for everything.
604604
const danger = ctx.autopilot ? classifyCommand(cmd) : { dangerous: true, category: null };
605605
const approved = danger.dangerous
606-
? await ctx.approve({ kind: 'command', command: cmd, explanation: input.explanation || '', danger: danger.category ? dangerLabel(danger.category) : null })
606+
? await ctx.approve({ kind: 'command', command: host.displaySafe(cmd), explanation: host.displaySafe(input.explanation || ''), danger: danger.category ? dangerLabel(danger.category) : null })
607607
: true;
608608
if (!approved) { return 'User skipped this command. Do not retry it.'; }
609609
const runId = tu.id || ('run-' + Date.now());
610-
ctx.post({ type: 'termRun', id: runId, command: cmd, cwd: path.basename(cwdRoot) || 'workspace', background: bg, explanation: input.explanation || '' });
610+
ctx.post({ type: 'termRun', id: runId, command: host.displaySafe(cmd), cwd: path.basename(cwdRoot) || 'workspace', background: bg, explanation: host.displaySafe(input.explanation || '') });
611611
const stops = ctx.commandStops; // shared registry so the Stop button / ■ can kill the process group
612612
// Only BACKGROUND commands get a registry entry (read_command_output reads it). Foreground
613613
// one-shots keep their old behavior + don't accumulate — the model already gets their output.
@@ -651,7 +651,7 @@ async function runTool(tu, ctx) {
651651
ctx.post({ type: 'termExit', id: runId, code: code, ms: ms, how: how });
652652
};
653653
const onStart = (child, stop) => { if (stops) { stops.set(runId, stop); } };
654-
const spawnOpts = Object.assign({ onData }, shellSpawnOpts(ctx, sh));
654+
const spawnOpts = Object.assign({ onData, explanation: String(input.explanation || '').slice(0, 300) }, shellSpawnOpts(ctx, sh));
655655
if (bg) {
656656
// Fire-and-forget: keep streaming + tracking, but return NOW so the agent loop isn't blocked.
657657
// No timeout — a background server is meant to run long (Stop / New Chat reap it).

‎extensions/levelcode-ai/host.js‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const isBrowser = !!globalThis.__LEVELCODE_BROWSER_HOST__;
3131
/**
3232
* A shell the browser build can borrow: a LevelCode-owned extension (web/shell) that exports one. It is
3333
* connected lazily (connectShell), once, the first time a run needs to know whether there is one.
34-
* @type {{ info: ShellInfo, spawn: (command: string, opts: SpawnOptions) => ChildLike } | null}
34+
* @type {{ info: ShellInfo, spawn: (command: string, opts: SpawnOptions) => ChildLike, forDisplay?: (text: string) => string } | null}
3535
*/
3636
let shell = null;
3737

@@ -173,6 +173,7 @@ const SHELL_API_VERSION = 1;
173173
* @property {string} cwd absolute path of a workspace folder, or inside one
174174
* @property {WorkspaceFolderRef[]} [folders] the workspace folders, live and in order (a shell mounts each at its root)
175175
* @property {Record<string,string>} [env] extra environment
176+
* @property {string} [explanation] the model's own label for the command (at most 300 characters), for a tab that mirrors it
176177
* @property {(changes: Change[]) => Promise<{ rest: Change[], notes: string[], failed?: number, refused?: boolean }>} [commit]
177178
* Agent runs only. Called once, after the command has run to its END (never after Stop, a timeout or an engine
178179
* limit: those apply nothing) and BEFORE 'close', with the files the command left changed. The caller validates
@@ -208,9 +209,22 @@ function spawn(command, opts) {
208209
/** The registered shell's description, or null (always null on the desktop: its shell is the user's own). */
209210
function shellInfo() { return shell ? shell.info : null; }
210211

212+
/**
213+
* A model-written string as the user is shown it on an approval card or a command card: invisible and
214+
* direction-changing characters made visible, so what is approved is what runs. The desktop shows text as it
215+
* always has; a shell that brings its own rules (the browser's) supplies them.
216+
* @param {string} text
217+
* @returns {string}
218+
*/
219+
function displaySafe(text) {
220+
const f = shell && shell.forDisplay;
221+
if (typeof f !== 'function') { return text; }
222+
try { const out = f(String(text)); return typeof out === 'string' ? out : text; } catch { return text; }
223+
}
224+
211225
/**
212226
* Register a shell. Called by connectShell; exported so a test (or a later in-process engine) can register one.
213-
* @param {{ info: ShellInfo, spawn: Function }} impl
227+
* @param {{ info: ShellInfo, spawn: Function, forDisplay?: (text: string) => string }} impl
214228
* @returns {{ dispose(): void }}
215229
*/
216230
function registerShell(impl) {
@@ -302,7 +316,7 @@ function findFiles(glob, exclude, max) {
302316

303317
module.exports = {
304318
isBrowser, caps,
305-
spawn, shellInfo, registerShell, connectShell,
319+
spawn, shellInfo, displaySafe, registerShell, connectShell,
306320
uriFor, readBytes, readText, readTextOrNull, stat, exists, isBinary, withReads, findFiles, openAuth,
307321
// For tests.
308322
_path: path,

‎extensions/levelcode-ai/test/shellSeam.test.js‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,33 @@ function ctxFor(over) {
271271
vscodeMock.extensions.getExtension = () => undefined;
272272
});
273273

274+
await test('BROWSER, shell registered: the card and the approval show the command through the shell\'s forDisplay; the shell is still handed the real one', async () => {
275+
const { agent, host } = load(true);
276+
const calls = registerFake(host, { forDisplay: (t) => t.replace(/\u202e/g, '[U+202E]') });
277+
const real = 'echo \u202eevil';
278+
script = [tool('d1', 'run_command', { command: real, explanation: 'say \u202ehi', }), say('Done: x')];
279+
const approvals = [];
280+
const ctx = ctxFor({ approve: async (r) => { approvals.push(r); return true; } });
281+
const p = agent.runAgent(ctx);
282+
await settle(); await settle();
283+
assert.strictEqual(approvals.length, 1);
284+
assert.strictEqual(approvals[0].command, 'echo [U+202E]evil');
285+
assert.strictEqual(approvals[0].explanation, 'say [U+202E]hi');
286+
const card = ctx.posted.find((m) => m.type === 'termRun');
287+
assert.strictEqual(card.command, 'echo [U+202E]evil');
288+
assert.strictEqual(calls[0].command, real, 'what runs is what the model wrote');
289+
assert.strictEqual(calls[0].opts.explanation, 'say \u202ehi', 'the mirror tab gets the label, to make safe itself');
290+
calls[0].child.emit('close', 0);
291+
await p;
292+
});
293+
294+
await test('DESKTOP: displaySafe is the identity, and nothing is registered', async () => {
295+
const { host } = load(false);
296+
assert.strictEqual(host.displaySafe('a\u202eb'), 'a\u202eb');
297+
const { host: bh } = load(true);
298+
assert.strictEqual(bh.displaySafe('x'), 'x', 'a browser with no shell, or one without forDisplay, shows text as it is');
299+
});
300+
274301
await test('BROWSER, shell registered: the verify command runs in the shell, and its changes are reviewed too', async () => {
275302
const { agent, host } = load(true);
276303
const calls = registerFake(host);

0 commit comments

Comments
 (0)