Skip to content

Commit a7f393c

Browse files
committed
web/shell: fix round 1 (engine 18 patches, runtime, terminal links), re-verified; docs, README, e2e-shell
Each role's findings were fixed with tests that failed before, then attacked again by an independent verifier. Known open findings are in the round-2 reports and are fixed in the next commit. agent.js: the review's commit hook honours the shell's cancelled(); extension.js: agent.forDisplay.
1 parent d6d6ba0 commit a7f393c

81 files changed

Lines changed: 11663 additions & 682 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CLAUDE.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,19 @@ the desktop app exactly as it was:
110110
The embedder's command API is the module's `commands` (`api.commands.executeCommand`): `create()` returns a
111111
disposable, so `workbench.commands` is `undefined` — a helper that used it ran inside a `try` and silently did
112112
nothing until a browser test asked for its effect.
113+
- **LevelCode Shell (`web/shell/`, `docs/WEB.md` "LevelCode Shell") is a simulated bash, not a machine.** The
114+
contract is `web/shell/README.md`; where code and it disagree one is a bug. Rules: a command's output reaches a
115+
terminal **only** through `src/terminal/output-filter.js`, and OSC 633 comes **only** from
116+
`shell-integration.js`; a string that is not this extension's own constant (file names, command output, model
117+
text, provider errors) goes through `src/sanitize.js` before it is shown or put in a note; the engine worker
118+
never writes (the host validates and commits a change set, whole-or-nothing); the vendored `just-bash` is
119+
patched only by `engine/vendor/just-bash/<v>/apply-patches.mjs`, anchor-checked; a change to a shared
120+
`extensions/levelcode-ai` file that the desktop also runs is its own commit and leaves
121+
`agentRunCommand.test.js` untouched. `web/test/e2e-shell.mjs` is the browser check; the unit gate runs with
122+
`LEVELCODE_REQUIRE_XTERM=1 LEVELCODE_REQUIRE_WORKER=1` where the Code-OSS checkout is present.
123+
- The browser's `levelcode.*` settings are `application`-scoped except the display preferences in
124+
`WORKSPACE_SETTINGS_OK` (`web/ai-extension/manifest.js`): Workspace Trust is off in a tab, so an unscoped
125+
setting would be read from a folder's `.vscode/settings.json`.
113126
- The session store (`createSecretStorage`) belongs to every tab: no cache, one-key read-modify-write under a
114127
Web Lock. The refresh token is rotated on use; do not reintroduce a start-up snapshot.
115128
- Build: `node scripts/build-web.mjs --account <origin>` → `dist-web/`; serve with `node web/serve.mjs --dist dist-web`.

‎docs/WEB.md‎

Lines changed: 102 additions & 8 deletions
Large diffs are not rendered by default.

‎extensions/levelcode-ai/agent.js‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,10 +304,13 @@ function shellSpawnOpts(ctx, info) {
304304
const folders = (vscode.workspace.workspaceFolders || []).map((f) => ({ name: f.name, root: f.uri.fsPath, uri: f.uri.toString() }));
305305
return {
306306
folders,
307-
commit: async (changes) => {
307+
// `control.cancelled()` is the shell's own word that the run was stopped while its changes were being
308+
// checked: nothing is applied then (see commitShellChanges).
309+
commit: async (changes, control) => {
308310
const r = await commitShellChanges(changes, ctx, {
309311
folders: () => workspaceFolderList(),
310-
readNow: async (abs) => { try { return await host.readBytes(abs); } catch { return null; } }
312+
readNow: async (abs) => { try { return await host.readBytes(abs); } catch { return null; } },
313+
cancelled: () => !!(control && typeof control.cancelled === 'function' && control.cancelled())
311314
});
312315
return { rest: r.rest, notes: r.notes, failed: r.failed.length, refused: r.refused };
313316
}

‎extensions/levelcode-ai/host.js‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,13 +174,14 @@ const SHELL_API_VERSION = 1;
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
176176
* @property {string} [explanation] the model's own label for the command (at most 300 characters), for a tab that mirrors it
177-
* @property {(changes: Change[]) => Promise<{ rest: Change[], notes: string[], failed?: number, refused?: boolean }>} [commit]
177+
* @property {(changes: Change[], control?: { cancelled(): boolean }) => Promise<{ rest: Change[], notes: string[], failed?: number, refused?: boolean }>} [commit]
178178
* Agent runs only. Called once, after the command has run to its END (never after Stop, a timeout or an engine
179179
* limit: those apply nothing) and BEFORE 'close', with the files the command left changed. The caller validates
180180
* the whole set and reviews it, or refuses it whole; it returns what the shell still has to do itself (make and
181181
* remove directories), lines to append to the command's output, and how many changes it could NOT apply (the
182182
* shell then does not report success). Without it the shell writes the changes straight to the workspace (the
183-
* user's own terminal).
183+
* user's own terminal). `control.cancelled()` turns true when the run was stopped while the set was being
184+
* checked: the caller then applies nothing (once the first write has begun nothing interrupts it).
184185
*
185186
* @typedef {object} ChildLike The part of a Node ChildProcess that agent.js's runCommand uses.
186187
* @property {number} [pid] leave undefined: agent.js then kills with child.kill(signal)

‎extensions/levelcode-ai/shellCommit.js‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,9 @@ function isProtected(rel) { return PROTECTED.some((re) => re.test(rel)); }
123123
*
124124
* @param {Change[]} changes
125125
* @param {{ applyEdit?: Function, applyDelete?: Function, touched?: Set<string>, editCount?: number }} ctx
126-
* @param {{ folders: () => Array<{name:string, root:string}>, readNow: (abs: string) => Promise<Uint8Array|null>, isDirty?: (abs: string) => boolean }} io
126+
* @param {{ folders: () => Array<{name:string, root:string}>, readNow: (abs: string) => Promise<Uint8Array|null>, isDirty?: (abs: string) => boolean, cancelled?: () => boolean }} io
127+
* `cancelled` is asked between the read-only checks and once more before the first write: a Stop that arrives
128+
* while the files are being compared applies nothing. Once the first write has begun nothing interrupts it.
127129
* @returns {Promise<{ rest: Change[], applied: Change[], failed: Refusal[], notes: string[], refused: boolean }>}
128130
* `rest` is what the SHELL itself still has to do (create and remove directories). `refused` is true when
129131
* nothing was applied because the set as a whole was refused.
@@ -184,14 +186,21 @@ async function commitShellChanges(changes, ctx, io) {
184186
}
185187
}
186188
}
189+
const stopped = () => ({
190+
rest: /** @type {Change[]} */ ([]), applied: /** @type {Change[]} */ ([]), failed: /** @type {Refusal[]} */ ([]), refused: true,
191+
notes: ['[LevelCode Shell: stopped before any file change was applied. NONE of this command\'s file changes were applied.]'],
192+
});
193+
const isCancelled = () => { try { return !!(io.cancelled && io.cancelled()); } catch { return false; } };
187194
// Changed on disk since the command read it (a save, another tab)? Never overwrite what someone else wrote.
188195
if (!refusals.length) {
189196
for (const { c } of plan) {
197+
if (isCancelled()) { return stopped(); }
190198
const before = c.kind === 'write' ? (c.existed ? c.before : null) : c.before;
191199
const now = await io.readNow(c.path);
192200
if (!same(now, before)) { refusals.push({ c, why: 'it changed while the command ran, so the command\'s result is not applied over it' }); }
193201
}
194202
}
203+
if (isCancelled()) { return stopped(); }
195204

196205
if (refusals.length) {
197206
/** @type {string[]} */ const notes = [];

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,42 @@ const ids = (out) => out.failed.map((f) => f.c.path).sort();
5959
assert.match(out.notes[0], /3 files changed, ready for Keep\/Undo: ~a\.txt \+new\.txt -b\.txt/);
6060
});
6161

62+
await test('a Stop while the files are being compared applies nothing (and says so); it is asked before each check and once more before the first write', async () => {
63+
const r = rig({ '/ws/a.txt': 'old', '/ws/b.txt': 'old' });
64+
let calls = 0;
65+
const io = Object.assign({}, r.io, { cancelled: () => { calls++; return calls > 1; } }); // true from the second question on
66+
const out = await commitShellChanges([W('/ws/a.txt', 'new', 'old'), W('/ws/b.txt', 'new', 'old')], r.ctx, io);
67+
assert.strictEqual(out.refused, true);
68+
assert.deepStrictEqual(r.edits, []);
69+
assert.deepStrictEqual(r.deletes, []);
70+
assert.strictEqual(r.ctx.editCount, 0);
71+
assert.deepStrictEqual(out.rest, [], 'directories are not made either');
72+
assert.match(out.notes.join('\n'), /stopped before any file change was applied/);
73+
assert.match(out.notes[out.notes.length - 1], /NONE of this command's file changes were applied/);
74+
});
75+
76+
await test('a Stop that arrives after the last check but before the first write also applies nothing', async () => {
77+
const r = rig({ '/ws/a.txt': 'old' });
78+
let reads = 0;
79+
const io = Object.assign({}, r.io, {
80+
readNow: async (abs) => { reads++; return r.io.readNow(abs); },
81+
cancelled: () => reads > 0,
82+
});
83+
const out = await commitShellChanges([W('/ws/a.txt', 'new', 'old')], r.ctx, io);
84+
assert.strictEqual(out.refused, true);
85+
assert.deepStrictEqual(r.edits, []);
86+
});
87+
88+
await test('a cancelled() that throws, or an io without one, is "not cancelled"', async () => {
89+
const r1 = rig({ '/ws/a.txt': 'old' });
90+
const o1 = await commitShellChanges([W('/ws/a.txt', 'new', 'old')], r1.ctx, Object.assign({}, r1.io, { cancelled: () => { throw new Error('boom'); } }));
91+
assert.strictEqual(o1.refused, false);
92+
assert.strictEqual(r1.edits.length, 1);
93+
const r2 = rig({ '/ws/a.txt': 'old' });
94+
const o2 = await commitShellChanges([W('/ws/a.txt', 'new', 'old')], r2.ctx, r2.io);
95+
assert.strictEqual(o2.refused, false);
96+
});
97+
6298
await test('a deleted file is no longer verified', async () => {
6399
const r = rig({ '/ws/a.txt': 'x' });
64100
r.ctx.touched.add('/ws/a.txt');

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,27 @@ function ctxFor(over) {
221221
await p;
222222
});
223223

224+
await test('BROWSER, the shell says the run was stopped while its changes were being checked: the commit hook applies nothing', async () => {
225+
const { agent, host } = load(true);
226+
const calls = registerFake(host);
227+
const edits = [];
228+
const enc = (s) => new TextEncoder().encode(s);
229+
script = [tool('t1', 'run_command', { command: 'make-changes', explanation: 'x' }), say('Done: x')];
230+
const ctx = ctxFor({ applyEdit: async (r) => { edits.push(r); return true; }, applyDelete: async () => true });
231+
const p = agent.runAgent(ctx);
232+
await settle(); await settle();
233+
const commit = calls[0].opts.commit;
234+
const out = await commit([{ kind: 'write', path: root + '/new.txt', existed: false, before: null, after: enc('hi\n') }], { cancelled: () => true });
235+
assert.deepStrictEqual(edits, [], 'nothing was applied');
236+
assert.strictEqual(out.refused, true);
237+
assert.match(out.notes.join('\n'), /stopped before any file change was applied/);
238+
const again = await commit([{ kind: 'write', path: root + '/new.txt', existed: false, before: null, after: enc('hi\n') }]);
239+
assert.strictEqual(again.refused, false, 'a hook called without a control is not cancelled');
240+
assert.strictEqual(edits.length, 1);
241+
calls[0].child.emit('close', 0);
242+
await p;
243+
});
244+
224245
// ---- connecting: the shell extension is found lazily, once per run, and a failure is not remembered ----
225246
const fakeShellApi = (over) => ({ apiVersion: 1, agent: { info: { label: 'LevelCode Shell', background: false, programs: false, network: false, commands: ['cat'] }, spawn() { return fakeChild(); } }, ...over });
226247

‎web/lib/config.mjs‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,16 @@ export function webConfig(o = {}) {
6060
// modernUI fades the top 24px of every editor into the ground (a scroll shadow that is there at scroll
6161
// position zero too), which washes out line one. The editor starts below it.
6262
'editor.padding.top': 24,
63-
// Light or dark follows the visitor's system; the dark one is where it starts.
64-
// The terminal (LevelCode Shell): a scrollback that holds a screenful of output the agent printed, no file links
65-
// the workbench cannot resolve in a virtual workspace, only web links that may be followed, no toast when a
66-
// command's status is not 0, and no kitty keyboard protocol (the shell does not speak it).
63+
// The terminal (LevelCode Shell): a scrollback that holds a screenful of output the agent printed; the
64+
// workbench's own file links off (the shell's links.js is the one path: it knows the mounts and what exists);
65+
// only web links may be followed; no toast when a command's status is not 0; and no kitty keyboard
66+
// protocol (the shell does not speak it).
6767
'terminal.integrated.scrollback': 10000,
6868
'terminal.integrated.enableFileLinks': 'off',
6969
'terminal.integrated.allowedLinkSchemes': ['http', 'https', 'mailto'],
7070
'terminal.integrated.showExitAlert': false,
7171
'terminal.integrated.enableKittyKeyboardProtocol': false,
72+
// Light or dark follows the visitor's system; the dark one is where it starts.
7273
'window.autoDetectColorScheme': true,
7374
'workbench.colorTheme': 'LevelCode Web Dark',
7475
'workbench.preferredDarkColorTheme': 'LevelCode Web Dark',

0 commit comments

Comments
 (0)