Skip to content

Ship the bashkit Plugin and give the built-in chat a shell - #211

Merged
vinniefalco merged 13 commits into
cppalliance:masterfrom
vinniefalco:bash-pack
Nov 12, 2026
Merged

vinniefalco merged 13 commits into
cppalliance:masterfrom
vinniefalco:bash-pack

Conversation

@vinniefalco

@vinniefalco vinniefalco commented Nov 12, 2026 •

Copy link
Copy Markdown
Member

The built-in chat now has a shell. Workshop ships a second library Plugin, the bashkit Plugin, whose Pack runvil/bash has one tool, Shell: it runs a command in a bash interpreter inside the run, built on bashkit. The Plugin's code is in cppalliance/runvil-plugin-bashkit, pinned here by commit. Four commits, 38 files.

This pull request is stacked on #210. Its branch starts from that pull request's head, so until #210 merges, the commit list and the diff here hold its nine commits too. The four commits of this one are the last four, and this comparison shows them alone.

This is the first of two stages. The shell reads the run's files and cannot change them, because contract 0.4 has no function that writes. A command that writes fails with one fixed reason, and nothing more is built around that. The second stage adds writing, with the contract's functions for it.

What a model sees:

  • Shell is in the chat's tool list, after the file tools and before the web tools. Its definition is Cursor's Shell definition, with the edits schemas/EDITS.md in the Plugin's repository records.
  • Each run has one shell session. The working directory, variables, and functions a command leaves are there for the next call.
  • Every command is built into bashkit. The shell starts no process and has no network, so git answers command not found.
  • A call answers the exit code and then standard output followed by standard error, as untrusted output. Output past 20,000 characters keeps its first and its last 10,000.
  • A command is stopped after 30 seconds, or after its call's block_until_ms when that is less. The call then fails with a message that says the command was stopped and the session is still usable.
  • Every read goes through the loader's file functions, so the run's policy applies to it, and a read the policy denies shows the policy's reason.

The commits, in order:

  • Build the bashkit Plugin's library with Workshop. workshop-plugin-bashkit links the pinned Plugin and exports its entry point as plugin_bashkit, as workshop-plugin-std does for the std Plugin. The server depends on it so that Cargo builds the library. A server test loads the library from a Plugin directory and runs a prompt that calls bash/shell over a file of its run.
  • Ship the bashkit Plugin's library with Workshop. cargo workshop, the NSIS template, the Tauri bundles, and the two cell scripts each handled the std Plugin's library alone. They now handle both.
  • Require and offer the bash Pack in the built-in chat. chat.md names bash under uses: and offers it between std and web. A new chat gate checks the order of the tool list.
  • Add a switch that mounts the C drive read-only in every run. C_DRIVE in language_guide.rs sits beside the language guide's switch and is committed as off. It is there so the shell can be tried on real files.

Things a reviewer should know:

  • deny.toml allows one more license, bzip2-1.0.6. It belongs to libbz2-rs-sys, the Rust port of libbzip2, which bashkit's bzip2 dependency builds on by default. It is a permissive BSD-style license. No other license was needed.
  • The lock file gains 28 packages, bashkit and its dependencies. No package already in it changes version. They compile with every Workshop build, and the release library is about 18 MB on Windows.
  • The pins move together, as for the std Plugin. bashkit-plugin is at runvil-plugin-bashkit 0864f1f, which is built against the contract commit already pinned here.
  • The built-in chat now requires bash, so a Workshop whose Plugin directory lacks the bashkit library refuses the chat. A config that sets [agents] plugins loads only that directory, so that directory must hold both libraries.
  • While C_DRIVE is on, a model can read every file the person who runs Workshop can, and what it reads goes to the model's provider.
  • bash runs inside the steps of its tool call. bashkit never pauses during work that needs no timer, so a heavy script holds its step, and a stop waits for it or for bashkit's own limits, such as 10,000 commands. While a script sits in a sleep, the Harness steps the call with nothing to do, because contract 0.4 has no way for a Plugin to say when to step it again.
  • Most refused writes fail one command and the script goes on. tee to a file and sed -i end the whole call instead.
  • On Windows, a redirect to /dev/stdout or /dev/stderr is refused as a write. This is a bug in bashkit 0.21.0: normalize_dev_path rebuilds the path with the host's separator, so the path no longer matches the names bashkit answers itself. >&2 works. It has not been reported to bashkit.

Checks:

  • Locally on Windows, before the branch was rebased onto Name sections by path and add the markdown namespace #210's ninth commit: rustfmt, both clippy commands with warnings denied, cargo nextest (4263 and 347 tests), cargo test for build-xtask and build-workshop, the doc commands for the workspace, runvil-engine, runvil, and workshop-server with -D warnings, docs-claims.mjs, cargo deny check, cargo audit, cargo xtask api --check, and cargo dylint.
  • Locally on Windows, at the head after that rebase: cargo nextest (4303 and 347 tests), clippy for the Workshop crates with warnings denied, docs-claims.mjs, and cargo deny check. The rebase had no conflict.
  • cargo workshop places both libraries in plugins, and the Workshop it built prints bashkit 0.1.0 and std 0.1.0 for --plugins.
  • CI on this pull request is green at the head.
  • The Installers workflow on this pull request passes at the head: all five builds and all ten install cells of its pull-request mode. On Windows, on macOS for both architectures, and on Linux for both architectures, the installed Workshop prints bashkit 0.1.0 and std 0.1.0 for --plugins. So the NSIS template, the Tauri bundles, and the two cell scripts, none of which ran locally, are checked there.
  • The Plugin's own CI is green at the pinned commit on Linux, Windows, and macOS.
  • A test that is not committed mounted the real C:\ at /c and ran the shell through the loader. It listed /c/Users, read a file, searched this checkout, and was refused a write.

Not checked:

  • No model has called the shell. The chat gate checks the tool list a round sends, and the server tests call the tool from Lua.
  • The full mode of the Installers workflow, with its seventeen install cells, was not dispatched.
  • No run exercised the updater, so "the updater replaces the libraries with Workshop" rests on where the libraries sit, as it does for the std Plugin.

The review bot's one finding, on crates/runvil-internal/markdown/src/render.rs, was about code from #210, which shows here only because this pull request is stacked on it. It was a real defect, and #210's ninth commit, c64f856, removes it.

Pushed alongside this:

The design in `runvil-design/design/2026-11-11-1-markdown-and-prompt-reads.md` names every section by a section path and reads Markdown as plain blocks. This crate is the part of that design that needs no Lua: the text-to-blocks parser, the path and selector grammars, and the writer behind `markdown.render`. Nothing uses it yet. It lands first because the functions that name a section in a prompt take the same path grammar.

- `parse` reads any text into `Block`s and never fails. It works in three layers under `parse`: `events` runs `pulldown-cmark` and records each top-level heading, fence, list, and rule by its byte range, `flat` turns those marks into blocks in file order with the text between them as prose, and `tree` folds them into sections. Only `events` names the library, and only `flat` sees a byte range. `frontmatter` splits the YAML mapping off first, by the prompt parser's delimiter rule, and `serde_yaml_ng` reads it.
- `parse_body` is the same parse for text with no frontmatter, with lines counted from a given line, which is what a read of one section's own content needs.
- `split` is the one path grammar: steps, the three escapes, the trimming, and what makes an index. `Path` is the form over parsed text. `Selector` reads a kind or `fence` and a language, and `nth` is how an index counts.
- `render` writes every list of blocks and checks nothing. What can be read back is for its caller to check, which knows where each block came from.
- The grammars return their message as a `String`, because each caller puts its own function's name in front before it raises the universal `Error`. So the crate depends on no workspace crate.
- Where the design is silent: an index too large for an `i64` is kept as the largest one, which no match reaches; a path that ends in a backslash raises `ends in a backslash`; and `fence` followed by two words raises `a fence selector takes one language`.
- The tests hold the design's worked example, each row of its edge-case table, and two properties over 74 sample texts: every non-blank line lies in exactly one block, and parsed text is written as text that parses to the same blocks.
Every function that names a section now takes a path of titles, as `runvil-design/design/2026-11-11-1-markdown-and-prompt-reads.md` specifies. `name` is a direct child, `../name` is a sibling, and `/a/b` starts at the top of the prompt and names any section. The level is not in the address, so moving a section to another depth changes no path.

- `call`, `jump`, `tasks.spawn`, and `tasks.fanout` take the two relative forms, which are a section's visible set. `prompt.section` and `prompt.list` take those and a path from the top. `require` takes only a path from the top, so one library has one name on every line of a prompt.
- `section_path` in `runvil-runtime` is the one lookup, with an entry for each of those three rules. It starts from an `Origin`, the H1 or a section, and the H1's children are the top-level sections. The path grammar is `runvil_markdown::split`, shared with paths over parsed text. `section_pos` holds the tree positions that `heading_address` held beside the lookup.
- A path that names no section raises one error from every function, which says where the lookup looked and lists nothing: ``section `Grand` not found: `/Main` has no child `Grand` ``. A path from the top reports the step where it stopped.
- `require` needs no starting point, so `RequireOrigin`, the wider reach, and the key a loading library passed to the resolver are gone. The resolver takes the path alone.
- A library is reported by its path from the top: the three library-load events carry `/shared_stuff` in `library`, and the cycle error reads `require cycle: /a -> /b -> /a`. `Library::heading` is `Library::path`.
- Sibling titles are unique, so the lookup has no ambiguity error.
- Every prompt and test in the repository names sections by path. The tests that asserted the reach and the listed sections are replaced by tests of the three forms, of each miss message, and of a path from the top read and required from the H1, a top-level section, and a nested one.
- Left for the commits that follow: a read that names no section still returns nil, so the message for a path from the top given to `call` names `require` and `prompt.section` only; a relative path in a library's code still starts at the running section; and `prompt.list` still exists.
- Where the design is silent: a path from the top that holds `..` raises `uses ".."; a path from the top takes none`.
`prompt.section` returned nil for a path that names no section, so a misspelled title read as nothing and failed later, somewhere else. It now raises the not-found error that `call` raises for the same path, and `prompt.section_or` is the read that returns nil instead. A malformed path and a target that is not a string raise from both.

- The read callback returns a `SectionRead`: the section's text, or the not-found error for a path that is well-formed and names nothing. `prompt.section` raises that error, and `prompt.section_or` and `prompt.list` return nil for it.
- The message for a path from the top given to `call`, `jump`, or a `tasks` function now names all three functions that take one: `require`, `prompt.section`, and `prompt.section_or`.
- The tests assert that `call` and `prompt.section` raise the same text for the same path from the same section, and that only `prompt.section` raises for an absence.
A relative path in a library's code named a neighbor of whichever section was using the library, so a library could not keep templates or workers of its own, and one line meant a different section for each user. It now names a neighbor of the library section, while the library loads and when its functions run later.

- `require` loads each block of a library with an environment table of its own, the facility Pack preludes use. The table holds `call`, `prompt`, and `tasks` made for the library, and sends every other global read and every global write on to the VM's globals, and so through the `_G` guard. `library_env` builds it, and its metatable is protected.
- `call`, `tasks.spawn`, and `tasks.fanout` are made by factories over the library's identity, and the `call` and `spawn` requests carry it as `from`. The scheduler resolves the target from the library's position when a request names one, and from the chain's current section otherwise. The read callback takes the same optional key.
- A function keeps the place it was written in: a callback a section passes to a library names the section's neighbors, and a tool handler written in a library names the library's. A name passed as a string is resolved where it is used.
- `jump` is not made for the library. It moves the running walk, so its path starts at the running section in a library's code too. Inside a library, `_G.prompt`, `_G.call`, and `_G.tasks` are the running section's own.
- A miss in a library's code names the library as the section it searched, and a miss through `_G` names the running section.
- The `prompt` table moves from `vm/install.rs` to `prompt_reads`, which builds the VM's table and each library's from one function.
- 24 tests drive a section VM and 13 run whole prompts through the scheduler. In the whole prompts the library and the section using it both have a child with the same title, so a lookup from the wrong place returns the wrong text. With the environment taken out, 20 of them fail.
A prompt had no way to read the structure of Markdown it did not write: a model's reply, a stored document, or the text of one of its own sections. `markdown` parses any text into plain Lua tables, the way `json.decode` does for JSON, as `runvil-design/design/2026-11-11-1-markdown-and-prompt-reads.md` specifies.

- `markdown.parse(text)` returns a marked list of blocks. A block is a record with its `kind`, its `line`, and the fields of its kind, so a parse result passes `json.encode`, can be stored in `var`, and can be a `tasks.fanout` collection.
- `markdown.section(blocks, path [, selector [, n]])` finds the one section a path names, or the one block of it a selector picks. It raises when the section or the block is not there, and `markdown.section_or` returns nil for that. Several matches, and a malformed path, selector, or index, raise from both.
- `markdown.select(blocks, selector)` collects every match at any depth in file order, and `markdown.render(blocks)` writes blocks back as text.
- A lookup returns the tables that are already inside the list it was given, and walks them without calling into Lua. It reads through sealed views by the rule `json.encode` uses, which moves from `json/encode.rs` to `proxy::unseal`, so blocks read back from `var` work with every function. It refuses a list on its own path and one nested more than 127 lists deep.
- `runvil-markdown` gains the messages a lookup raises, beside the grammars they belong to: `Path::no_match`, `Path::ambiguous`, and `pick`, which says which of a selector's matches a lookup keeps.
- `markdown.parse` takes the input check of `json.decode`, with its 8,388,608-byte limit.
- Where the design is silent: a lookup counts the blocks it visits against the VM's walk budget and raises `blocks are too large to walk`, because a table shared by many parents is visited once for each; a path or a selector that is not a string raises `path must be a string` or `selector must be a string`; a block other than a section given where a list belongs raises `got a fence block`; a message that lists lines leaves them out when a block records none; a fence table with no `lang` field matches no language; `markdown.render` requires every field of a block's kind except `line`; and a Pack prelude's environment does not hold `markdown`.
- One wording differs from the design's table: `markdown.parse(42)` says `got integer`, the name every other Engine message gives that type, where the table says `got number`.
- The tests run every example of the design through a section VM, and four whole prompts parse a stored file, parse the text of a `prompt` read, carry a parse result to a later section in `var`, and fan out over parsed sections.
`prompt.section(path, selector [, n])` and its `prompt.section_or` twin return one block of a section's own content: a template kept in a fence, the items of a list, or any other single block. The selector and the index are the ones `markdown.section` takes, and the block is the table `markdown.parse` builds for it.

- The read parses the section's own raw Markdown by the block rules and picks the only block the selector matches, or the `n`th when there is an index. The table is built for the call, and its `line` counts lines of the prompt file.
- A block that is not there raises from `prompt.section` and is nil from `prompt.section_or`. Several matches with no index, and a malformed selector or index, raise from both. The selector and index are checked before the path is looked up.
- The kinds `section` and `frontmatter` raise, because a section's own content holds neither. Frontmatter is never recognized there, since a prompt's is at the top of the file.
- `Section` records the file line its Markdown starts on, and `SectionText` reads that Markdown in place from the prompt text the run shares. So `prompt.section(path)` copies only the string it returns, and the selector form copies only the chosen block's strings.
- The tests run the design's report prompt and assert every line of its worked example from `Write`, from one level down in `Template`, from two workers under different parents, from the H1 body, and from a library that reads its own template and its user's list.
The selector form of prompt.section reads a list, a fence, or any other
block with one syntax, so a second read for lists and a second notion of
a list in the parser are no longer needed. A list is a list block and
nothing else, and a section that holds only a list is an ordinary
section.

- The read is prompt.section(path, "list").items. The prompt table holds
  section and section_or and nothing else.
- The prompt parser no longer builds list items. Section loses items()
  and is_list(), and the list module is deleted.
- The parse error for a bullet with no content is removed with its
  ErrorCode, its tag, its prose, the Workshop server's wire code, and
  the fixture that triggered it. The number 104 is left unused.
- tasks.spawn and tasks.fanout no longer refuse a section that holds
  only a list. It is entered like any section with no Lua and runs
  nothing of its own.
- The error for a second parameter of tasks.fanout that is not a
  collection names the read that gives the items of a list.
- Items now follow the block rules, so two results differ for a prompt
  written before this change: a nested bullet is part of its parent's
  item, and a bullet with no content is the item "".
A section is named by a path, so the last two places that described one as a heading now say what they hold.

- The Lua test rig binds a recorded jump's target as `path`.
- The event module's note on what an author controls says that a library is reported by a path made of heading text.
@coderabbitai

coderabbitai Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

Important

  • Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: e92a48f2-4d77-43b7-bbf4-88b8a8151f65
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

@greptile-apps

greptile-apps Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[High impact] The PR appears safe to merge, with the previous fence-rendering issue fixed and no new actionable findings.

Summary

Workshop ships the bashkit Plugin and offers Shell in the built-in chat. The latest changes also preserve Markdown source text instead of rebuilding it.

  • Lua can work with Markdown blocks and render their original text.
  • Prompt reads and control-flow calls use section paths, with relative paths based on the running section or library.
  • The built-in chat offers bashkit’s per-run Shell tool for running commands inside a shell.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Chat["Built-in chat"] --> Shell["Shell"]
  Shell --> Plugin["bashkit Plugin library"]
  Plugin --> Files["Loader file functions"]
  Files --> Policy["Run file policy"]
  Policy --> VFS["Run files"]
Loading

Reviews (2) · Last reviewed commit: "Add a switch that mounts the C drive rea..." · Reviewed by Greptile

markdown.render rebuilt each block in one fixed style, so rendering a
parse changed the text. A list that started at 3 was renumbered from 1.
Markers, fences, and underlined headings were rewritten, and a title's
markup and a frontmatter's comments were lost. A tilde fence whose label
starts with a tilde was written with the two run together, which cost it
its closing line and every block after it.

Every block now holds `source`, the lines it was read from with the
blank lines after them. The sources of a parse are the whole text, and
markdown.render writes sources and nothing else, so a parse is written
back as the text it was read from.

- A block's source runs from its first line to the first line of the
  next block. A section's is its heading's, and blank lines at the top
  of the text are in the first block's. Text of blank lines alone has
  no block, so it is the one text that is not written back.
- The selector `*` matches every block. markdown.select(blocks, "*") is
  a document as one flat list, and the sources of that list join to the
  text. `*` works in markdown.section and in the prompt reads too.
- markdown.render reads `source`, and reads `kind` and `blocks` only to
  know what comes next. An edit of any other field changes nothing that
  is written, and a block with no `source` raises and names the block.
- The writer adds a line ending of its own in two cases, and neither
  occurs inside one parse: before a block when the text so far does not
  end with one, and a blank line between two prose blocks.
- A list now ends with its last item. The Markdown library ends a list
  where the next block starts, so a link definition after a list was in
  no field of any block. It is prose now.
- The writer that rebuilt blocks is deleted, and so is the reader that
  checked every field for it.
- A byte order mark and the line endings are read as before, so every
  source uses `\n` only.

The tests check the 75 sample texts and 2,000 texts generated from a
fixed seed: the sources join to the text, each is whole lines, each
agrees with its block's line, and each reads alone as its own block.
Two tests state the cases where the last does not hold.

Plan: /plan/2026-11-12-1-exact-markdown-source.plan.md
The bashkit Plugin inserts the Pack `runvil/bash`, whose one tool, `Shell`, runs a command in a bash interpreter inside the run. Its code is in `cppalliance/runvil-plugin-bashkit`, pinned here by commit. `workshop-plugin-bashkit` links it and exports its entry point as `plugin_bashkit`, as `workshop-plugin-std` does for the std Plugin, and the server depends on that crate so that Cargo writes the library to `deps` with every build. Nothing places the library in a Plugin directory yet.

- `bashkit-plugin` in the root `Cargo.toml` pins the Plugin at `0864f1f`, which is built against the contract commit already pinned here.
- `tidy.rs` adds the crate to the `PLUGINS` tier, which only the server may name.
- `deny.toml` allows the Plugin's repository as a git source, and the license `bzip2-1.0.6`. That license is the Rust port of libbzip2, `libbz2-rs-sys`, which bashkit's `bzip2` dependency builds on by default. It is a permissive BSD-style license.
- A server test loads the library from a Plugin directory and runs a prompt that calls `bash/shell`. The shell reads a file of the run through the loader's file functions, its output reaches the script marked as untrusted, and a write fails and changes nothing.
- A Host with no Plugin directory refuses a prompt that requires `bash` with `runvil/bash is not installed`, as it does for `std`. The startup warning for a missing shipped Pack now covers both.
- `plugin_dir`, which was `std_plugin_dir`, gives the server's integration tests one shared Plugin directory with a hard link to each of the two libraries.
- The lock file gains bashkit and its dependencies. No package already in it changes version.
`cargo workshop` and every installer now carry two libraries in Workshop's Plugin directory, the std Plugin's and the bashkit Plugin's, where each of them handled the std Plugin's alone. A built Workshop lists both for `--plugins`: `bashkit 0.1.0` and `std 0.1.0`.

- `LIBRARIES` in `build-workshop`'s `plugin.rs` names the two. `library_names`, `built_libraries`, `bundle_config`, `check_bundled`, and `place` work on the list.
- A build that left one library out places neither, so a Plugin directory never holds half of what Workshop ships. The failure names the missing file.
- A macOS or Linux installer has Tauri copy both libraries into Workshop's bundle, and fails when the bundle lacks either.
- `installer.nsi` installs `plugins\plugin_bashkit.dll` with the Workshop component, from `BASHKITPLUGINSRCPATH`, and removes it with that component.
- `cell.ps1` and `cell.sh` require both libraries when Workshop is selected, and require `--plugins` to list `std` and `bashkit`.
- A pull request that touches `crates/workshop/plugin-bashkit` runs the Installers workflow.
- The NSIS template, the Tauri bundles, and the two cell scripts did not run here. The scripts were parsed for syntax, and the Installers workflow is what runs them.
The built-in chat agent now names `bash` under `uses:` and offers it with `tools.offer("bash")`, between `std` and `web`. So a chat model's tool list holds `Shell` itself, after the file tools and before the web tools, where it sat only among the extras before.

- A Pack named under `uses:` is required, so a Workshop whose Plugin directory lacks the bashkit library refuses the chat, as it does without the std library. Both libraries ship with Workshop.
- The `uses:` list is written on one line, in the form the tests use.
- A new chat gate launches the embedded chat and checks the order of the tool list its first round sends.
`C_DRIVE` in `language_guide.rs` sits beside the language guide's switch and is committed as off. Turned on, it mounts `C:\` read-only at `/c` in every run, so `C:\Users` is `/c/Users`, the path Git Bash gives it. It is there so the shell and the file tools can be tried on real files.

- While the switch is on, a model can read every file the person who runs Workshop can, what it reads goes to the model's provider, and a search that starts at `/` walks the drive.
- Both switches may be on: the two mounts sit side by side, and the in-memory root stays the store.
- A machine with no `C:\` logs a warning and runs with its plain root, as one with no guide directory does.
- The tests map two temporary directories and read both, map a drive's root on Windows, and run a prompt whose shell lists and reads two mapped directories.
@vinniefalco
vinniefalco merged commit 2d40baf into cppalliance:master Nov 12, 2026
32 checks passed

This branch was previously deployed

1 inactive deployment
github-pages — 2d40bafa Deployed Nov 12, 2026 by vinniefalco via deploy #185
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