Skip to content

web: LevelCode Shell — a terminal and run_command in the tab (stage 1: no programs, no network) - #107

Draft
ndemianc wants to merge 22 commits into
feat/web-lookfrom
feat/web-shell
Draft

ndemianc wants to merge 22 commits into
feat/web-lookfrom
feat/web-shell

Conversation

@ndemianc

Copy link
Copy Markdown
Contributor

LevelCode Shell — stage 1 of running things in the tab

A terminal panel and the agent's run_command in the browser edition, with no programs and no network: a bash
interpreter in JavaScript (the vendored, patched just-bash 3.6.0,
Apache-2.0) over vscode.workspace.fs. It parses bash and runs 82 coreutils-like commands (ls cat grep sed awk jq find sort uniq xargs diff tree …). npm test is "command not found", 127, and the agent is told so in its prompt.

Stacked on #106 (the look), which is stacked on #105 (the edition). Draft. Nothing changes for the desktop app: the
commits that touch shared extensions/levelcode-ai code are separable (host.js seam, agent.js, shellCommit.js),
and the desktop path is byte-for-byte what it was (pinned by shellSeam.test.js; agentRunCommand.test.js untouched).

What a visitor gets

  • LevelCode Shell: New Terminal (palette, Ctrl+`, or the button in an empty Terminal panel): a persistent working
    folder and exported variables, history, Tab completion, Ctrl+C that really stops a runaway loop (the engine runs in a
    nested Worker and is terminate()d), and path:line:col in output is a link to the file at that line.
  • The agent's commands run in it too, are mirrored (read-only, without taking the focus) in a LevelCode Agent
    tab, and what they change goes through the same review as edit_file: Keep and Undo.

The safety rules (what to look at in review)

  1. The engine cannot write. Copy-on-write overlay over a read-only RPC; the host validates and commits a change
    set, everything reviewed or the whole command refused (extensions/levelcode-ai/shellCommit.js for the
    agent, web/shell/src/commit.js for the visitor's own terminal): outside the folders, protected paths (.git .vscode .levelcode .cursor .husky .github/workflows AGENTS.md CLAUDE.md …), binary/BOM/>1 MiB, line-ending changes, dirty
    buffers, changed on disk, case-only renames, >300 files — one refusal and none of the command's changes are
    applied. A stopped, timed-out or limit-hit command applies nothing. The commit is bounded in time and a Stop ends
    it at once.
  2. No secrets, no network in the engine worker; its only file operations are three bounded reads.
  3. Untrusted text (file names, command output, model text, provider errors) reaches a terminal only through an
    allow-list filter and a card only through forDisplay; replies xterm would type into the prompt are swallowed;
    OSC 633 only from one module with a per-terminal nonce.
  4. Settings provenance: Workspace Trust is off in a tab, so every levelcode.* setting is application-scoped
    except an explicit display allow-list — a folder's .vscode/settings.json can no longer redirect the session token
    or turn Autopilot on (found while reviewing this, measured in the real workbench).

Verification — what was run, and what was not

  • Unit gate (./scripts/test-extensions.sh, 117 files): macOS Node 24 with the real xterm.js 6.1 (headless) as the
    oracle for the line editor and LEVELCODE_REQUIRE_XTERM=1/LEVELCODE_REQUIRE_WORKER=1; macOS Node 18.20.5; and, on an
    earlier commit of this branch, Linux Node 24 and 18 in docker (the Docker engine became unavailable before the last
    engine commit, so those two were not re-run on it). The runtime suite runs the real engine in a worker thread;
    CI sets LEVELCODE_REQUIRE_WORKER=1.
  • Differential against GNU bash 5.1 / coreutils 8.32 (docker recordings): 905 golden cases (837 identical, 68
    listed with a reason in a shrink-only table), 1,778 cp/mv operand shapes, 568 xargs groups, 596 time zones; a seeded
    change-set consistency fixture; 4,600 fuzz scripts under three process time zones. Final independent corpus:
    30,270 differential units, 0 regressions against the last verified checkpoint, 207 wrong-to-right.
  • Mutation tables: 371 one-edit mutants of the runtime, commit and sanitiser, all caught on macOS Node 24 and
    Linux Node 18; engine mutants for every patch and wrapper branch.
  • Independent review, five rounds (three owners + a different verifier per role each round). Every round found
    regressions that every suite passed (cp -r d d2/, xargs over 16k files, a swallowed tee error, a check budget
    scaled without the write budget); each has a red-before-fix test now.
  • Real Chrome (web/test/e2e-shell.mjs, 38 checks, 39 with the extension host on its own origin): lazy
    activation, a terminal from the palette, typing, cd, 127, Ctrl+C on sleep 60 (~0.3 s), a link click opening the
    file at Ln 2, Col 3, pasted multi-line buffers through width, height and width-and-height resizes, an emoji name,
    hostile output that does not type into the prompt or rename the terminal, run_command offered without
    background, a file written through the review with Keep/Undo, a .vscode write refused whole, a timeout that
    applies nothing, the mirror tab. e2e.mjs --dist (now asserts run_command is offered), e2e-look.mjs,
    e2e-signin-tab.mjs, e2e-byok.mjs green.
  • Not run: Safari, Firefox, phones; Windows/Linux key bytes and IME; a real OS folder picker through the shell and
    a provider call that hangs behind a permission prompt; long sessions and 2,000-file commands in a real tab.

Known differences from a real shell (all in web/shell/engine/NOTES.md §4–§5, and the model is told the main ones)

grep -r does not enter .git; back-references in sed/grep -E fail closed (RE2); there is one file time
(-atime/-ctime read the modification time); date -d takes a defined subset of GNU's relative grammar; a
redirection writes to the path when it ends, not an open descriptor; resizing a terminal while a multi-line command
is pending can leave a stale copy of the prompt (never lose earlier output in 33,600 measured sessions; the rate and
method are in src/terminal/NOTES.md).

Owner decisions

  • The commit policy's caps (300 files, 1 MiB) and the protected list; whether .github/workflows and CI files are
    ever allowed.
  • Stage 2 (Pyodide as a custom command behind the same seam) needs the runtime's files fetched by a page with
    connect-src 'self' and, for threads, COOP/COEP on the editor origin. Stage 3 (real Node/npm) needs
    WebContainers or BrowserPod: not open source, needs a StackBlitz agreement. Neither is started.
  • The account-site copy ("no terminal") is a separate stacked PR in onetime.

Layout of the diff

web/shell/ (engine, runtime, terminal; README.md is the contract, engine/NOTES.md and src/terminal/NOTES.md
go deeper), extensions/levelcode-ai (the seam: host.js, agent.js, shellCommit.js), web/ai-extension
(manifest scoping, skills, copy), scripts/build-web.mjs, web/lib/config.mjs, docs/WEB.md, CLAUDE.md, tests
and fixtures. The engine is ~1.5 MB raw / ~445 KB gzip, fetched only when the shell is first used.

Workspace Trust is off in a tab, so a setting without a scope was read from the
folder's .vscode/settings.json as if the user wrote it. Measured in the real
workbench: levelcode.cloud.endpoint, levelcode.ai.baseURL, agent.autopilot,
verify.command and commandTimeout all came back as the folder's values, and the
session token goes to whatever https host the endpoint names.

The browser manifest now makes every setting application-scoped except a short,
double-entered list of display preferences. The desktop manifest is untouched.
runAgent built the system prompt as (skills ? buildSystem(menu) : SYSTEM_BASE).
buildSystem already answers a run with no menu with the HOST's base prompt, but the
fallback bypassed it: in the browser build, with levelcode.ai.skills.enabled off, the
model was told about run_command, background:true and 'EVERY run_command' while
run_command itself was withheld. No effect on the desktop (SYSTEM_BASE_HOST is
SYSTEM_BASE there).
…and a shell's file changes through the review

The browser build's shell is another extension (levelcode.levelcode-shell), activated on
first need. host.js gets spawn / shellInfo / registerShell / connectShell, and caps.shell
becomes a getter. agent.js reads the answer once per run, so the prompt, the tool list and
the verify step agree for the whole run; the three load-time constants that baked the
capability in become per-run. run_command is offered as what it is (no background
argument, no read_command_output), refused with an instructive message when a model asks
for background anyway, and its file changes are handed to shellCommit.js, which sends them
through the same applyEdit / applyDelete as edit_file.

The desktop path is unchanged: the same spawn options, the whole tool list, the same prompt
(pinned by shellSeam.test.js; agentRunCommand.test.js is untouched).
The tool result was (output + status).slice(0, 8000): for long output the end — where
the failure and the '[exit 1]' line are — was exactly what got cut, so the model could
not tell a command had failed. Output that fits is returned byte for byte as before;
longer output keeps a quarter from the start and the rest from the end, with the gap
said out loud, and the status line is never what is cut.

DESKTOP-VISIBLE behaviour change (outputs over 8000 characters only), in its own commit
so it can be dropped on its own.
…the card has room for

The registry that read_command_output serves (ring, byte count, port and readiness
sniffing, preview) was fed from the chunk callback that feeds the live card, and that
stops at the card's 100000-character cap: after that a server's 'listening on :4000'
left port=null, ready=false and totalBytes frozen. It is fed by its own callback now,
after the card's, so posts keep their order.

DESKTOP-VISIBLE behaviour change (output past 100000 characters), in its own commit.
…mpt says what the shell is

shellCommit.js is rewritten around one rule. A command's change set is validated as a WHOLE
and applied only if every change is acceptable; there is no unreviewed path (the first
draft wrote 'build output' and new binaries straight to disk, which is a way round
everything edit_file refuses). Refused whole: outside the folders; .git, .vscode,
.levelcode, CI workflows and instruction files (AGENTS.md, CLAUDE.md, .cursorrules); not
plain UTF-8 text, a BOM, over 1 MiB; line endings the review would rewrite; unsaved editor
changes; a file that changed on disk since the command read it; case-only renames; over
300 files. A review call that throws is reported, never swallowed, and the command does not
exit 0.

host.connectShell no longer caches a failure: an early return ran the finally block before
the promise was stored, so every later run got a settled stale answer. It is bounded (10 s)
and asks again next run.

The shell prompt block is rewritten: the program list is the shell's own, what exists and
what does not, the limits and the exit codes, that a stopped command changes nothing, what
is refused, and that tool output is data. Stop/limit/timeout apply nothing (the staged
writes are discarded), so the kill semantics in host.js say so.
Each SKILL.md in the browser build gets one preamble: the shell is a simulated bash with no git, npm,
node, python or test runner and cannot run the code, so the steps that say to run them are skipped,
no result is claimed for them, and the summary says which checks were not run. The steps and the menu
entries are unchanged; the desktop's files are untouched.

Also: the verify.command example means what it says (`! grep -rn TODO src`).
…dependently verified)

LevelCode Shell stage 1 — a JavaScript bash over vscode.workspace.fs, for the visitor's terminal and the
agent's run_command; no programs, no network.

engine/   just-bash 3.6.0 vendored unmodified (sha256 pinned) and patched in memory at build time
          (anchor-checked); a copy-on-write overlay over a read-only RPC; compat wrappers; limits; the
          worker entry. 535 golden cases recorded from GNU bash 5.1 / coreutils 8.32.
src/      runtime (engine worker lifecycle, read-only fs RPC, sessions, the agent's ChildLike, commit),
          commit (the visitor's terminal), info, sanitize, extension.
src/terminal/  Pseudoterminal: input parser, line editor (checked against real xterm.js), completeness,
          output allow-list filter, history, prompt, agent tab, shell integration.

Known defects found by the verifiers are fixed in the commits that follow.
…s 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.
…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.
… is still open)

Engine: 25 patches, cp/mv trailing-slash regression and the redirect content-write regression fixed and
pinned at run level, printf %(..)T zone, grep BRE, du limit, sed labels, 682 golden cases against GNU 5.1.
Runtime: planMounts is the engine's own algorithm, safeMessage, one bounded gate for the commit, Stop settles
at once, resolve/stat. agent.js: the review stops between changes after a Stop, and the prompt names RE2, UTC
and /tmp.
…t scales, the review's give-up cancels, /tmp folders counted)
…ize, joiners kept, linear regexes); README for the commit/Stop/mount contract; e2e-shell: height resize, emoji paste
… README to the real contract; CI sets LEVELCODE_REQUIRE_WORKER; suites that exit silently fail
…pe.Unknown, a link to nothing); links are made to files only
…wners left them; engine-compat and engine-patches suites are mid-edit
…r every step, the lowest redraw over every count of dropped rows), links accept only a file, measured wording in README/NOTES
…ommand, relative dates, diff -r, option tables against GNU getopt, xargs -x -s, sort -k signs, TZ as a zoneinfo path)
…faults inside $(( )), clustered value options, for x; do, --, stdin as -, strftime flags, find -exec status and time tests, diff quoting, 11 more patches); 0 regressions over 30,270 differential units
…ry between touch and date (the third figure is rounded with an hour of slack)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant