# ide

Run Unix commands, background jobs, and structured filesystem operations from the iii engine, on the host or forwarded into a sandbox microVM.

## Notice: renamed from `shell`

This worker used to be called `shell`. Only its identity changed, so nothing that talks to it needs to be rewritten:

- worker name, binary, Cargo package and registry entry: `shell` → `ide` (`iii call compose::add worker=ide`, `npx skills add iii-hq/workers --skill ide`)
- source directory: `shell/` → `ide/`; dependents pin `ide` in their `iii.worker.yaml`
- renamed with it (2026-09-17): the console page id, the UI asset scope (`ide/page.js`, `data-iii-ui="ide"`) and the configuration entry are `ide`; a stored `shell` configuration value is carried over to `ide` once on boot
- unchanged on purpose: the `shell::*` function ids (`shell::exec`, `shell::fs::*`, `shell::pty::*`, …), `coder::*` and the `data/shell/` data directory

## Install

```sh
iii call compose::add worker=ide
```

Sandbox-targeted execution and `shell::fs::*` forwarding need the `iii-sandbox` worker; `iii call compose::add worker=ide` does not pull it in. To surface `shell::*` to LLM agents, pair with `iii-directory`:

```sh
iii call compose::add worker=iii-sandbox worker=iii-directory
```

## Skills

Install the `shell` agent skill for Claude Code, Cursor, and 30+ other agents:

```bash
npx skills add iii-hq/workers --skill ide
```

Browse or install every worker skill at once:

```bash
npx skills add iii-hq/workers --list
npx skills add iii-hq/workers --all
```

## Running

The binary needs no required environment variables — it boots against a local
engine with pure defaults. The full operator surface:

| Knob | Default | What it does |
|---|---|---|
| `--url <URL>` / `III_URL` env var | `ws://127.0.0.1:49134` | WebSocket URL of the iii engine. The CLI flag wins over the env var. |
| `--config <path>` | `./config.yaml` | Seed config sent as `initial_value` on FIRST registration only; the stored value wins afterwards (see [Configure](#configure)). |
| `--version` | — | Print the worker version (also registered with the engine as worker metadata). |
| `RUST_LOG` env var | `info` | Log filter (tracing `EnvFilter` syntax, e.g. `RUST_LOG=ide=debug,info`). |

If the engine is unreachable at boot, a pre-connect probe (run detached, so it
never delays startup) logs one ERROR ("engine unreachable at <host>:<port> —
is the iii engine running? Set --url or the III_URL env var...", logging the
resolved host/port rather than the raw URL) and the worker keeps retrying in
the background every 2s — it never exits, so supervised deployments recover
as soon as the engine is up.

A `--config` seed file that exists but fails to parse (for example, one still
carrying 0.6.x keys) aborts boot rather than silently falling back to the
permissive built-in default — see [Upgrading to 0.7.0](#upgrading-to-070). A
genuinely missing file still falls through gracefully to the stored value or
the built-in zero-config default.

## Configure

Settings are managed through the central `configuration` worker. On boot, the shell worker registers its schema (id `shell`) and fetches the live value over RPC — that live value is the authoritative config, not a local file. The optional `--config <path>` flag (default `./config.yaml`) provides the `initial_value` sent on first registration only; once registered, subsequent boots pull the stored value from the `configuration` worker. When the config changes, the worker hot-reloads the security policy and fs backend automatically (see [Hot-reload](#hot-reload)).

The worker refuses to start unless `fs.host_roots` is set, or `fs.allow_unjailed: true` is explicitly opted in, because an unset root exposes the whole host filesystem behind only the advisory denylist.

By default, `mkdir`/`chmod`/`write` reject modes carrying setuid/setgid/sticky bits (the top octal digit, e.g. `4755`) with `S210`, since they are a privilege-escalation primitive when the worker runs as root. Set `fs.allow_special_bits: true` only if your workload genuinely needs them.

```yaml
max_timeout_ms: 120000       # foreground exec hard cap; per-call timeout_ms is clamped to this
max_bg_timeout_ms: 0         # host bg job hard cap in ms; 0 = unbounded (foreground uses max_timeout_ms)
default_timeout_ms: 30000    # applied when the caller omits timeout_ms (code default 10000)
max_output_bytes: 1048576    # 1 MiB; stdout/stderr past this set *_truncated
env:
  inherit: true              # forward the worker's env to children; per-call dangerous keys still blocked
  # with inherit: false, git over SSH needs SSH_AUTH_SOCK in allow, which gives every child command the agent's keys: add it only if that is intended
  allow: [PATH, HOME, LANG, LC_ALL, TERM]  # forwarded when inherit is false; has no effect on per-call `env` (deny-only — dangerous keys never settable)

# Command policy is deny-only: allow/ask policy lives in the approval-gate.
# denylist_patterns are advisory regex over argv.join(" "), a tripwire for
# catastrophic mistakes only, NOT a security boundary. Command-shaped
# patterns are anchored to argv[0] (tolerating a sudo/doas/nohup/env/timeout
# wrapper) so `grep -rn shutdown src/` is not rejected but `sudo shutdown -h
# now` still is; argument-shaped ones (rm -rf /) stay unanchored. See the
# shipped config.yaml for the full patterns.
denylist_patterns:
  - "rm\\s+-rf\\s+/"
  - "^(\\S*/)?mkfs"
  - "^(\\S*/)?dd\\s+if="

max_concurrent_jobs: 16      # exec_bg past this is rejected
job_retention_secs: 3600     # finished jobs pruned after this

fs:
  host_roots: []             # jail roots for shell::fs::*/cwd; empty = unjailed (the shipped default); first entry = primary
  allow_unjailed: true       # explicit opt-in to running with no jail root (required whenever host_roots is empty)
  max_read_bytes: 16777216   # 0 = unlimited (reads stream; cap bounds caller cost)
  max_write_bytes: 16777216  # 0 = unlimited
  denylist_paths: [/etc/passwd, /etc/shadow]  # primary protection while unjailed; defense in depth once you set host_roots
  allow_special_bits: false  # permit setuid/setgid/sticky bits in mode (default false)
  exec_confinement: "off"    # landlock = a host exec carrying fs_scope writes only under its root, grants, git dir and exec_writable; fails closed (S222) without Landlock
  exec_writable: [/tmp, /dev, ~/.cache, ~/.cargo, ~/.local/share/pnpm, ~/.gnupg]  # extra writable paths for a confined exec; missing paths skipped

sandbox:
  enabled: true              # false -> every target: sandbox call returns S210

commit_messages:             # the Commit panel's Generate button (read live on every call)
  model: null                # router model id ("provider::model" or bare); null = the chat's default model, passed by the panel
  thinking: low              # default|minimal|low|medium|high|xhigh; "default" sends no reasoning override
  instructions: ""           # free text appended to the prompt, e.g. "Use Conventional Commits"

code:
  templates:                 # worker templates for coder::list-templates / coder::scaffold-worker
    dir: null                # local templates checkout (repo root or its iii/ dir), read on every call; III_TEMPLATE_DIR overrides
    url: https://github.com/iii-hq/templates.git  # shallow-cloned when dir is unset; III_TEMPLATE_URL overrides
    ref: main                # branch or tag to clone
    cache_dir: data/shell/templates  # clone cache; relative paths resolve against III_COMPOSE_DIR
    refresh_secs: 600        # fetch the clone again after this long (or on coder::list-templates refresh: true)
```

### Zero-config default

With no `--config` file and no value stored in the `configuration` worker, the worker seeds a built-in default on first registration — so it boots with nothing configured (database-style). That built-in default is the shipped [`config.yaml`](config.yaml): unjailed (`fs.allow_unjailed: true`, empty `host_roots`), env forwarded, deny-only exec with a catastrophic-only denylist (kept in sync by a unit test). Unjailed means `shell::fs::*` and `shell::exec`'s per-call `cwd` operate against the real filesystem, confined only by `fs.denylist_paths` — matching `shell::exec` itself, which has never been confinement-based. `coder::*` follows the same policy, keeping the engine workspace cwd + `/tmp` only as relative-path anchors. Set `fs.host_roots` to jail all of them. If the stored value is later nulled, the worker does not silently fall back to this seed: boot fails closed and a hot-reload keeps the last-good config. A config that is *present* but leaves `fs.host_roots` unset (without `fs.allow_unjailed: true`) also fails closed — the failure mode only disappears once you explicitly opt in, one way or another.

Host `shell::exec` is not a security boundary: any interpreter (`sh`, `node`, `python3`) can construct a denylisted token at runtime and bypass the regex. Run untrusted input with `target: { kind: "sandbox", sandbox_id }`, which forwards through the `iii-sandbox` microVM. The denylist still applies on top of either backend.

### Per-call `cwd`, `env`, and `stdin` (host target)

`shell::exec` and `shell::exec_bg` each accept optional fields so an agent can scope a single command to a directory, set specific env values, and feed it standard input without wrapping everything in `sh -lc` (which would blur what the argv actually was):

- **`cwd`** (string): the working directory for this one call. It is confined to the fs jail **exactly** like `shell::fs::*` paths — jail-relative when `fs.host_roots` is set (else absolute), canonicalized, and required to resolve inside a jail root and miss `denylist_paths`. A `cwd` that escapes the jail returns `S215`; one that doesn't exist or isn't a directory returns `S211`/`S210`. Omit it to use the configured `working_dir` (unchanged default).
- **`env`** (object of string→string): per-call environment values. Deny-only, like the exec/fs command policy: a key may be set to **any** value **except** an exec-hijacking key — `PATH`, `IFS`, `HOME`, every `LD_*`/`DYLD_*` variant, and other loader/lookup-path and interpreter startup-file keys (`GCONV_PATH`, `BASH_ENV`, `ENV`, `PYTHONSTARTUP`, `PERL5OPT`, `RUBYOPT`, `NODE_OPTIONS`, …), which are rejected unconditionally. `env.allow` plays **no role** in this gate at all — that list's only job is which vars get *forwarded* from the worker's own environment when `env.inherit` is false (see [Configure](#configure)); it has nothing to do with what a per-call override may set. Supplying a dangerous key rejects the **whole call** with `S210` (the offending key is named); the env is never silently dropped. A per-call value overrides the value that would otherwise be forwarded for that key. So an agent can do `NODE_ENV=test` freely, but can never inject `PATH`, `HOME`, or `LD_PRELOAD`.
- **`stdin`** (string): written to the program's standard input, which is then closed (EOF). Use it to feed `tee`, `patch`, `cat`, or any stdin filter instead of a shell heredoc. Omit it and stdin is `/dev/null`.

All three fields are **host-only**. The `sandbox::exec` protocol does not forward `cwd`/`env`/`stdin`, so a sandbox-targeted call that supplies any of them is rejected with `S210` rather than silently ignoring it. Omit them and behaviour is identical to prior versions.

### Harness session scope (`fs_scope`)

The harness stamps a trusted `fs_scope { root, grants, boundary }` on every `shell::*`/`coder::*` call of a session. What `root` means depends on `boundary`, which the harness's `filesystem_boundary` config picks (`auto`, the default, is `workspace` only while approval-gate's access watch is bound):

- `workspace`: `coder::*`, `shell::fs::*` and an exec `cwd` stay inside `root` plus `grants` (`C220`/`S220` for a path in an allowed root but outside the session, `C215`/`S215` otherwise).
- `configured_roots`: `root` only anchors relative paths and is the default exec `cwd`; `fs.host_roots` is the jail.

What a host `shell::exec`/`exec_bg` process writes is a separate switch: with `fs.exec_confinement: landlock`, a scoped exec may write only under `root`, `grants`, the root's git dir and `fs.exec_writable`, in either mode. Reads stay open, and setuid binaries do not work in a confined exec. Without Landlock the exec fails closed with `S222`. Unscoped execs (the console terminal, other workers) and sandbox targets are never confined.

## Quick start

```sh
npm install iii-sdk
```

```ts
import { registerWorker } from 'iii-sdk'

const iii = registerWorker(process.env.III_URL ?? 'ws://127.0.0.1:49134')

const result = await iii.call({
  function_id: 'shell::exec',
  payload: { command: 'echo', args: ['hello'] },
})

console.log(result)
```

The example runs on the host. The same payload retargets at a microVM with `target: { kind: 'sandbox', sandbox_id: '<uuid>' }`. The other entry points are `shell::exec_bg`, `shell::status`, `shell::kill`, `shell::list`, `shell::config-status`, plus the `shell::fs::*` family (`ls`, `stat`, `read`, `write`, `grep`, `sed`, `mkdir`, `rm`, `chmod`, `mv`).

## Functions

| Function | Purpose |
|---|---|
| `shell::exec` | Run a command in the foreground; returns stdout, stderr, exit code, and timing. Blocks until exit or timeout. Accepts optional host-only `cwd` (confined to `fs.host_roots` when set, else absolute), `env` (deny-only, gated by a dangerous-key denylist), and `stdin` (string piped to the program's stdin, then EOF) — see [Per-call `cwd`, `env`, and `stdin`](#per-call-cwd-env-and-stdin-host-target). |
| `shell::exec_bg` | Spawn a command as a background job; returns `{ job_id, argv }` immediately. Accepts an optional caller-chosen `job_id` on host and sandbox targets so you can [observe its completion before starting the job](#background-job-completion-shellon-job-finished). Host-targeted jobs run until they exit or `shell::kill` terminates them — unbounded by default, and capped only when the operator sets a positive `max_bg_timeout_ms` (default `0` = unbounded), after which a runaway job is killed and its status becomes `killed`. Sandbox jobs honor `timeout_ms`. Same optional host-only `cwd`/`env`/`stdin` as `shell::exec`. |
| `shell::status` | Fetch one job's full record: state, exit code, and captured stdout/stderr. A missing id — one that never existed or aged out past `job_retention_secs` — returns an `S211` ("no such job") error. |
| `shell::list` | Enumerate current jobs as lightweight summaries; argv, stdout, and stderr are redacted. |
| `shell::kill` | Terminate a running background job by `job_id`. Sandbox jobs cannot be hard-killed: the record flips to `killed` but the in-VM process runs until its `timeout_ms` (or `sandbox::stop`). |
| `shell::kill-session-jobs` | *(harness-internal — not agent-callable)* Kill every running background job owned by the given `session_ids`; returns `{ killed }`. The harness stamps the calling session on each agent's `shell::exec_bg`, and `harness::stop` calls this for the stopped session tree. Jobs started outside a harness session have no owner and never match. |
| `shell::config-status` | *(operator/automation only — not agent-callable)* Report the last hot-reload outcome: `last_outcome` (`applied`/`rejected`), `last_error`, and `rejected_reloads` (count since boot). A rejected outcome or non-zero count means a stored config was refused and shell is enforcing an older policy than the central store. Takes no arguments. |
| `shell::fs::ls` | List a directory's entries with structured metadata, sorted by name and paginated: `page` (1-based, default 1), `page_size` (default 500, max 2000); the response carries `total`, `page`, `page_size`, `has_more`. |
| `shell::fs::stat` | Read one path's metadata (size, mode, symlink flag). |
| `shell::fs::mkdir` | Create a directory, optionally with missing parents. Returns `{ created, path, already_existed }`. |
| `shell::fs::rm` | Remove a file or directory, optionally recursive. Returns `{ removed, path, was_present }`. |
| `shell::fs::chmod` | Change a path's mode, and optionally its uid/gid. Returns `{ entries_changed, path, recursive }`. (**Breaking**: field renamed from `updated` to `entries_changed`.) |
| `shell::fs::mv` | Rename or move one path within the jail. Returns `{ moved, src, dst, overwrote }`. |
| `shell::fs::grep` | Recursive regex search across a tree; returns structured matches. Keys are singular `include_glob`/`exclude_glob`; the case flag is `ignore_case`. |
| `shell::fs::sed` | Regex find-and-replace across one file or many. |
| `shell::fs::write` | Write a file. Simplest form passes inline string `content` (host target only): `{ path, content: "file text" }`, with `mode` (octal, default `"0644"`) and `parents: true` to create parents. A `ContentRef` object in `content` instead streams large/staged payloads through an SDK channel (temp file + atomic rename) and is **required** for sandbox targets — an inline string on a sandbox target returns `S210`. Batch form: pass `files: [{ path, content, mode?, parents? }, ...]` (host, inline per file) to write several files in one call; the response then carries per-file `files: [{ path, bytes_written }]`. A single-file write leaves `files` empty and returns `{ bytes_written, path }`. Supplying both single `path`/`content` and `files` returns `S210`. |
| `shell::fs::read` | Stream a file's bytes out through an SDK channel. For an inline read on the web surface, use the `harness::fs::read_inline` wrapper instead. |
| `shell::scm::commit-message-config` | *(console only)* The live `commit_messages` settings: `{}` → `{ model: string \| null, thinking, instructions }`. See [Commit messages](#commit-messages-shellscm). |
| `shell::scm::commit-message` | *(console only)* Write a git commit message for a diff with an LLM through `router::complete`: `{ changes, recent_subjects?, fallback_model? }` → `{ message, model }`. See [Commit messages](#commit-messages-shellscm). |

Every `shell::fs::*` call accepts the same optional `target` as `exec`, so host and sandbox share one wire shape.

## Code surface (`coder::*`)

The shell worker also serves the **`coder::*`** code-file functions (the former
standalone `coder` worker, folded in). They are agent-ergonomic, structured
file operations that share `shell::fs::*`'s jail (`fs.host_roots`). When it's
empty (the shipped default — see [Zero-config default](#zero-config-default)),
both surfaces are unjailed and `coder::*` keeps the engine workspace cwd +
`/tmp` only as relative-path anchors.

| Function | Purpose |
|---|---|
| `coder::info` | Discover the jail: roots, caps, response budgets, exclude/non-accessible globs. Call first. |
| `coder::read-file` | Windowed reads (`line_from`/`line_to`), `stat` probe, byte-budgeted full reads, and multi-file batch reads. |
| `coder::search` | Literal/regex content + path search over a folder or one file, with context lines, bounded by match/byte budgets. `respect_gitignore: true` skips what `.gitignore` hides; `fuzzy_paths: true` ranks path matches quick-open style, best first. |
| `coder::list-folder` | Paginated single-folder listing. |
| `coder::tree` | Recursive depth- and per-folder-bounded directory snapshot. |
| `coder::create-file` / `coder::update-file` / `coder::delete-file` / `coder::move` | Batched create, line/regex edits, delete, and atomic rename/move. |
| `coder::list-templates` | The worker templates `coder::scaffold-worker` creates from, with their language and the compose containers they need. Read from `code.templates`: a local `dir`, or a cached shallow clone of `url` at `ref` (`refresh: true` re-fetches it). |
| `coder::scaffold-worker` | Create a worker from a template in a missing or empty folder whose last segment is the worker name (default `workers/<name>`), all or nothing, with the template's name token replaced in paths and text, and return `compose_add`, the `compose::add` payload to send whole, adding `start_after` to its entry and missing `requires` as more entries (`{ workers: [compose] }`; the bare `worker` string form drops the scripts). Writes go through the `coder::create-file` path and show in the turn summary. |

Roots come from `fs.host_roots` (with the cwd+`/tmp` fallback noted above);
protection globs come from `code.non_accessible_globs` in the shipped
`config.yaml`'s `code:` block — the **same** list `shell::fs::*` enforces.
`coder::*` returns its own `C2xx` codes. Since 0.10.0, shared `C2xx` and
`S2xx` numbers mean the same failure class; coder-only failures are called
out explicitly below:

| Code | Meaning | fs twin |
|---|---|---|
| `C210` | Malformed input: bad payload, illegal line numbers, overlapping ops. | `S210` |
| `C211` | Path not found, permission denied, matched `non_accessible_globs`, or under `fs.denylist_paths` — deliberately ONE code and wording for all four, so a caller can't probe for a denied path's existence. | `S211` |
| `C213` | `create-file`/`move` saw an existing target and `overwrite=false`. | `S213` |
| `C215` | Path escapes every allowed root, lexically or through a symlink (jailed mode only). | `S215` |
| `C216` | Underlying I/O error. | `S216` |
| `C218` | File exceeds `max_read_bytes`/`max_write_bytes`. | `S218` |
| `C220` | Path resolves inside a configured root but outside the per-call `scope_root` the session is scoped to. | `S220` |
| `C221` | Optimistic whole-file save conflict: the file no longer matches `expected_revision`; no bytes were written. | n/a |
| `C230` | Worker templates unavailable: `code.templates.dir` holds no template manifest, or the templates repo cannot be cloned and no cached copy exists. | n/a |
| `C231` | `coder::scaffold-worker` named a template that does not exist or has no `worker:` block. | n/a |
| `C232` | Worker name breaks `^[a-z][a-z0-9]*(-[a-z0-9]+)*$` (1–63 characters), or the scaffold `directory` does not end with the worker name. | n/a |
| `C233` | Scaffold target directory exists and is not empty; nothing was written. | n/a |
| `C234` | Invalid template: a bad `template.yaml` or `worker:` block, a `files:` entry that is absolute, contains `..`, is missing or leaves `worker.dir` through a symlink, a worker file that is not UTF-8 text (binary files are not supported), or no `containers.<worker.compose>` in its `worker-compose.yaml`. Nothing was written. | n/a |
| `C235` | `coder::scaffold-worker` with `start` (the default) found a container named after the worker already in the stack: adding it would repoint that container at the new folder. Checked before any write, so nothing was written; pick another name, or pass `start: false`. | n/a |

No separate install: `iii call compose::add worker=ide` brings the whole surface.

## Terminal sessions (`shell::pty::*`)

The console's terminal runs on this worker: `shell::pty::open` starts a
persistent host PTY, `write` sends base64 keystrokes, `resize` follows the
pane, `attach`/`detach` survive a reload (buffered output replays from a
sequence number, out of a 2 MiB ring buffer), and `close` terminates the
session. These are console plumbing — flagged `internal` and `trace_hidden`,
so their spans stay out of the default trace view; one span per keystroke
buries the work worth reading.

A session runs the user's login shell by default, or the program a caller
names:

| Field on `open` | Meaning |
|---|---|
| `program` | Program to run instead of the login shell, resolved on PATH. A caller that can open a login shell can already run any program by typing it, so this is reach, not privilege — it is what lets a session BE one program, with no shell around it. |
| `args` | argv for `program`; ignored without it. |
| `env` | Extra environment for the session. Deny-only exactly like `shell::exec`'s per-call `env`: an exec-hijacking key (`PATH`, `LD_*`, `DYLD_*`, `BASH_ENV`, ...) refuses the whole call, and so does a key that is not a variable name (`[A-Za-z_][A-Za-z0-9_]*`) — a key like `PATH=/tmp/evil` would otherwise carry its own assignment past the `PATH` rule. |

`cwd` resolves through the same jail as `shell::fs::*`, so what it can reach is
whatever that config says:

- `fs.host_roots` set: a session can only start inside a configured root.
- `fs.allow_unjailed: true` with `fs.host_roots` empty (**the shipped
  default**): there is no root to be inside, and `cwd` reaches the real
  filesystem, held back only by `fs.denylist_paths`.

Either way this bounds where a session STARTS. A login shell can then `cd`
anywhere its user can, which is the point of a terminal.

Output goes to a browser-registered console handler and nowhere else:
`iii::<worker>-ui::pty-output::console-<browser-id>`. The `<worker>-ui`
segment is not pinned to this worker's own page — a worker that runs its own
program in a session serves its own console page, and therefore its own
handler — but the shape is enforced, so a session can never be pointed at an
arbitrary function on the bus.

Each pane carries a font-size stepper (8–40 px, 14 by default; Ctrl or ⌘ +
scroll does the same). The size is a browser preference rather than a worker
setting — the same person reading the same engine from a laptop and from a
wall display wants two different answers — so it is stored per browser and
read by every terminal the console renders, this worker's panes and the
agent-CLI pages alike. A change refits the pane, which resizes the PTY
through the ordinary `shell::pty::resize` path.

### Taking a session back (`shell::pty::adopt`)

The reconnect token is what proves a caller opened a session, and a browser
that loses its storage loses the token while the program keeps running — an
agent still working in a workspace nobody can reach. `shell::pty::adopt` is
the way back, under two rules that keep it from being a way in:

- **The session must be unattached.** A terminal someone is watching is never
  taken from them; only one nobody holds.
- **The new output handler must name the same console page as the old one.**
  The browser id may differ — that is the point — but `iii::claude-ui::…`
  cannot adopt a session whose output went to `iii::pi-ui::…`.

Credentials rotate on adoption, so whatever the previous owner still held
stops working, and an in-flight retry of the old attach cannot resurrect it.
`shell::pty::sessions` is how a page finds its orphan: every session reports
`ui`, the console page it belongs to (a page, never a browser), whether or not
anyone is attached.

`shell::pty::sessions` lists live sessions with their program, cwd, status,
last sequence number, replayable frames and bytes, and current output target.
No credentials: it exists to separate a terminal that shows nothing because
no output arrived from one whose frames the browser dropped.

## Commit messages (`shell::scm::*`)

The Commit panel's **Generate** button writes a commit message with an LLM.
The page runs git and prepares the diff text; this worker reads its live
`commit_messages` config (model, reasoning effort, extra instructions), builds
the prompt and calls `router::complete` on the `llm-router` worker (120 s
timeout, at most 1024 output tokens). Both functions are console-only
(`internal: true`, denied to agents in `iii-permissions.yaml`: a call spends
model budget outside the harness loop's accounting). Install and configure a
provider on `llm-router` first.

| Function | Request | Response |
|---|---|---|
| `shell::scm::commit-message-config` | `{}` | `{ "model": string \| null, "thinking": string, "instructions": string }` from the live config (`model` trimmed, blank → `null`). |
| `shell::scm::commit-message` | `{ "changes": string, "recent_subjects"?: string[], "fallback_model"?: string \| null }` | `{ "message": string, "model": string }`, `model` being the id actually used. |

- **Model**: `commit_messages.model` when set, else `fallback_model` (the
  panel passes the chat's default model), else `NO_MODEL`. `provider::model`
  is split on the first `::` into the router's `provider` and `model`; a bare
  id sends only `model`.
- **Thinking**: sent to the router as `thinking_level`; `default` omits it.
- **Prompt**: a fixed system prompt (imperative summary of at most 72
  characters, optional 72-column body, no preamble or fences) followed by
  `commit_messages.instructions` under "The repository's own instructions win
  over the rules above:". The user turn lists `recent_subjects` for style,
  then `changes`, cut at 60000 characters with a trailing `[diff truncated]`.
- **Result**: the text blocks of the answer, trimmed, with a whole-text
  Markdown fence and one pair of wrapping quotes removed.

Errors carry `{ code, message }`:

| Code | Meaning |
|---|---|
| `NO_MODEL` | `commit_messages.model` is unset and no `fallback_model` was passed. |
| `EMPTY_CHANGES` | `changes` is empty or whitespace. |
| `ROUTER_FAILED` | `router::complete` failed (provider not configured, `llm-router` not installed, timeout) or the model's turn ended in an error; the message names `router::complete` and the router's reason. |
| `EMPTY_MESSAGE` | The model answered with no text. |

## Two surfaces, one contract

`shell::fs::*` and `coder::*` are two views of the same filesystem, served by
this one worker under ONE policy: the same jail (`fs.host_roots`), the same
unjailed opt-in (`fs.allow_unjailed`), the same protected globs
(`code.non_accessible_globs`), the same operator denylist
(`fs.denylist_paths`), and — since 0.10.0 — the same error-code semantics
(equal digits, equal meaning) and the same existence redaction (a denied path
reads exactly like a missing one on both surfaces).

They differ in ergonomics, and each operation has a twin:

| Task | `coder::*` (agent-ergonomic) | `shell::fs::*` (byte-level) |
|---|---|---|
| read | `coder::read-file` — inline text, windowed, batched | `shell::fs::read` — streams bytes via channel |
| create | `coder::create-file` — batched inline text | `shell::fs::write` — inline or streamed, modes |
| edit | `coder::update-file` — line ops + regex, post-apply echoes | `shell::fs::sed` — regex replace across files |
| delete | `coder::delete-file` — batched, per-entry errors | `shell::fs::rm` — single path |
| list | `coder::list-folder` / `coder::tree` — paginated, noise-filtered | `shell::fs::ls` — single directory, paginated |
| move | `coder::move` — batched, cross-root copy+delete | `shell::fs::mv` — single path |
| search | `coder::search` — budgeted, context lines | `shell::fs::grep` — raw matches |
| introspect | `coder::info` — mode, roots, caps, globs | `shell::fs::stat` — one path's metadata |

Conventions (hold these when adding functions to either surface):

- **Batching**: `coder::*` operations are batched with per-entry error
  isolation. `shell::fs::*` point operations are single-path (`write` and
  `sed` are the historical exceptions).
- **Naming**: `coder::*` uses verb-noun kebab (`read-file`); `shell::fs::*`
  uses the unix tool name (`read`, `ls`, `mv`). Follow the surface you are
  extending.
- **Errors**: one `{ code, message }` envelope; C/S codes with equal digits
  mean the same failure class (see [Errors](#errors)). Redaction: never let
  a denied path read differently from a missing one.
- **Discovery**: `coder::info` is the agent-facing contract report.
  `shell::config-status` (reload health), `shell::workspace::*` (console
  working-directory picker and the explorer's chunked byte reads) and
  `shell::turns::*` (per-session change history and its `revert`) are
  operator/console control plane, not agent tools.
- **Sandbox**: `shell::fs::*`/`shell::exec` accept `target: sandbox`;
  `coder::*` is host-only.

## Observable functions

The worker hosts four event functions. Each is private (only this worker
calls it) and its handler returns the event it is called with, so its
observers observe the event. iii delivers each call to the observers that
want it.

| Function | Called when | Args | Event |
|---|---|---|---|
| `shell::on-job-finished` | A background job finishes, is killed, or fails. An observer with a `job_id` added after its job finished receives the retained completion once it is accepted, from a call only that observer receives. | `{ job_id? }` | `{ job_id, argv, status, exit_code, started_at_ms, finished_at_ms, duration_ms }` |
| `shell::on-changed` | A file or directory under the observed directory changes. | `{ path, include_ignored? }` | `{ path, kind, root, dir, ignored }` |
| `shell::on-git-changed` | The repository `args.path` sits in changes state: HEAD, the index, refs, another worktree, or the repository itself appearing or going (a path in no repository is watched for a `.git`). One call per ~200 ms batch. `.git` is what `shell::on-changed` leaves out. | `{ path }` | `{ path, worktree?, git_dir?, common_dir?, changes: ["head" \| "index" \| "refs" \| "worktrees" \| "repository"] }` |
| `shell::turns::on-changed` | A session's change history (`shell::turns::list`) is stored again: a turn opened or closed, a change recorded. At most one call per session per ~200 ms. | none; filter on `input.session_id` | `{ session_id }` |

An observer of `shell::on-changed` or `shell::on-git-changed` whose `path`
the path policy refuses, or that is not a directory, is rejected with
`INVALID_PATH`; one whose watcher does not start is rejected with
`WATCH_FAILED`.

One observer per consumer pattern:

```ts
// wake on one background job, once
iii.observe(
  { function_id: 'shell::on-job-finished', args: { job_id }, deliver: 'output' },
  'builds::on-job-finished',
  { max_fires: 1 },
)

// follow a directory
iii.observe(
  { function_id: 'shell::on-changed', args: { path: '/some/dir' }, deliver: 'output' },
  'indexer::on-file-changed',
)

// follow one session's change history
iii.observe(
  {
    function_id: 'shell::turns::on-changed',
    filters: { 'input.session_id': { $eq: sessionId } },
    deliver: 'output',
  },
  'review::on-turns-changed',
)
```

### Background job completion (`shell::on-job-finished`)

Choose a job ID, observe its completion, then start the job with
`shell::exec_bg` using that same ID. The observer tells you when the job
ends without polling `shell::status`.

`shell::exec_bg` accepts an optional `job_id` on both host and sandbox targets.
It must be a string containing at least one non-whitespace character. Omitting
it or passing `null` generates a `job-<UUID>` ID. A supplied ID is reserved
atomically before the command starts or the sandbox execution request is
sent. If that ID already belongs to a running job or a retained finished job,
the request is rejected without executing the command or changing the
original job.

IDs belong to one worker process. Generate a fresh, globally unique ID for
every execution, even after an older record has been pruned. This field is
not an idempotency key: resubmitting an ID does not resume or return its
original job.

The function's args accept one field:

| Field | Type | Default | Meaning |
|---|---|---|---|
| `job_id` | Optional string | Every job | Receive the named job's completion. Omit it to receive future completions of all background jobs in this worker process. |

An observer with a `job_id` also receives the result if its job has already
completed, including a job that finished before `shell::exec_bg` returned.
Once iii accepts the observer, the worker calls the function again with the
retained event and `{ "for_observer": "<observer id>" }` as the call's
metadata, and the function's match hook gives that call to that observer
alone. iii delivers it as it delivers a live completion: in the shape the
observer's `deliver` asks for (the `Observed` envelope by default), through
its filters and `on`. Each observer of a job receives its completion once
each time iii adds it, from the live call or from the replay. An observer
without a `job_id` receives future completions only; it does not replay
existing records.

iii removes an observer from this worker when the observing worker's
connection drops, and adds it again when that worker comes back (it
reattaches, or observes again under the same observer id). An observer
added again after its job finished receives the retained completion from a
replay: for the first time when the job ended while its worker was away,
and a second time when it received the completion before the connection
dropped. An observer with `max_fires: 1` ends with its first delivery, so it
receives the completion once.

For a harness agent, generate a new ID, such as `job-` followed by a UUID.
Call `harness::observe` with that ID and wait for it to return before starting
the job. Save its returned `observer_id` for cleanup if execution cannot
start. Omitting `target` wakes the calling session; sibling workers observe
with their own function as the target, as above:

```json
{
  "function_id": "shell::on-job-finished",
  "args": { "job_id": "<new-unique-job-id>" },
  "once": true
}
```

Then call `shell::exec_bg` with the same ID and await its response:

```json
{
  "job_id": "<new-unique-job-id>",
  "command": "pnpm",
  "args": ["build"],
  "cwd": "/path/to/project"
}
```

If `shell::exec_bg` fails before starting a job, the function is never called
for that attempted job. Remove the observer by calling `harness::unobserve`
with the saved observer ID:

```json
{ "id": "<returned-observer-id>" }
```

A session that may not remove observers, including a leaf sub-agent, can
bound the wake when observing with
`"lifecycle": { "expires_in_ms": 600000 }` (ten minutes in this example).
An expiry notification reports that the wake ended; it is not a job completion
event.

The job can also start first: call `shell::exec_bg` without `job_id`:

```json
{ "command": "pnpm", "args": ["build"], "cwd": "/path/to/project" }
```

Then call `harness::observe` with the generated `job_id` from the response. A
job that already completed is replayed while its record remains in memory:

```json
{
  "function_id": "shell::on-job-finished",
  "args": { "job_id": "<returned-job-id>" },
  "once": true
}
```

The completion event contains:

| Field | Type | Meaning |
|---|---|---|
| `job_id` | String | The ID returned by `shell::exec_bg`. |
| `argv` | Array of strings | The command and arguments as spawned. |
| `status` | String | `finished`, `killed`, or `failed`; never `running`. |
| `exit_code` | Integer or `null` | The process exit code, when available. |
| `started_at_ms` | Non-negative integer | Job start time in milliseconds since the Unix epoch. |
| `finished_at_ms` | Non-negative integer | Completion time in milliseconds since the Unix epoch. |
| `duration_ms` | Non-negative integer | Elapsed milliseconds, clamped to zero if the clock moved backwards. |

The event excludes stdout and stderr. Read captured output with
`shell::status` using `{ "job_id": "<returned-job-id>" }`.

Replay and output retrieval depend on the job record remaining in this
worker's memory. Finished records are pruned after `job_retention_secs`
(default `3600`, one hour after completion), and a worker restart loses them.
Observe and retrieve output within that window; a pruned record cannot be
replayed and `shell::status` returns `S211`. Delivery is best effort, without
retries or a durable event history.

### Live change feed (`shell::on-changed`)

`shell::on-changed` is backed by a system-level directory watch (FSEvents on
macOS, inotify on Linux, via the `notify` crate). An observer names the
directory in its args:

```ts
iii.observe(
  { function_id: 'shell::on-changed', args: { path: '/some/dir' }, deliver: 'output' },
  'my-worker::on-changed',
)
```

Paths the root ignores — git-ignored inside a repository; `data/`,
`config/`, `.iii/`, `node_modules/` and the root `.gitignore` outside one —
are **not delivered** unless the observer also sets `include_ignored: true`.
A watch on a project root would otherwise fire on the engine's own
`data/observability` and `data/session-manager` writes, i.e. on the
observer's own transcript. The workspace UI opts in to count them.

The worker calls the function once per change, and iii delivers the call to
the observers of the watch that saw it. The engine hands each target its
observer's `metadata`; the harness matches a wake to its `__binding` that
way.

Observers of one directory (and `include_ignored` setting) share one
watch: the first starts it, and the watch stops when its last observer is
removed (the console disposes a closed tab's observer). The watch puts one
OS watch on each directory, `.git` and symlinks left out, and gitignored
trees too unless the observer sets `include_ignored: true`. It takes at most
32768 directories (a sixteenth of the host's `fs.inotify.max_user_watches`
when that is less), breadth-first from the root: on a bigger tree, changes
deeper down go unreported and the worker logs a warning once; the observer
is never refused for it. On macOS one recursive FSEvents watch covers the
whole tree instead. `args.path` goes through the same path policy as every
`coder::*` call — jail containment (`fs.host_roots`), the operator denylist,
canonicalization — and must be a directory: watching a tree is a read of
every filename under it, so a path you can't read is a path you can't watch.

The payload is lean — `{ path, kind, root, dir, ignored }` with `kind` ∈
`created | modified | deleted`, `path` relative to the watched `root`,
`dir` true when the path is a directory (an observer that opens files must
skip those; deleted paths can't be probed and report false), and `ignored`
true when git ignores the path (always false outside a repository). An
observer that wants content asks `coder::read-file`; one that wants the
diff asks git.

Semantics:

- **Catches every writer** — an agent calling `coder::*`, a
  `shell::exec` command's side effects, or an editor outside the engine
  entirely. Nothing is coupled to the harness and no other worker is
  involved.
- **Coalesced**: raw OS events storm, so each watcher batches per path
  in a 200 ms window; kinds merge toward the visible outcome (a create
  plus the write that fills it is `created`, a deletion supersedes what
  came before, a create-after-delete is `created`). `.git` internals
  are filtered — the visible outcome arrives as worktree events of its
  own.
- **Best-effort fan-out**: the worker calls the function without waiting
  for a reply; a slow or absent observer never delays anything.

The console explorer page observes this on its browsed root: the tree and
git panels refresh live, a clean active buffer follows the disk (a dirty
one keeps the user's edits), and the last written file follows the
writer into view as a diff — git baseline in a repo, empty baseline for
created files, the page's last-seen content otherwise. Hidden segments,
noise directories (`target`, `node_modules`, `dist`, `build`, …) at any
depth, and build/temp artifact extensions never steal the view.

## Hot-reload

When the `configuration` worker pushes an updated config, the shell worker swaps in the new security policy and fs backend atomically. A few things to know:

- Each call executes against one consistent runtime snapshot; there is no mid-call config change.
- Already-running background jobs are **not** retroactively re-checked when the policy tightens — they continue under the policy that was active when they were spawned.
- A reload that widens the jail (for example, clearing `host_roots`) succeeds but is logged as a privilege change.
- If the incoming config is invalid or unsafe, the worker keeps the last-good runtime and logs an error. The rejection is also surfaced through `shell::config-status` (a `rejected` outcome with a non-zero `rejected_reloads` count), so the divergence between the central store and the policy shell is actually enforcing is detectable instead of silent. Rejections are kept last-good and not retried (re-fetching returns the same bad value), so they will not retry-storm.
- At boot the reconcile against the configuration worker is **fail-closed**: the worker refuses to start (and exposes no functions) if it cannot confirm the authoritative config, so it never serves a possibly stale security policy.

## Errors

Returned error bodies carry a stable `code` field. Denylist rejections come back as a plain message (`command matches denylist: <pattern>`) rather than an S-code.

| Code | Meaning |
|---|---|
| `S200` | In-VM execution failure on a sandbox target. |
| `S210` | Invalid request: non-absolute path, empty command or pattern, bad octal mode, malformed payload, `sandbox.enabled: false` on a sandbox-targeted call, a `cwd` that is not a directory, an `env` key in the dangerous-key denylist, `cwd`/`env`/`stdin` supplied on a sandbox target (host-only), an inline string `content` on a sandbox-targeted `shell::fs::write`, or both single `path`/`content` and `files` on `shell::fs::write`. |
| `S211` | Path not found, permission denied, matched the protected globs, or under `fs.denylist_paths` — ONE code and wording for all four (redaction: a denied path reads exactly like a missing one). Includes a `cwd` that does not exist. |
| `S212` | Wrong file type for the operation (for example, a file where a directory was expected). |
| `S213` | Path already exists. |
| `S214` | Directory not empty (non-recursive `rm`). |
| `S215` | Path (or a per-call `cwd`) escapes the `fs.host_roots` jail — exclusively a confinement escape; denylist and permission-denied fold into `S211`. |
| `S216` | Generic shell-internal failure: host spawn error, channel error, or a bad engine response. |
| `S217` | Invalid regex passed to `grep`/`sed`. |
| `S218` | `fs.max_read_bytes` / `fs.max_write_bytes` cap exceeded. |
| `S220` | Path (or a per-call `cwd`) is inside an allowed root but outside the session's `fs_scope.root` and grants (`workspace` boundary). |
| `S222` | A scoped host exec under `fs.exec_confinement: landlock` could not be confined (no Landlock: kernel, seccomp, non-Linux); it did not run. Set `fs.exec_confinement: off` to run unconfined. |
| `S300` | Sandbox VM boot failed (needs a virtualization host: Apple Silicon or `/dev/kvm`). |

Sandbox-forwarded `fs::*`/`exec` errors can also surface engine codes verbatim instead of collapsing to `S216`: `S001`–`S004` (sandbox lifecycle), `S100`–`S102` (image/VM/resource), `S300`, `S400`, and the sandbox router's `S301` (adapter not connected; retryable), `S302` (adapter on another contract major), `S303` (no adapter bound) and `S304` (adapter lacks the operation). Branch on the specific code where relevant; only an unrecognized engine code falls back to `S216`.

## Upgrading to 0.10.0

- **BREAKING: three `coder::*` error codes renumbered** to align with the
  `S2xx` scheme (equal digits now mean the same failure class on both
  surfaces): already-exists `C217` → `C213`; too-large `C213` → `C218`;
  outside-session `C218` → `C220`. Consumers branching on the old numbers
  must update; the approval-gate's jail-scope allowlist ships the matching
  change in the same release wave.
- **BREAKING: `shell::fs::*` existence redaction.** Permission-denied,
  protected-glob (`code.non_accessible_globs`), and `fs.denylist_paths`
  rejections now return `S211` with the same "not found or not accessible"
  wording as a missing path, instead of `S215`. `S215` is now exclusively a
  jail-confinement escape. Callers must not distinguish "missing" from
  "denied" — that distinction was an existence-probing side channel.
- **`coder::*` is permissive when unjailed.** With the explicit opt-in
  (`fs.allow_unjailed: true`, empty `fs.host_roots`) the coder surface now
  follows the same deny-only policy as `shell::fs::*`: absolute paths
  anywhere on the host, the cwd + `/tmp` fallback roots demoted to
  relative-path anchors, and the harness-selected working directory trusted
  as the anchor. `fs.denylist_paths` now applies to `coder::*` in every
  mode. Jailed deployments (explicit `fs.host_roots`) are unchanged.
  `coder::info` reports the effective `mode`.

## Upgrading to 0.8.0

- **BREAKING: `allowlist` removed.** The command allowlist is gone — the
  shell is deny-only now (see [Configure](#configure)); allow/ask policy
  lives entirely in the approval-gate. Any stored config still carrying the
  key, including the inert `allowlist: []` written by older seeds, fails
  closed at boot with a migration hint. Rewrite the stored value to drop it:

  ```ts
  import { registerWorker } from 'iii-sdk'
  const iii = registerWorker(process.env.III_URL ?? 'ws://127.0.0.1:49134')
  const { value } = await iii.call({ function_id: 'configuration::get', payload: { id: 'shell' } })
  delete value.allowlist
  await iii.call({ function_id: 'configuration::set', payload: { id: 'shell', value } })
  ```

  If you seed from a `--config` file instead, just delete the `allowlist: []` line.

- **BREAKING (fresh installs only): the fs jail is unjailed by default.** The
  shipped `config.yaml` now sets `fs.allow_unjailed: true` with empty
  `fs.host_roots` — `shell::fs::*` and `shell::exec`'s per-call `cwd` operate
  against the real filesystem by default (see
  [Zero-config default](#zero-config-default)). This only affects a
  genuinely fresh install or a config that gets nulled — an existing
  deployment with an explicit `fs.host_roots` already in its stored config is
  unaffected; the stored value always wins over the seed. To keep the old
  jailed-to-`/tmp` behavior, set it explicitly:

  ```yaml
  fs:
    host_roots: [/tmp]
  ```

- **The per-call `env` override is deny-only.** `env.allow` no longer gates
  which keys `shell::exec`/`exec_bg`'s per-call `env` may set — only the
  hardcoded dangerous-key list does (see [Per-call `cwd`, `env`, and
  `stdin`](#per-call-cwd-env-and-stdin-host-target)). This is a pure
  widening: nothing that worked before now fails, and no stored config needs
  a rewrite. `env.allow` keeps its other job (forwarding when `env.inherit`
  is false) unchanged.

## Upgrading to 0.7.0

- **BREAKING: env config keys renamed and nested.** The top-level `inherit_env`
  and `allowed_env` keys are replaced by one `env` block — no legacy aliases:

  ```yaml
  # 0.6.x                                # 0.7.0
  inherit_env: true                      env:
  allowed_env: [PATH, HOME, LANG]          inherit: true
                                           allow: [PATH, HOME, LANG]
  ```

  The old keys are **rejected at parse** with a migration hint ("config keys
  removed in 0.7.0: `inherit_env` -> `env.inherit` ..."), because serde would
  otherwise ignore them silently and boot with env forwarding OFF. A stored
  configuration value still carrying the old keys makes the worker fail closed
  at boot; rewrite it via `configuration::set` (id `shell`) with the nested
  shape. **Sequencing matters**: update the binary FIRST, then the stored
  value — writing the new shape while 0.6.x is still running makes the old
  worker hot-reload it, ignore the unknown `env` block, and silently stop
  forwarding env until restart. A **half-migration** (nesting the OLD key
  names under the new block, e.g. `env: { inherit_env: true }`) is also
  rejected — `env` denies unknown fields — rather than silently falling back
  to the wider default `allow` list.
  A `--config` seed file carrying any of these removed keys now **aborts
  boot** rather than warning and falling back to the permissive built-in
  seed; only a genuinely missing seed file falls through gracefully.
- **BREAKING: `fs.host_root` (single-root alias) removed.** The 0.6.x
  one-entry alias for the jail root is **rejected at parse** with a migration
  hint ("config key removed in 0.7.0: `fs.host_root` -> `fs.host_roots`
  (one-entry list)"). Replace it with the list form:

  ```yaml
  # 0.6.x                                # 0.7.0
  fs:                                    fs:
    host_root: /srv/app                    host_roots: [/srv/app]
  ```

  Same fail-closed rationale as the env keys: serde would otherwise ignore
  the stale key and the worker would see no jail configured at all.
- **BREAKING: `code.base_path`/`code.base_paths` removed from the schema and
  REJECTED at parse.** They were inert even before removal — the code
  resolver has taken its roots from `fs.host_roots` since the coder merge —
  but this is a hard migration: "never had an effect" is not an exception. A
  stored value still carrying either fails closed with a hint naming both
  keys, same as every other removed key. Set the jail once via
  `fs.host_roots`.
- **The one-shot coder→shell config migration is removed.** 0.7.0 no longer
  folds a legacy standalone-`coder` configuration entry into the `shell`
  value at boot, and the hidden `migrated_from_coder` marker field is
  REJECTED at parse rather than silently tolerated. Boot also no longer
  probes `configuration::get` for the `coder` entry, so the
  "configuration 'coder' not found" WARN retries at startup are gone.
  **Two distinct upgrade scenarios**:
  - An install that ALREADY has a `shell` entry (it went through the fold
    under 0.6.x, so the entry carries `migrated_from_coder: true`) now fails
    closed at 0.7.0 boot with a migration hint, instead of silently parsing
    past the marker.
  - An install with ONLY a standalone `coder` entry and NO `shell` entry at
    all has nothing to reject — it still boots 0.7.0 with the generic
    permissive `/tmp` dev seed for `shell`, silently, because there is no
    stored `shell` value to fail closed on. The old `coder` roots and
    protected globs are NOT carried over. Boot 0.6.x once first (it performs
    the migration and writes the `shell` entry) before upgrading to 0.7.0 to
    avoid this.
- **`--version` added**, and `--url`/`III_URL` and `RUST_LOG` are now
  documented (see [Running](#running)).
- **Unreachable-engine boot is loud**: one ERROR naming the host/port and the
  fix hint, instead of only the SDK's silent retry WARNs. The probe runs
  detached so it never delays boot.
- **Every config field now carries a schema description**, so the console
  configuration UI documents each knob inline.
- **Command-shaped denylist patterns tolerate a wrapper prefix**
  (`sudo`/`doas`/`nohup`/`env`/`timeout [duration]`, optionally
  path-qualified): `sudo shutdown -h now` trips the tripwire again.

## Upgrading to 0.4.0

0.4.0 is a breaking release. Migrating from 0.3.x:

- **`shell::fs::chmod` response field renamed** `updated` → `entries_changed`; read `entries_changed`. Inbound legacy `{ updated }` from an older engine still deserializes (serde alias), but new consumers should read `entries_changed`.
- **`mkdir`/`rm`/`mv` gained structured fields** (`path`/`already_existed`, `path`/`was_present`, `src`/`dst`/`overwrote`) alongside the original boolean. Additive: existing readers of `created`/`removed`/`moved` keep working.
- **Configuration moved to the central `configuration` worker** (database-style) with hot-reload. `--config` is now a first-boot seed only; the live value from the configuration worker wins once an entry exists. `--manifest` was removed (the interface is collected live).
- **New read-only `shell::config-status`** reports the last hot-reload outcome. Operator/automation only; it is denied to agents.
- **Error envelope: the S-code is now the top-level wire `code`.** Every S-coded failure returns `{ "code": "S211", "message": "no such job: ..." }` with the S-code as the top-level `code`. Previously the code was buried — handlers returned the `{code,message}` JSON stringified into `message` under the generic `invocation_failed` code. Consumers that branched on `code == "invocation_failed"` or parsed the embedded JSON out of the message must update to read `error.code` directly.
- **Host background jobs are unbounded by default.** A host-targeted `shell::exec_bg` job runs until it exits or `shell::kill` terminates it — long installs, builds, dev-servers, and tails are not killed. To force-kill a runaway host bg job, set a positive `max_bg_timeout_ms` (new operator config field, default `0` = unbounded; separate from `max_timeout_ms`, which bounds foreground `shell::exec`); a job that exceeds it is killed and its status becomes `killed`. Sandbox jobs still honor `timeout_ms`.
- **Per-call `env` denylist broadened.** The always-rejected key set now includes `HOME` and loader/lookup-path keys (`LD_*`, `DYLD_*`, `GCONV_PATH`, …) **and** interpreter/shell startup-file keys (`BASH_ENV`, `ENV`, `PYTHONSTARTUP`, `PERL5OPT`, `RUBYOPT`, `NODE_OPTIONS`). These are rejected even if listed in `allowed_env`. `LD_*`/`DYLD_*` are matched by family prefix, so a loader variable not in the explicit list is still rejected. Note that `HOME` is in the default `allowed_env` for the worker's own forwarded env but is **not** settable per-call.
- **Sandbox-forwarded fs/exec errors can now surface engine S-codes** that 0.3.7 collapsed to `S216` — e.g. `S001`–`S004` (lifecycle), `S100`–`S102` (image/VM), `S300`, and `S400`. Branch on the specific code where relevant.

## Troubleshooting

- **`fs.host_roots is empty ... refusing to start unjailed`**: set `fs.host_roots` to at least one directory, or set `fs.allow_unjailed: true`.
- **`S215 path escapes the fs jail roots` on a path inside the jail**: a symlink in the path resolves outside the jail. Resolve it yourself, or move the target inside a jail root.
- **`S300` on a sandbox target**: the host cannot boot microVMs. Sandbox execution requires Apple Silicon or `/dev/kvm`.
- **Worker never connects**: the engine is not running or not bound on the configured `--url`. Start the engine first; the default WebSocket port is 49134.
- **`config keys removed in 0.7.0: ...` at boot or on reload**: the seed file or the stored configuration value still uses the 0.6.x `inherit_env`/`allowed_env` keys (nest them under `env:` as `inherit`/`allow`) or the single-root `fs.host_root` alias (use `fs.host_roots: [<path>]`) — see [Upgrading to 0.7.0](#upgrading-to-070).
- **`` `allowlist` (removed in 0.8.0, no replacement) `` at boot or on reload**: the seed file or the stored configuration value still carries the removed `allowlist` key (even an empty `allowlist: []` trips this) — see [Upgrading to 0.8.0](#upgrading-to-080) for the fix.
- **A relative path (including `~`) passed to `shell::fs::*`/`cwd` behaves unexpectedly**: paths are never shell-expanded. `~` is not resolved to your home directory — when jailed, a relative path (including one starting with `~`) resolves against the primary `fs.host_roots` entry, so `~/foo` is interpreted as a path literally named `~` under that root, not your home directory; when unjailed, only absolute paths are accepted at all, so a relative path (including `~/foo`) is rejected outright. Pass an absolute, already-expanded path instead.

For the threat model, streaming wire shapes, and contributor build steps, see [ARCHITECTURE.md](ARCHITECTURE.md).

## License

Apache 2.0 — see [LICENSE](https://github.com/iii-hq/workers/blob/main/LICENSE).
