Repository navigation
feat(embed): add h.fs and a just-bash shell tool for the browser host - #616
Merged
Merged
Conversation
andybons
force-pushed
the
feat/embed-shell-tool
branch
from
October 14, 2026 02:53
290dcd2 to
d1407ca
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Filesystem lifecycle and shell configuration bugs can cause hanging calls, ignored limits, and incorrect file-operation semantics.
5 open findings
What changed in this PR
Adds browser-only filesystem access and an optional just-bash shell over the WebAssembly module’s shared filesystem.
Changes:
- Adds the
h.fspage-to-Worker filesystem bridge. - Adds the optional
shellTool()export and just-bash adapter. - Adds browser, Node, and end-to-end coverage plus documentation.
| File | Description |
|---|---|
sdk/embed/test/suite.mjs |
Checks Node filesystem unavailability. |
sdk/embed/test/shell.test.mjs |
Tests shell adapter behavior. |
sdk/embed/test/browser/page.mjs |
Exercises shell and filesystem APIs. |
sdk/embed/test/browser.mjs |
Bundles just-bash for browser tests. |
sdk/embed/src/workerbridge.mjs |
Implements Worker filesystem operations. |
sdk/embed/src/wasm.mjs |
Exposes browser fs. |
sdk/embed/src/shellfs.mjs |
Adapts h.fs for just-bash. |
sdk/embed/src/shell.mjs |
Implements shellTool(). |
sdk/embed/src/shell.d.ts |
Types the shell export. |
sdk/embed/src/index.d.ts |
Types HarnessFs and tool options. |
sdk/embed/src/bridge.mjs |
Implements the page filesystem bridge. |
sdk/embed/README.md |
Documents filesystem and shell usage. |
sdk/embed/package.json |
Exports shell and declares dependencies. |
sdk/embed/package-lock.json |
Locks new dependencies. |
sdk/embed/AGENTS.md |
Records shell implementation constraints. |
e2e/browser_test.go |
Registers the shell browser row. |
e2e/browser_shell_test.go |
Verifies shared filesystem behavior. |
docs/design/acp-wasm.md |
Documents the shell design. |
docs/architecture.md |
Updates the binding public API specification. |
CHANGELOG.md |
Records the user-visible feature. |
Files not reviewed (1)
- sdk/embed/package-lock.json: Generated file
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
andybons
force-pushed
the
feat/embed-shell-tool
branch
3 times, most recently
from
October 14, 2026 03:14
132c7da to
489b22d
Compare
Problem: a page that embeds the wasm module has no shell for the model and cannot reach the files of the module. Design: h.fs is an async handle on the module file system served by the Worker; the shell export builds a just-bash tool over it, with no network and with time and command limits. Semantic change: createHarness returns fs with the wasm transport in a browser. Verification: browser row, node tests.
… shell output Problem: the default load of the shell export fails in a bundler because just-bash imports named exports from node:zlib; h.fs readFile of a directory returned empty bytes; two shell tools shared the limits of the first; the output bound applied only after a command ended; the request double could not show the tool schema. Design: the package exports zlib-stub for a bundler alias; readFile checks fstat and fails with EISDIR; each tool keeps its own shell per harness; maxOutputSize is eight times maxOutput; harnesstest records ToolSpecs. Semantic change: as described. Verification: browser row asserts EISDIR, the bash tool description and schema, and a bundle built with the alias.
Problem: a directory move turned every rename error into copy and delete; a call after close() waited forever; mkdir recursive accepted a file; clip kept the whole text for a tiny limit. Design: fall back only for ENOSYS and EXDEV; a closed bridge rejects at once; mkdir checks that an existing component is a directory; clip handles a zero tail. Semantic change: the errors above. Verification: node test for the move.
Problem: the decode test compared a request with empty tool specs. Design: ToolSpecs holds a tool only when it has a description or a schema. Semantic change: none for requests that send neither. Verification: go test -race ./harnesstest/.
…e shell Problem: maxOutputSize was 8 times maxOutput, so a command that wrote more than 160 KB failed, even to a file, and the loop limit followed the command limit, so seq stopped at 10000. Design: maxOutput clips what the model sees; the new maxOutputBytes (default 8 MiB) stops a runaway command; the loop limit keeps the just-bash default. Semantic change: a pipeline can write a large file. Verification: browser row writes about 2 MB through tee and the result is clipped; the file has the full size.
andybons
force-pushed
the
feat/embed-shell-tool
branch
from
October 14, 2026 04:33
3d5cbaf to
a8c80c7
Compare
Problem: the large-file pipeline of the shell row wrote about 2 MB. Design: 30000 padded lines write about 1.1 MB, enough to pass the clip and the old byte limit. Semantic change: none. Verification: the browser rows pass.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Problem
A page that embeds the wasm module cannot give the model a shell: the module has no
exec, and the page has no way to reach the files of the module, either to seed them or to read what a tool wrote. The spike (scratchpad/bash-spike-report.md) showed that just-bash over the module file system works end to end with a real model.Design
Spec:
docs/architecture.mdPublic packages (sdk/embed: theh.fsbullet and theshellexport bullet), Built-in tools (default set and thebashrule it states), CI gates (browser job rows). Design:docs/design/acp-wasm.mdsection 5.6.h.fs: asyncreadFile,writeFile(append),readdir,stat,mkdir(recursive),rm(recursive,force),rename. Calls go to the Worker as{fsop: {id, op, args}}, run onglobalThis.fs(the file system Go uses, so there is one owner of the state), and return as{fsres}with bytes as a transferredArrayBuffer. Errors carry the POSIXcode.close()rejects every call that waits. The wire ops are the eight the Worker serves; the recursive forms are page-side loops over them. Both ends of the contract are inbridge.mjsandworkerbridge.mjs.run(input, {signal, fs})of a page tool gets the same handle, soshellTool()needs no reference toh, which does not exist yet when the tools are passed tocreateHarness.@majorcontext/harness-embed/shell:shellTool({name, load, timeoutMs, maxCommands, maxOutput}). The input is{command}; the output is stdout,[stderr], and[exit N](an error result for a non-zero exit), each stream clipped (head and tail) as in the spike. just-bash is an optional peer dependency (>=3.6.0 <4, Apache-2.0, checked on npm). It loads byload()at the first call;src/index.mjs,bridge.mjs, andwasm.mjsimport nothing of it, so a page that does not import the subpath loads nothing extra. The defaultloadisimport("just-bash/browser"), which a bundler or an import map resolves; a page without a bundler passes its ownload.networkoption, socurldoes not exist, and no Python or other runtime is enabled. The call stops attimeoutMs(10 s) ormaxCommands(10000, also the loop limit). Mounts:/work(the working directory) and/tmpwhen the module has it./persistis never mounted.bashby default, renameable withname. fix(harness): let a host tool replace an unallowed built-in tool of the same name #615 is merged, so the page tool namedbashreplaces the built-in (sdk/embedlists page tool names in the allowed tools); the row uses the default name and checks that the model request lists exactly onebash. The request double records tool names only, so the shell schema is proven by the results (jq, curl refused, the limit), not compared.h.fsis browser-only. Under Node the wasm module runs on the real disk of the host, so a model-driven shell overh.fswould reach every file of the user (and/workmay not exist).h.fsisundefinedwith the wasm transport under Node and withsubprocess(the native binary already hasbash);shellToolthen fails its call with a clear message. A test of the shared suite checks this on both transports.Semantic change
createHarnessreturnsfswith the wasm transport in a browser. A page tool receivesfsin its run options. New subpath export./shell; new optional peer dependency. Dev dependenciesjust-bashandesbuild(the browser fixture bundlesjust-bash/browserfor the page; the lock file grows). Nothing changes for a page that does not use either.Verification
test/shell.test.mjswere written first; the row failed withFailed to fetch dynamically imported module: /embed/shell.mjs(404), the node test with a missingsrc/shellfs.mjs.the shell tool and h.fs share the work directory with the file tools(one module, one turn of 8 tool calls, tool namedbashagainst the scripted model):write_filethen shellcatandgrep -c … | sortsee the file; the shell writes a file andread_filesees it;jqover a JSON file that the page seeded throughh.fs;curlfails withcommand not found;while true; do :; donestops at the command limit (exit 126); then the page reads the shell output throughh.fsand runs everyh.fscall (round trips, append, stat, recursive mkdir and rm, rename, ENOENT and ENOTEMPTY codes,/tmp).node --test sdk/embed/test/*.test.mjs: 55 pass (the adapter against a fakeh.fs, the refusal withouth.fs,h.fsundefined on both transports).go build ./... && go vet ./... && go test -race ./... && test -z "$(gofmt -l .)"pass.Review fixes: the package exports
@majorcontext/harness-embed/zlib-stub(aliasnode:zlibto it in a bundler; only gzip, gunzip, and zcat fail; the fixture bundles with this esbuild alias; Vite and webpack take the same alias but are not tested);h.fs.readFileof a directory fails withEISDIR; eachshellToolkeeps its own shell and limits;maxOutputSizeis 8 timesmaxOutput;harnesstest.Request.ToolSpecsrecords the description and schema, and the row asserts thebashtool description andcommandschema; the README and the spec say thatfsreaches/persist. just-bash'scatreports any read error of a directory as "No such file or directory", so the shell row asserts only exit 1 forcat /work. Copilot threads are answered and resolved.Output limits:
maxOutput(default 20000) clips what the model sees, head and tail kept.maxOutputBytes(default 8 MiB) is a separate limit on the bytes that one call writes, including to a file. The loop limit keeps the just-bash default. A row writes about 1.1 MB throughteeand the model sees a clipped result whilewc -cof the file shows the full size. Without the stub,rg -zon a.gzfile also fails; the docs say so.CI job times (head 432adba vs main run 38510452277):
Contract grew by 5 s and Browser by 16 s. In the Browser job the main run had a warm Go build cache (browser rows step 43 s); across five earlier runs of this PR and main the step has been 64 to 70 s on both, and a local run of the new row's commands in just-bash takes under 0.5 s.
npm ciadds about 2 s for the dev dependencies. The Contract difference is runner variance: this PR changes no Contract-job code.