Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -860,9 +860,9 @@ flowchart TB
end
subgraph PL["runtime: pi"]
direction TB
P1["/sandbox/pi-config\nAPPEND_SYSTEM.md · settings.json · skills/\nhooks/ · fullsend-hooks.js · fullsend-manifest.json"]
P1["/sandbox/pi-config\nAPPEND_SYSTEM.md · settings.json · skills/\nhooks/ · fullsend-hooks.js · fullsend-edit-repair.js · fullsend-manifest.json"]
P0{"shell guard, before .env:\nadapter present and SHA-256 = embedded copy?\nmanifest present?"}
P2["pi --print --mode json --no-approve\n--no-extensions [-e anthropic-vertex, on Vertex] -e fullsend-hooks.js\n--tools … --model anthropic-vertex/… #lt;/dev/null"]
P2["pi --print --mode json --no-approve\n--no-extensions [-e anthropic-vertex, on Vertex] -e fullsend-hooks.js\n[-e fullsend-edit-repair.js, with edit] --tools … --model anthropic-vertex/… #lt;/dev/null"]
PX["exit 97 — never runs unhooked\n(Run refuses earlier, exit -1, if the manifest has no hook plan)"]
P1 --> P0
P0 -- yes --> P2
Expand Down
19 changes: 11 additions & 8 deletions docs/contributing/runtime-implementation.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/reference/harness-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ A pi-format entry must also satisfy pi's own loader rule:
- **Containment** — a `pi.extensions` or `main` entry that is absolute or climbs out with `..` is rejected, in a nested `package.json` as well as the top one; pi resolves both with no containment check.
- **Glob entries** (`*`, `?`) are matched against the tree, so a pattern selecting nothing is rejected; `**` and brace patterns are accepted unevaluated, `[...]` is a literal file name to pi, and a leading `!` is a *disable* pattern — a `pi.extensions` made only of `!` entries is rejected.
- **`package.json`** — a UTF-8 byte-order mark is stripped before parsing, as pi strips it.
- **Reserved names** — not `fullsend-hooks`, `anthropic-vertex` or `xai-vertex`, which the runner owns.
- **Reserved names** — not `fullsend-hooks`, `fullsend-agent`, `fullsend-edit-repair`, `anthropic-vertex` or `xai-vertex`, which the runner owns. An entry also must not register a tool named `edit`: pi rejects two extensions that register the same tool name, and the runner's own `fullsend-edit-repair` extension already registers `edit` whenever the agent has the edit tool.
- **`pi.args`** — flags the extension registered with `pi.registerFlag`, each `--flag` or `--flag=value` (pi has no single-dash options), never one of pi's own option names, with no value starting with `-` or `@`. One bare word may follow a `--flag` written without `=`; any other bare word is prompt text pi would prepend to the agent's prompt.
- **`env` keys** match `^[A-Z_][A-Z0-9_]*$` and may not name the interpreter environment (`PATH`, `HOME`, `TMPDIR`, `ENV`, `BASH_ENV`, `SHELL`, `IFS`, `CDPATH`, `PROMPT_COMMAND`, `LD_*`, `DYLD_*`, `PYTHON*`, `NODE_*`, `SSL_*`, `JITI_*`, `GIT_*`, `JAVA_TOOL_OPTIONS`, `RUBYOPT`, `PERL5OPT`), a credential- or proxy-shaped name (`*_API_KEY`, `*_TOKEN`, `*_SECRET*`, `*_PROXY`), a trust-store or resolver name (`HOSTALIASES`, `OPENSSL_CONF`, `SSLKEYLOGFILE`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `GOPROXY`, `GOFLAGS`), or a runner/provider family (`PI_*`, `FULLSEND_*`, `TIRITH_*`, `GOOGLE_*`, `GCLOUD_*`, `CLOUDSDK_*`, `ANTHROPIC_*`, `XAI_*`, `OPENAI_*`, `AZURE_*`, `AWS_*`, `CLOUD_ML_REGION`).

Expand Down
29 changes: 25 additions & 4 deletions docs/runtimes/pi.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,14 @@ What a local pi run needs, beyond the guide:
Code does not, so redaction and unicode normalization apply on both paths.
- **Fast release cadence** (~weekly minors, with wire-format changes inside a minor) — versions are
pinned exactly and the stream-parser fixtures are tied to the pinned version.
- **Malformed `edit` calls are repaired.** Some models send the `edit` tool's `edits` as a JSON
string holding raw newlines, or as an array of JSON strings; the pinned pi rejects both with
`edits.0: must be object` and the model has to redo the call
([earendil-works/pi#8521](https://github.com/earendil-works/pi/issues/8521),
[#8962](https://github.com/earendil-works/pi/issues/8962)). When the agent has `edit`, the runner
loads `fullsend-edit-repair.js`, which parses the argument before pi validates it and logs each
repair to stderr. Hooks still see the edits that get applied. It goes once the pinned pi handles
both shapes.

## Plugins (pi extensions)

Expand Down Expand Up @@ -238,9 +246,12 @@ ever picked up from the target repository.
- **Do not vendor pi's own packages** (`@earendil-works/pi-coding-agent`, `pi-agent-core`,
`pi-tui`). pi resolves those imports to the running pi, so an extension written against the
pinned `PI_VERSION` just works.
- **Pick a free name.** Not `fullsend-hooks`, `anthropic-vertex` or `xai-vertex` — those are the
runner's own sandbox names — and not the directory name another entry already uses. Allowed
characters are `a-z`, `A-Z`, `0-9`, `_` and `-`.
- **Pick a free name.** Not `fullsend-hooks`, `fullsend-agent`, `fullsend-edit-repair`,
`anthropic-vertex` or `xai-vertex` — those are the runner's own sandbox names — and not the
directory name another entry already uses. Allowed characters are `a-z`, `A-Z`, `0-9`, `_`
and `-`. Also do not register a tool named `edit`: pi rejects two extensions that register the
same tool name, and the runner's own `fullsend-edit-repair` extension already registers `edit`
whenever the agent has the edit tool.
- **Give a path or a pinned URL, not a package source.** Entries are paths relative to the harness
repository, or forge `/tree/` URLs pinned with `#sha256=` — the `skills:` rule. `npm:`/`git:`/`ssh:`
sources and `..` segments are refused: pi would fetch them from the network at startup.
Expand Down Expand Up @@ -558,7 +569,8 @@ tool, and the runtime note telling it to execute sub-agent definitions itself, i
|---|---|---|
| `model "<spec>": ...; use opus, sonnet, haiku, or one of ...` | The `model` argument is not one this run can serve | Use one of the forms the message lists, or omit `model` to inherit the parent's |
| `manifest changed since load; refusing to dispatch` | `fullsend-manifest.json` changed after the extension read it | Runner-owned config was rewritten inside the sandbox — treat it as tampering, not a transient |
| `hook adapter changed since load; refusing to dispatch` | `fullsend-hooks.js` changed after bootstrap recorded its digest | The same: the child would otherwise have come up unhooked |
| `fullsend-hooks.js changed since load; refusing to dispatch` | The hook adapter changed after bootstrap recorded its digest | The same: the child would otherwise have come up unhooked |
| `fullsend-edit-repair.js changed since load; refusing to dispatch` | The edit-repair extension changed after bootstrap recorded its digest | The same: the child would otherwise run rewritten code in pi |
| A child call fails after 15 minutes | The per-child deadline; the child is signalled and reaped | Narrow the child's prompt, or split the task across more children |
| A child call reports `error` or `aborted` | The child's own run failed — model error, non-zero exit, or no `agent_end` | Read that child's transcript under `transcripts/<agent>-sub<seq>-*.jsonl` |

Expand Down Expand Up @@ -600,6 +612,15 @@ pi; the plan block's `Runtime:` line and stderr's `runtime: selected ...` show w
**`--debug "..."` fails with `accepts 1 arg(s)`.** `--debug` takes an optional value: write
`--debug='*'` (with `=`).

**`fullsend: pi edit-repair extension missing or modified; refusing to run` (exit 93).** The
runner-owned `/sandbox/pi-config/fullsend-edit-repair.js` is not the copy bootstrap wrote. Treat it
as tampering, like the hook adapter's guard: something inside the sandbox rewrote runner config.

**`Tool "edit" conflicts with .../fullsend-edit-repair.js` and pi exits 1.** One of the harness's
own extensions registers an `edit` tool, and pi refuses two extensions claiming one tool name
whatever order they load in. Rename that tool, or drop `Edit` from the agent's `tools:` so the
repair does not load ([Plugins](#plugins-pi-extensions)).

**The agent fails with nothing in the terminal.** Sandbox-side pi failures land in `pi-debug.log`
inside the run directory, next to the transcripts; kept sandboxes must be removed manually
(`openshell sandbox delete <name>`).
Expand Down
5 changes: 5 additions & 0 deletions images/sandbox/Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ RUN NPM_CLAUDE="$(npm prefix -g)/bin/claude" \
# regex entry in renovate.json. pi releases ~weekly minors and has changed
# its --mode json wire shape within a minor before (0.84.0) — the pi stream
# parser fixtures must be re-verified when this pin moves.
# Also run internal/runtime/pi_extension/fullsend-edit-repair.test.mjs with
# FULLSEND_TEST_PI_BIN at the new pin. That extension works around pi
# rejecting malformed edit arguments (earendil-works/pi#8521, #8962); once
# the test's without-extension control stops failing, this pi repairs both
# shapes itself — delete the extension and internal/runtime/pi_edit_repair.go.
ARG PI_VERSION=0.85.0
RUN node -e "process.exit(process.versions.node.split('.').map(Number).reduce((a,b,i)=>a!==0?a:(b-[22,19,0][i]),0)>=0?0:1)" \
|| (echo "pi ${PI_VERSION} requires Node >=22.19.0, base image has $(node --version)" >&2 && exit 1) \
Expand Down
17 changes: 9 additions & 8 deletions internal/pluginformat/pi.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,15 @@ import (
// silently load nothing from.

// PiReservedExtensionNames are the sandbox names the pi runtime owns: the
// hook adapter's file basename and the vendored provider extensions Run
// loads by path. A declared extension uploads under its directory
// basename, so one of these names would shadow — or be mistaken for —
// runner-owned code. runtime.piResolveRunPlugins refuses them again at
// bootstrap; the check here is so a harness author learns at load which
// entry is the problem. The list lives in this package because both
// internal/harness and internal/runtime read it.
var PiReservedExtensionNames = []string{"fullsend-hooks", "fullsend-agent", "anthropic-vertex", "xai-vertex"}
// basenames of the extensions Bootstrap embeds (hook adapter, Agent tool,
// edit repair) and the vendored provider extensions Run loads by path. A
// declared extension uploads under its directory basename, so one of
// these names would shadow — or be mistaken for — runner-owned code.
// runtime.piResolveRunPlugins refuses them again at bootstrap; the check
// here is so a harness author learns at load which entry is the problem.
// The list lives in this package because both internal/harness and
// internal/runtime read it.
var PiReservedExtensionNames = []string{"fullsend-hooks", "fullsend-agent", "fullsend-edit-repair", "anthropic-vertex", "xai-vertex"}

// piReservedOptions are pi's own command-line options (cli/args.ts, read
// at 0.84.4; at 0.85.0 args.js adds only help text for PI_SERVER_DIR and
Expand Down
63 changes: 45 additions & 18 deletions internal/runtime/pi_bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ type piAgentManifest struct {
// extension re-hashes them before every dispatch; see
// piAgentExtensionDigests for why the vendored ones are absent.
ExtensionDigests map[string]string `json:"extensionDigests,omitempty"`
// EditRepairExtension is the edit-repair extension's -e path (see
// pi_edit_repair.go), empty when the children's tool set has no edit.
// It is kept out of Extensions on purpose: the extension registers the
// edit tool, and a child launched with --no-builtin-tools (a persona
// declaring no tools) does not filter extension tools, so the Agent
// extension adds it only for a child whose --tools list names edit.
EditRepairExtension string `json:"editRepairExtension,omitempty"`
// Models maps "default" (the agent's model) and the Claude aliases to
// pi model specs; the extension translates a child's `model` through
// it and rejects anything else it cannot serve.
Expand Down Expand Up @@ -288,6 +295,12 @@ func (r PiRuntime) Bootstrap(input BootstrapInput) error {
Extensions: extensions,
}

if piEditRepairEnabled(tools) {
if err := uploadBytes(sandboxName, cfg+"/"+piEditRepairExtensionFile, piEditRepairExtensionJS); err != nil {
return fmt.Errorf("installing edit-repair extension: %w", err)
}
}

if hooksEnabled {
hooks := hooksInput.SandboxHookConfig()
if err := installHookScripts(sandboxName, r.piHooksDir(), hooks); err != nil {
Expand Down Expand Up @@ -461,11 +474,6 @@ func (r PiRuntime) piAgentManifestFor(sandboxName string, def *piAgentDef, tools
if err != nil {
return nil, err
}
exts := append([]string{}, providerExts...)
hooksExt := r.ConfigDir() + "/" + piHooksExtensionFile
if hooksEnabled {
exts = append(exts, hooksExt)
}
childTools := []string{}
if tools == nil {
childTools = append(childTools, piDefaultTools...)
Expand All @@ -476,12 +484,19 @@ func (r PiRuntime) piAgentManifestFor(sandboxName string, def *piAgentDef, tools
}
}
}
exts := append([]string{}, providerExts...)
hooksExt := r.ConfigDir() + "/" + piHooksExtensionFile
if hooksEnabled {
exts = append(exts, hooksExt)
}
editRepairExt := r.ConfigDir() + "/" + piEditRepairExtensionFile
editRepair := piEditRepairEnabled(childTools)
manifest := &piAgentManifest{
Enabled: true,
PiBin: piBin,
SessionsDir: r.piSessionsDir(),
Extensions: exts,
ExtensionDigests: piAgentExtensionDigests(hooksExt, hooksEnabled),
ExtensionDigests: piAgentExtensionDigests(hooksExt, hooksEnabled, editRepairExt, editRepair),
Models: piAgentModels(def.Model, configAliases),
ProviderModels: piAgentProviderModels(),
Thinking: piAgentThinking(),
Expand All @@ -491,6 +506,9 @@ func (r PiRuntime) piAgentManifestFor(sandboxName string, def *piAgentDef, tools
TimeoutSeconds: piAgentTimeoutSeconds,
UsageFile: r.piAgentUsagePath(),
}
if editRepair {
manifest.EditRepairExtension = editRepairExt
}

// Resolve per-persona models and the blanket subagents.default (#7031).
// This runs even when no persona was discovered: `subagents.default`
Expand Down Expand Up @@ -520,25 +538,34 @@ func (r PiRuntime) piAgentManifestFor(sandboxName string, def *piAgentDef, tools
}

// piAgentExtensionDigests records the sha256 of every child -e entry that
// Bootstrap itself writes under the runner-owned config dir — today only
// the hook adapter, and the same bytes piHooksGuard checks before pi
// starts. fullsend-agent.js re-hashes them immediately before every
// dispatch: the launch guard fires once, and nothing else re-verifies the
// adapter afterwards, so a parent with `write` could replace it
// mid-iteration and dispatch children whose adapter runs no hooks and
// silently skips its own manifest-digest check. The map travels inside the
// manifest, so the manifest digest already covers it.
// Bootstrap itself writes under the runner-owned config dir — the hook
// adapter (the same bytes piHooksGuard checks before pi starts) and the
// edit-repair extension (piEditRepairGuard's bytes). fullsend-agent.js
// re-hashes them immediately before every dispatch: the launch guard
// fires once, and nothing else re-verifies the adapter afterwards, so a
// parent with `write` could replace it mid-iteration and dispatch
// children whose adapter runs no hooks and silently skips its own
// manifest-digest check. The map travels inside the manifest, so the
// manifest digest already covers it.
//
// The vendored provider extensions under piVertexExtensionPath /
// piXaiVertexExtensionPath are deliberately absent: the image installs them
// root-owned and read-only outside the config dir, so there is nothing
// there for the agent to rewrite and nothing to re-check.
func piAgentExtensionDigests(hooksExt string, hooksEnabled bool) map[string]string {
if !hooksEnabled {
func piAgentExtensionDigests(hooksExt string, hooksEnabled bool, editRepairExt string, editRepair bool) map[string]string {
digests := map[string]string{}
if hooksEnabled {
sum := sha256.Sum256(piHooksExtensionJS)
digests[hooksExt] = hex.EncodeToString(sum[:])
}
if editRepair {
sum := sha256.Sum256(piEditRepairExtensionJS)
digests[editRepairExt] = hex.EncodeToString(sum[:])
}
if len(digests) == 0 {
return nil
}
sum := sha256.Sum256(piHooksExtensionJS)
return map[string]string{hooksExt: hex.EncodeToString(sum[:])}
return digests
}

// piAgentModels is the child model table: "default" is the agent
Expand Down
Loading
Loading