From a79365358160929d0f56e88c0e690a50bf0e4808 Mon Sep 17 00:00:00 2001 From: Wayne Sun Date: Fri, 21 Aug 2026 17:14:31 -0400 Subject: [PATCH 1/3] refactor(runtime): register pi as a stub runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register PiRuntime (earendil-works/pi, CLI `pi`) in runtime.Resolve() following the OpenCode stub precedent (#6035): resolvable internally for dev/testing, deliberately NOT added to config.ValidRuntimes() — a selectable stub would burn pre-script side effects and a sandbox before failing — so it is not user-selectable via `fullsend admin install --runtime` until the runtime is functional. - internal/runtime/pi.go: Name "pi", System "pi" (multi-provider, OpenCode precedent), ConfigDir /sandbox/pi-config (new sandbox.SandboxPiConfig, outside the agent-writable workspace), EnvExports pinning PI_CODING_AGENT_DIR / PI_CODING_AGENT_SESSION_DIR to runner-owned paths plus PI_OFFLINE=1 and PI_SKIP_VERSION_CHECK=1; Bootstrap/Run and transcript extraction return explicit not-implemented errors (#6464). - images/sandbox/Containerfile: ARG PI_VERSION=0.84.2, npm install --ignore-scripts, with a renovate customManagers regex tracking the pin; the install comment records that pin bumps must re-verify the upcoming stream-parser fixtures (pi changed its --mode json shape within 0.84). - docs/runtimes.md: registered-runtimes row, pi (stub) column in the security feature matrix and the config-key support matrix. - Tests: pi_test.go (metadata, env exports, not-implemented, no-ops, capability defaults — no CLAUDE.md bridge, default debug-log name); registry and config tests extended with the resolvable-but-not-selectable cases. Next steps tracked in #6464: stream parser with recorded fixtures, Bootstrap/Run with the ADR 0090 hook-adapter extension, transcript extraction, then ValidRuntimes() enable. Refs #6464 Assisted-by: Claude Signed-off-by: Wayne Sun --- docs/runtimes.md | 47 +++++++++-------- images/sandbox/Containerfile | 15 ++++++ internal/config/config_test.go | 1 + internal/runtime/pi.go | 87 +++++++++++++++++++++++++++++++ internal/runtime/pi_test.go | 72 +++++++++++++++++++++++++ internal/runtime/registry.go | 3 ++ internal/runtime/registry_test.go | 13 +++++ internal/sandbox/sandbox.go | 4 ++ renovate.json | 10 ++++ 9 files changed, 229 insertions(+), 23 deletions(-) create mode 100644 internal/runtime/pi.go create mode 100644 internal/runtime/pi_test.go diff --git a/docs/runtimes.md b/docs/runtimes.md index 4164e4ffe4..f9c98c0c89 100644 --- a/docs/runtimes.md +++ b/docs/runtimes.md @@ -10,24 +10,25 @@ When adding a runtime, fill in the security matrix below and register it in `run |---------|---------|-----------| | `claude` | Production agent runs via Claude Code | Required | | `opencode` | OpenCode agent runs (stub — not yet functional; resolved by `runtime.Resolve()` but not in `ValidRuntimes()` until implemented) | Required | +| `pi` | pi agent runs (stub — not yet functional; resolved by `runtime.Resolve()` but not in `ValidRuntimes()` until implemented; #6464) | Required | | `dummy` | Behaviour tests — scripted ops in real sandbox | None | ## Security feature matrix -| Feature | Where it runs | Claude Code | OpenCode (stub) | Notes for future runtimes | -|---------|---------------|-------------|-----------------|---------------------------| -| **Host-side context injection scan** (DeBERTa / LLM Guard, unicode, SSRF patterns on repo context files) | Host + sandbox `scan context` | ✓ | N/A — stub | Requires sandbox image with ML models; harness `security.host_scanners` | -| **Host-side runtime content scan** (agent def, SKILL.md, plugin JSON before upload) | Host (`scanRuntimeContent`) | ✓ | N/A — stub | Uses `security.InputPipeline()`; not part of `Runtime` interface — runner responsibility | -| **Tirith** (Bash command scanning) | Sandbox PreToolUse hook | wired; **not loaded by Claude Code today** (#6358) | N/A — stub | `tirith_check.py`; harness `security.sandbox_hooks.tirith`; fails open on missing binary/timeout unless `TIRITH_REQUIRED=1` | -| **SSRF pre-tool** | Sandbox PreToolUse hook | wired; not loaded (#6358) | N/A — stub | `ssrf_pretool.py`; default on | -| **Canary token detection** | Sandbox Pre/PostToolUse hooks | wired; not loaded (#6358); post-tool field mismatch (#6357) | N/A — stub | `canary_pretool.py` / `canary_posttool.py`; both inert unless `FULLSEND_CANARY_TOKEN` is set | -| **Secret redaction** | Sandbox PostToolUse hook | wired; not loaded (#6358); not effective even when loaded (#6357) | N/A — stub | `secret_redact_posttool.py` | -| **Unicode normalization** | Sandbox PostToolUse hook | wired; not loaded (#6358); not effective even when loaded (#6357) | N/A — stub | `unicode_posttool.py` | -| **Context suppression** | Sandbox PostToolUse hook | wired; not loaded (#6358); not effective even when loaded (#6357) | N/A — stub | `context_suppress_posttool.py` | -| **Tool allowlist** | Sandbox PreToolUse hook | opt-in; wired; not loaded (#6358) | N/A — stub | `tool_allowlist_pretool.py`; requires `FULLSEND_TOOL_ALLOWLIST` (fail-closed when unset) | -| **Prompt injection (DeBERTa)** | Host Path A + sandbox Path B | ✓ | N/A — stub | Same scanner stack as context files when enabled in harness | -| **Sandbox tool hooks wiring** | `SandboxHooksBootstrap` type assert in `Bootstrap` | ✓ scripts + settings.json installed — but written to `/sandbox/workspace/.claude/`, which the CLI (cwd `/sandbox/workspace/`) does not read (#6358) | ✗ — `Bootstrap` is a stub; must wire `security.HookPlan` via OpenCode plugin hooks | Hook scripts and wiring plan are runtime-neutral (see [Sandbox hook contract](#sandbox-hook-contract)); a runtime that ignores `SandboxHooksBootstrap` installs **no** sandbox tool hooks — say so explicitly here | -| **Transcript / debug artifacts** | `TranscriptHandler` (+ optional `DebugLogNamer`) | ✓ (stream-json, `claude-debug.log`) | No-op — see #1935 | Format-specific; not shared across runtimes. Debug-log filename defaults to `agent-debug.log` unless the runtime implements `DebugLogNamer` | +| Feature | Where it runs | Claude Code | OpenCode (stub) | pi (stub) | Notes for future runtimes | +|---------|---------------|-------------|-----------------|-----------|---------------------------| +| **Host-side context injection scan** (DeBERTa / LLM Guard, unicode, SSRF patterns on repo context files) | Host + sandbox `scan context` | ✓ | N/A — stub | N/A — stub | Requires sandbox image with ML models; harness `security.host_scanners` | +| **Host-side runtime content scan** (agent def, SKILL.md, plugin JSON before upload) | Host (`scanRuntimeContent`) | ✓ | N/A — stub | N/A — stub | Uses `security.InputPipeline()`; not part of `Runtime` interface — runner responsibility | +| **Tirith** (Bash command scanning) | Sandbox PreToolUse hook | wired; **not loaded by Claude Code today** (#6358) | N/A — stub | N/A — stub | `tirith_check.py`; harness `security.sandbox_hooks.tirith`; fails open on missing binary/timeout unless `TIRITH_REQUIRED=1` | +| **SSRF pre-tool** | Sandbox PreToolUse hook | wired; not loaded (#6358) | N/A — stub | N/A — stub | `ssrf_pretool.py`; default on | +| **Canary token detection** | Sandbox Pre/PostToolUse hooks | wired; not loaded (#6358); post-tool field mismatch (#6357) | N/A — stub | N/A — stub | `canary_pretool.py` / `canary_posttool.py`; both inert unless `FULLSEND_CANARY_TOKEN` is set | +| **Secret redaction** | Sandbox PostToolUse hook | wired; not loaded (#6358); not effective even when loaded (#6357) | N/A — stub | N/A — stub | `secret_redact_posttool.py` | +| **Unicode normalization** | Sandbox PostToolUse hook | wired; not loaded (#6358); not effective even when loaded (#6357) | N/A — stub | N/A — stub | `unicode_posttool.py` | +| **Context suppression** | Sandbox PostToolUse hook | wired; not loaded (#6358); not effective even when loaded (#6357) | N/A — stub | N/A — stub | `context_suppress_posttool.py` | +| **Tool allowlist** | Sandbox PreToolUse hook | opt-in; wired; not loaded (#6358) | N/A — stub | N/A — stub | `tool_allowlist_pretool.py`; requires `FULLSEND_TOOL_ALLOWLIST` (fail-closed when unset) | +| **Prompt injection (DeBERTa)** | Host Path A + sandbox Path B | ✓ | N/A — stub | N/A — stub | Same scanner stack as context files when enabled in harness | +| **Sandbox tool hooks wiring** | `SandboxHooksBootstrap` type assert in `Bootstrap` | ✓ scripts + settings.json installed — but written to `/sandbox/workspace/.claude/`, which the CLI (cwd `/sandbox/workspace/`) does not read (#6358) | ✗ — `Bootstrap` is a stub; must wire `security.HookPlan` via OpenCode plugin hooks | N/A — stub | Hook scripts and wiring plan are runtime-neutral (see [Sandbox hook contract](#sandbox-hook-contract)); a runtime that ignores `SandboxHooksBootstrap` installs **no** sandbox tool hooks — say so explicitly here | +| **Transcript / debug artifacts** | `TranscriptHandler` (+ optional `DebugLogNamer`) | ✓ (stream-json, `claude-debug.log`) | No-op — see #1935 | N/A — stub | Format-specific; not shared across runtimes. Debug-log filename defaults to `agent-debug.log` unless the runtime implements `DebugLogNamer` | ### Fail modes @@ -63,15 +64,15 @@ The hook scripts in `internal/security/hooks/*.py` are plain programs with no Cl Harness keys are runtime-neutral in the YAML but each runtime owns their translation. Claude Code passes them through unchanged; other runtimes must document their mapping here (this is also an acceptance criterion in #6319). -| Harness key | Claude Code | OpenCode (stub) | Dummy | Notes for new runtimes | -|-------------|-------------|-----------------|-------|------------------------| -| `model` | `--model` (identity; aliases like `opus` resolved by the CLI) | — | ignored | `validModelName` is `^[a-zA-Z0-9_.@-]+$` — no `/`. Runtimes with `provider/model` ids need an alias table or a follow-up regex change | -| `effort` | `--effort` (`low\|medium\|high\|xhigh\|max`, #6218) | — | ignored | Map to the runtime's reasoning knob or reject with a clear error | -| `plugins` | Claude plugin marketplace layout (`bootstrapPlugins`) | — | ignored | Claude-specific format; warn and skip if unsupported | -| Agent frontmatter `tools:` (`Bash(gh,jq)` syntax, ADR 0027) | Native Claude permission syntax | — | ignored | Enforce via `--tools`/allowlist plus a hook adapter; Claude tool names differ in case from most runtimes (#608) | -| `skills` | `CLAUDE_CONFIG_DIR/skills/` | — | ignored | Agent Skills spec (`SKILL.md`) is portable; destination is `rt.ConfigDir() + "/skills"` (also used by the runtime fetch service) | -| `security.sandbox_hooks` | `SandboxHooksBootstrap` → settings.json | ✗ (stub) | ignored | See [Sandbox hook contract](#sandbox-hook-contract) | -| `--debug` (CLI flag) | `--debug-file`, artifact `claude-debug.log` | — | no-op | Implement `DebugLogNamer` to name the artifact | +| Harness key | Claude Code | OpenCode (stub) | pi (stub) | Dummy | Notes for new runtimes | +|-------------|-------------|-----------------|-----------|-------|------------------------| +| `model` | `--model` (identity; aliases like `opus` resolved by the CLI) | — | — | ignored | `validModelName` is `^[a-zA-Z0-9_.@-]+$` — no `/`. Runtimes with `provider/model` ids need an alias table or a follow-up regex change | +| `effort` | `--effort` (`low\|medium\|high\|xhigh\|max`, #6218) | — | — | ignored | Map to the runtime's reasoning knob or reject with a clear error | +| `plugins` | Claude plugin marketplace layout (`bootstrapPlugins`) | — | — | ignored | Claude-specific format; warn and skip if unsupported | +| Agent frontmatter `tools:` (`Bash(gh,jq)` syntax, ADR 0027) | Native Claude permission syntax | — | — | ignored | Enforce via `--tools`/allowlist plus a hook adapter; Claude tool names differ in case from most runtimes (#608) | +| `skills` | `CLAUDE_CONFIG_DIR/skills/` | — | — | ignored | Agent Skills spec (`SKILL.md`) is portable; destination is `rt.ConfigDir() + "/skills"` (also used by the runtime fetch service) | +| `security.sandbox_hooks` | `SandboxHooksBootstrap` → settings.json | ✗ (stub) | — | ignored | See [Sandbox hook contract](#sandbox-hook-contract) | +| `--debug` (CLI flag) | `--debug-file`, artifact `claude-debug.log` | — | — | no-op | Implement `DebugLogNamer` to name the artifact | ## Sandbox workspace layout diff --git a/images/sandbox/Containerfile b/images/sandbox/Containerfile index ae502e4640..6f5cf40903 100644 --- a/images/sandbox/Containerfile +++ b/images/sandbox/Containerfile @@ -52,6 +52,21 @@ RUN apt-get update \ ARG CLAUDE_CODE_VERSION=2.1.234 RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} +# --------------------------------------------------------------------------- +# Install pi (earendil-works/pi) — pinned version, same supply-chain rationale +# as Claude Code above. --ignore-scripts is the upstream-recommended install +# (the package ships an npm-shrinkwrap.json; no postinstall needed). Stub +# runtime for now — see #6464; the runtime is not user-selectable until +# implemented, but the binary ships so fixture recording and the upcoming +# Bootstrap/Run work run against a pinned, reviewed version. +# +# To update: bump PI_VERSION; renovate tracks this ARG via a customManagers +# 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. +ARG PI_VERSION=0.84.2 +RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent@${PI_VERSION} + # --------------------------------------------------------------------------- # ONNX Runtime + ProtectAI DeBERTa-v3 model for ML-based prompt injection # scanning. The fullsend binary loads the model directly via Go (hugot + diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e364651388..7357c0386d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -408,6 +408,7 @@ func TestValidRuntimes(t *testing.T) { assert.Contains(t, runtimes, "claude") assert.Contains(t, runtimes, "dummy") assert.NotContains(t, runtimes, "opencode", "opencode is resolved via runtime.Resolve() but not user-selectable until implemented") + assert.NotContains(t, runtimes, "pi", "pi is resolved via runtime.Resolve() but not user-selectable until implemented") } func TestOrgConfigValidateRuntime(t *testing.T) { diff --git a/internal/runtime/pi.go b/internal/runtime/pi.go new file mode 100644 index 0000000000..0ad21aace9 --- /dev/null +++ b/internal/runtime/pi.go @@ -0,0 +1,87 @@ +package runtime + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/fullsend-ai/fullsend/internal/sandbox" + "github.com/fullsend-ai/fullsend/internal/ui" +) + +// PiRuntime is a stub implementation of the Runtime and TranscriptHandler +// interfaces for the pi agent runtime (earendil-works/pi, CLI `pi`). All +// methods are no-ops or return not-implemented errors. Subsequent PRs will +// fill in stream parsing (`pi --print --mode json`), bootstrap, run +// execution, and transcript extraction. Tracked in #6464. +type PiRuntime struct{} + +func (PiRuntime) Name() string { return "pi" } + +// System returns the OTEL GenAI gen_ai.system value. Like OpenCode, pi is +// multi-provider (Anthropic, OpenAI, Google, ...), so the system is the +// runtime itself rather than a single model vendor (same precedent as +// OpenCodeRuntime; the per-message `provider` field in pi's event stream may +// allow capturing the actual vendor in a future PR). +func (PiRuntime) System() string { return "pi" } + +// ConfigDir returns the pi config directory inside the sandbox. It is +// exported to the agent process as PI_CODING_AGENT_DIR (see EnvExports) and +// lives outside the agent-writable workspace so the target repo cannot +// rewrite the runtime's settings, extensions, or skills. +func (PiRuntime) ConfigDir() string { return sandbox.SandboxPiConfig } + +func (PiRuntime) WorkspaceDir() string { return sandbox.SandboxWorkspace } + +// EnvExports pins pi's config and session locations to runner-owned paths +// and disables all startup network traffic (update checks, package update +// checks, telemetry). PI_OFFLINE does not affect the inference call itself. +func (PiRuntime) EnvExports() []string { + return []string{ + "export PI_CODING_AGENT_DIR=" + sandbox.SandboxPiConfig, + "export PI_CODING_AGENT_SESSION_DIR=" + sandbox.SandboxPiConfig + "/sessions", + "export PI_OFFLINE=1", + "export PI_SKIP_VERSION_CHECK=1", + } +} + +func (PiRuntime) Bootstrap(_ BootstrapInput) error { + return fmt.Errorf("pi runtime is not yet implemented") +} + +func (PiRuntime) Run(_ context.Context, _ RunParams, _ *ui.Printer, _ time.Time, _ *RunMetrics) (int, error) { + return -1, fmt.Errorf("pi runtime is not yet implemented") +} + +func (PiRuntime) ClearIterationArtifacts(_ string) error { return nil } + +// TranscriptHandler stub methods — return not-implemented errors for extract +// methods (to avoid silent success claims in CI logs) and no-ops for parse +// methods (which correctly indicate "nothing found"). pi writes JSONL session +// files under PI_CODING_AGENT_SESSION_DIR, so extraction will follow the +// Claude find-and-download shape rather than OpenCode's export path (#6464). + +func (PiRuntime) ExtractTranscripts(_, _, _ string) error { + return fmt.Errorf("pi transcript extraction not implemented (see #6464)") +} + +func (PiRuntime) ExtractDebugLog(_, _, _ string) error { + return fmt.Errorf("pi debug log extraction not implemented (see #6464)") +} + +func (PiRuntime) ParseTranscriptErrors(_ string) []TranscriptError { return nil } + +func (PiRuntime) ParseTranscriptFile(_ string) (TranscriptError, bool) { + return TranscriptError{}, false +} + +func (PiRuntime) EmitTranscriptErrors(w io.Writer, summaries []TranscriptError) { + emitTranscriptErrors(w, summaries) +} + +// Compile-time interface assertions. +var ( + _ Runtime = PiRuntime{} + _ TranscriptHandler = PiRuntime{} +) diff --git a/internal/runtime/pi_test.go b/internal/runtime/pi_test.go new file mode 100644 index 0000000000..c61d955ddf --- /dev/null +++ b/internal/runtime/pi_test.go @@ -0,0 +1,72 @@ +package runtime + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/fullsend-ai/fullsend/internal/sandbox" + "github.com/fullsend-ai/fullsend/internal/ui" +) + +func TestPiRuntimeMetadata(t *testing.T) { + t.Parallel() + rt := PiRuntime{} + assert.Equal(t, "pi", rt.Name()) + assert.Equal(t, "pi", rt.System()) + assert.Equal(t, sandbox.SandboxPiConfig, rt.ConfigDir()) + assert.Equal(t, sandbox.SandboxWorkspace, rt.WorkspaceDir()) + // Config dir must be outside the agent-writable workspace tree. + assert.False(t, strings.HasPrefix(rt.ConfigDir(), sandbox.SandboxWorkspace)) +} + +func TestPiRuntimeEnvExports(t *testing.T) { + t.Parallel() + exports := strings.Join(PiRuntime{}.EnvExports(), "\n") + assert.Contains(t, exports, "PI_CODING_AGENT_DIR="+sandbox.SandboxPiConfig) + assert.Contains(t, exports, "PI_CODING_AGENT_SESSION_DIR="+sandbox.SandboxPiConfig+"/sessions") + assert.Contains(t, exports, "PI_OFFLINE=1") + assert.Contains(t, exports, "PI_SKIP_VERSION_CHECK=1") +} + +func TestPiRuntimeRun_NotImplemented(t *testing.T) { + t.Parallel() + exit, err := PiRuntime{}.Run(context.Background(), RunParams{}, ui.New(nil), time.Now(), nil) + assert.Equal(t, -1, exit) + require.ErrorContains(t, err, "not yet implemented") +} + +func TestPiRuntimeBootstrap_NotImplemented(t *testing.T) { + t.Parallel() + require.ErrorContains(t, PiRuntime{}.Bootstrap(bootstrapInput{}), "not yet implemented") +} + +func TestPiRuntimeExtractStubs_NotImplemented(t *testing.T) { + t.Parallel() + rt := PiRuntime{} + require.ErrorContains(t, rt.ExtractTranscripts("sb", "agent", t.TempDir()), "not implemented") + require.ErrorContains(t, rt.ExtractDebugLog("sb", "/tmp/x", "*"), "not implemented") +} + +func TestPiRuntimeNoopMethods(t *testing.T) { + t.Parallel() + rt := PiRuntime{} + assert.NoError(t, rt.ClearIterationArtifacts("sb")) + assert.Nil(t, rt.ParseTranscriptErrors(t.TempDir())) + _, ok := rt.ParseTranscriptFile("/nonexistent") + assert.False(t, ok) + var sb strings.Builder + rt.EmitTranscriptErrors(&sb, nil) +} + +func TestPiRuntimeCapabilities(t *testing.T) { + t.Parallel() + // pi reads AGENTS.md natively — no CLAUDE.md bridge. + assert.False(t, WantsClaudeMDBridge(PiRuntime{})) + // No DebugLogNamer yet — falls back to the runtime-neutral default. + assert.Equal(t, DefaultDebugLogName, DebugLogNameFor(PiRuntime{})) +} diff --git a/internal/runtime/registry.go b/internal/runtime/registry.go index 857b71388c..3302ffe61d 100644 --- a/internal/runtime/registry.go +++ b/internal/runtime/registry.go @@ -20,6 +20,9 @@ func Resolve(name string) (Backend, error) { case "opencode": r := OpenCodeRuntime{} return Backend{Runtime: r, Transcripts: r}, nil + case "pi": + r := PiRuntime{} + return Backend{Runtime: r, Transcripts: r}, nil default: return Backend{}, fmt.Errorf("unknown runtime %q: must be one of %s", name, strings.Join(config.ValidRuntimes(), ", ")) } diff --git a/internal/runtime/registry_test.go b/internal/runtime/registry_test.go index b91b2bc399..21a059273c 100644 --- a/internal/runtime/registry_test.go +++ b/internal/runtime/registry_test.go @@ -26,6 +26,11 @@ func TestResolve(t *testing.T) { _, isOC := oc.Transcripts.(OpenCodeRuntime) assert.True(t, isOC, "Transcripts should be OpenCodeRuntime") + pb, err := Resolve("pi") + require.NoError(t, err) + assert.Equal(t, "pi", pb.Runtime.Name()) + assert.IsType(t, PiRuntime{}, pb.Transcripts) + _, err = Resolve("unknown") require.Error(t, err) } @@ -72,6 +77,14 @@ func TestResolveFromPerRepoConfig(t *testing.T) { require.NoError(t, err) assert.Equal(t, "opencode", ocBackend.Runtime.Name()) + // pi follows the same pattern (#6464): resolvable via Resolve(), not in + // ValidRuntimes() until implemented. + piCfg := config.NewPerRepoConfig(nil, "") + piCfg.SetRuntime("pi") + piBackend, err := ResolveFromPerRepoConfig(piCfg) + require.NoError(t, err) + assert.Equal(t, "pi", piBackend.Runtime.Name()) + invalidCfg := config.NewPerRepoConfig(nil, "") invalidCfg.SetRuntime("invalid") _, err = ResolveFromPerRepoConfig(invalidCfg) diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index c2d6cdf60d..5fbbbdf4a6 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -26,6 +26,10 @@ const ( SandboxWorkspace = "/sandbox/workspace" //nolint:gosec // not a credential // SandboxClaudeConfig is the Claude config directory inside the sandbox. SandboxClaudeConfig = "/sandbox/claude-config" //nolint:gosec // not a credential + // SandboxPiConfig is the pi config directory inside the sandbox + // (PI_CODING_AGENT_DIR). Outside the agent-writable workspace, like + // SandboxClaudeConfig, so the agent cannot rewrite its own runtime config. + SandboxPiConfig = "/sandbox/pi-config" //nolint:gosec // not a credential readyTimeout = 120 * time.Second readyPoll = 2 * time.Second diff --git a/renovate.json b/renovate.json index c9099745ac..5d1fa223eb 100644 --- a/renovate.json +++ b/renovate.json @@ -113,6 +113,16 @@ "depNameTemplate": "@anthropic-ai/claude-code", "datasourceTemplate": "npm" }, + { + "customType": "regex", + "description": "Track pi CLI version pin in the sandbox image (#6464). Bumps must re-verify the pi stream parser fixtures — the --mode json wire shape changed within a minor before (0.84.0).", + "managerFilePatterns": ["/^images/sandbox/Containerfile$/"], + "matchStrings": [ + "PI_VERSION=(?\\d+\\.\\d+\\.\\d+)" + ], + "depNameTemplate": "@earendil-works/pi-coding-agent", + "datasourceTemplate": "npm" + }, { "customType": "regex", "description": "Track tirith CLI version pin in the sandbox image. SHA256 checksums are updated automatically by postUpgradeTasks (scripts/renovate/update-tirith-checksums.sh).", From c40f5284ef5bb0a3355d268b651cd8afd64daea2 Mon Sep 17 00:00:00 2001 From: Wayne Sun Date: Fri, 21 Aug 2026 20:00:35 -0400 Subject: [PATCH 2/3] fix(runtime): harden pi stub per review-squad findings Bake pi's safety env vars (PI_OFFLINE, PI_SKIP_VERSION_CHECK, PI_CODING_AGENT_DIR/SESSION_DIR) into the sandbox image directly, since the binary ships to every sandbox regardless of runtime selection while PiRuntime.EnvExports() only runs once Bootstrap is implemented. Add a build-time Node engine check (pi requires >=22.19.0) so an incompatible base image fails loudly instead of silently. Disable renovate automerge for pi's version pin given its documented history of breaking --mode json wire changes within a minor. Align docs/runtimes.md's pi columns with OpenCode's existing phrasing for stub hook rows. Add missing pi test parity alongside existing opencode cases (config validation rejection, SandboxPiConfig pinning, capability-table coverage) and cite sources for env var/CLI-flag claims in code comments. Assisted-by: Claude (fix), Claude (review), Grok (review) Signed-off-by: Wayne Sun --- docs/guides/dev/cli-internals.md | 1 + docs/runtimes.md | 4 ++-- images/sandbox/Containerfile | 16 +++++++++++++++- internal/config/config_test.go | 16 ++++++++++++---- internal/runtime/capabilities_test.go | 2 ++ internal/runtime/pi.go | 8 ++++++-- internal/sandbox/sandbox_test.go | 1 + renovate.json | 5 +++++ 8 files changed, 44 insertions(+), 9 deletions(-) diff --git a/docs/guides/dev/cli-internals.md b/docs/guides/dev/cli-internals.md index 8cb6959e67..5eb7410d19 100644 --- a/docs/guides/dev/cli-internals.md +++ b/docs/guides/dev/cli-internals.md @@ -518,6 +518,7 @@ Vendoring commit messages use title + body (upload and stale delete). `github st ```go SandboxWorkspace = "/sandbox/workspace" SandboxClaudeConfig = "/sandbox/claude-config" +SandboxPiConfig = "/sandbox/pi-config" ``` For sandbox workspace layout, agent rule layering, and security scanning diff --git a/docs/runtimes.md b/docs/runtimes.md index 2e55799417..d1ad5cd89c 100644 --- a/docs/runtimes.md +++ b/docs/runtimes.md @@ -27,7 +27,7 @@ When adding a runtime, fill in the security matrix below and register it in `run | **Context suppression** | Sandbox PostToolUse hook | wired; not effective under Claude Code (#6357) | N/A — stub | N/A — stub | `context_suppress_posttool.py` | | **Tool allowlist** | Sandbox PreToolUse hook | opt-in; ✓ when enabled | N/A — stub | N/A — stub | `tool_allowlist_pretool.py`; requires `FULLSEND_TOOL_ALLOWLIST` (fail-closed when unset) | | **Prompt injection (DeBERTa)** | Host Path A + sandbox Path B | ✓ | N/A — stub | N/A — stub | Same scanner stack as context files when enabled in harness | -| **Sandbox tool hooks wiring** | `SandboxHooksBootstrap` type assert in `Bootstrap` | ✓ scripts at `claude-config/hooks/`, wiring at `claude-config/hooks.json` via `--settings` (#6358) | ✗ — `Bootstrap` is a stub; must wire `security.HookPlan` via OpenCode plugin hooks | N/A — stub | Hook scripts and wiring plan are runtime-neutral (see [Sandbox hook contract](#sandbox-hook-contract)); a runtime that ignores `SandboxHooksBootstrap` installs **no** sandbox tool hooks — say so explicitly here | +| **Sandbox tool hooks wiring** | `SandboxHooksBootstrap` type assert in `Bootstrap` | ✓ scripts at `claude-config/hooks/`, wiring at `claude-config/hooks.json` via `--settings` (#6358) | ✗ — `Bootstrap` is a stub; must wire `security.HookPlan` via OpenCode plugin hooks | ✗ — `Bootstrap` is a stub; must wire `security.HookPlan` via pi's `tool_call`/`tool_result` extension hooks | Hook scripts and wiring plan are runtime-neutral (see [Sandbox hook contract](#sandbox-hook-contract)); a runtime that ignores `SandboxHooksBootstrap` installs **no** sandbox tool hooks — say so explicitly here | | **Transcript / debug artifacts** | `TranscriptHandler` (+ optional `DebugLogNamer`) | ✓ (stream-json, `claude-debug.log`) | No-op — see #1935 | N/A — stub | Format-specific; not shared across runtimes. Debug-log filename defaults to `agent-debug.log` unless the runtime implements `DebugLogNamer` | ### Fail modes @@ -71,7 +71,7 @@ Harness keys are runtime-neutral in the YAML but each runtime owns their transla | `plugins` | Claude plugin marketplace layout (`bootstrapPlugins`) | — | — | ignored | Claude-specific format; warn and skip if unsupported | | Agent frontmatter `tools:` (`Bash(gh,jq)` syntax, ADR 0027) | Native Claude permission syntax | — | — | ignored | Enforce via `--tools`/allowlist plus a hook adapter; Claude tool names differ in case from most runtimes (#608) | | `skills` | `CLAUDE_CONFIG_DIR/skills/` | — | — | ignored | Agent Skills spec (`SKILL.md`) is portable; destination is `rt.ConfigDir() + "/skills"` (also used by the runtime fetch service) | -| `security.sandbox_hooks` | `SandboxHooksBootstrap` → hooks.json via `--settings` | ✗ (stub) | — | ignored | See [Sandbox hook contract](#sandbox-hook-contract) | +| `security.sandbox_hooks` | `SandboxHooksBootstrap` → hooks.json via `--settings` | ✗ (stub) | ✗ (stub) | ignored | See [Sandbox hook contract](#sandbox-hook-contract) | | `--debug` (CLI flag) | `--debug-file`, artifact `claude-debug.log` | — | — | no-op | Implement `DebugLogNamer` to name the artifact | ## Sandbox workspace layout diff --git a/images/sandbox/Containerfile b/images/sandbox/Containerfile index 6f5cf40903..2eef3a7f96 100644 --- a/images/sandbox/Containerfile +++ b/images/sandbox/Containerfile @@ -2,6 +2,8 @@ # # Pre-installs all runtime dependencies needed by any fullsend agent: # - Claude Code (npm, version-pinned via CLAUDE_CODE_VERSION ARG) +# - pi (npm, version-pinned via PI_VERSION ARG; stub runtime, not yet +# user-selectable — see #6464) # - jq (JSON parsing in agent scripts and skills) # - gitleaks (secret scanner, SHA256-verified) # - acli (Atlassian CLI for Jira issue queries) @@ -65,7 +67,19 @@ RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} # 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. ARG PI_VERSION=0.84.2 -RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent@${PI_VERSION} +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) \ + && npm install -g --ignore-scripts @earendil-works/pi-coding-agent@${PI_VERSION} + +# pi is present in every sandbox regardless of runtime selection (PiRuntime +# is a stub, not yet reachable via config — see internal/runtime/pi.go), so +# an agent invoking the `pi` binary ad-hoc via Bash would otherwise get none +# of PiRuntime.EnvExports()'s safety defaults. Bake them at image level too; +# PI_CODING_AGENT_DIR must match sandbox.SandboxPiConfig (internal/sandbox/sandbox.go). +ENV PI_CODING_AGENT_DIR="/sandbox/pi-config" \ + PI_CODING_AGENT_SESSION_DIR="/sandbox/pi-config/sessions" \ + PI_OFFLINE="1" \ + PI_SKIP_VERSION_CHECK="1" # --------------------------------------------------------------------------- # ONNX Runtime + ProtectAI DeBERTa-v3 model for ML-based prompt injection diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7357c0386d..305040029a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -422,11 +422,14 @@ func TestOrgConfigValidateRuntime(t *testing.T) { } require.NoError(t, cfg.Validate()) - // opencode is resolvable via runtime.Resolve() but not in ValidRuntimes(), - // so config validation must reject it until the runtime is implemented. + // opencode/pi are resolvable via runtime.Resolve() but not in ValidRuntimes(), + // so config validation must reject them until the runtime is implemented. cfg.Defaults.Runtime = "opencode" require.Error(t, cfg.Validate()) + cfg.Defaults.Runtime = "pi" + require.Error(t, cfg.Validate()) + cfg.Defaults.Runtime = "invalid" require.Error(t, cfg.Validate()) } @@ -676,13 +679,18 @@ func TestPerRepoConfigValidate_Runtime(t *testing.T) { } assert.NoError(t, cfg.Validate()) - // opencode is resolvable via runtime.Resolve() but not in ValidRuntimes(), - // so config validation must reject it until the runtime is implemented. + // opencode/pi are resolvable via runtime.Resolve() but not in ValidRuntimes(), + // so config validation must reject them until the runtime is implemented. cfg.Runtime = "opencode" err := cfg.Validate() assert.Error(t, err) assert.Contains(t, err.Error(), "invalid runtime") + cfg.Runtime = "pi" + err = cfg.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid runtime") + cfg.Runtime = "invalid" err = cfg.Validate() assert.Error(t, err) diff --git a/internal/runtime/capabilities_test.go b/internal/runtime/capabilities_test.go index 103cdb41f8..451bcf8b2f 100644 --- a/internal/runtime/capabilities_test.go +++ b/internal/runtime/capabilities_test.go @@ -17,6 +17,7 @@ func TestDebugLogNameFor(t *testing.T) { t.Parallel() assert.Equal(t, "claude-debug.log", DebugLogNameFor(ClaudeRuntime{})) assert.Equal(t, DefaultDebugLogName, DebugLogNameFor(OpenCodeRuntime{})) + assert.Equal(t, DefaultDebugLogName, DebugLogNameFor(PiRuntime{})) assert.Equal(t, DefaultDebugLogName, DebugLogNameFor(DummyRuntime{})) assert.Equal(t, "other.log", DebugLogNameFor(namedDebugLog{name: "other.log"})) // An empty name falls back to the default rather than producing "". @@ -37,5 +38,6 @@ func TestWantsClaudeMDBridge(t *testing.T) { t.Parallel() assert.True(t, WantsClaudeMDBridge(ClaudeRuntime{})) assert.False(t, WantsClaudeMDBridge(OpenCodeRuntime{})) + assert.False(t, WantsClaudeMDBridge(PiRuntime{})) assert.False(t, WantsClaudeMDBridge(DummyRuntime{})) } diff --git a/internal/runtime/pi.go b/internal/runtime/pi.go index 0ad21aace9..1f8f2e280e 100644 --- a/internal/runtime/pi.go +++ b/internal/runtime/pi.go @@ -13,8 +13,9 @@ import ( // PiRuntime is a stub implementation of the Runtime and TranscriptHandler // interfaces for the pi agent runtime (earendil-works/pi, CLI `pi`). All // methods are no-ops or return not-implemented errors. Subsequent PRs will -// fill in stream parsing (`pi --print --mode json`), bootstrap, run -// execution, and transcript extraction. Tracked in #6464. +// fill in stream parsing (`pi --mode json`, per docs/json.md — confirm at +// implementation time whether `--print` is required alongside it), bootstrap, +// run execution, and transcript extraction. Tracked in #6464. type PiRuntime struct{} func (PiRuntime) Name() string { return "pi" } @@ -37,6 +38,9 @@ func (PiRuntime) WorkspaceDir() string { return sandbox.SandboxWorkspace } // EnvExports pins pi's config and session locations to runner-owned paths // and disables all startup network traffic (update checks, package update // checks, telemetry). PI_OFFLINE does not affect the inference call itself. +// Var names/semantics per earendil-works/pi docs/environment-variables.md +// (PI_CODING_AGENT_DIR, PI_CODING_AGENT_SESSION_DIR, PI_OFFLINE, +// PI_SKIP_VERSION_CHECK) — re-verify against that doc when PI_VERSION moves. func (PiRuntime) EnvExports() []string { return []string{ "export PI_CODING_AGENT_DIR=" + sandbox.SandboxPiConfig, diff --git a/internal/sandbox/sandbox_test.go b/internal/sandbox/sandbox_test.go index d3a467b8e8..01c657c631 100644 --- a/internal/sandbox/sandbox_test.go +++ b/internal/sandbox/sandbox_test.go @@ -32,6 +32,7 @@ func TestEnsureAvailable_OpenshellNotInPath(t *testing.T) { func TestConstants(t *testing.T) { assert.Equal(t, "/sandbox/workspace", SandboxWorkspace) assert.Equal(t, "/sandbox/claude-config", SandboxClaudeConfig) + assert.Equal(t, "/sandbox/pi-config", SandboxPiConfig) } func TestBuildProviderArgs_BareKeyCredentials(t *testing.T) { diff --git a/renovate.json b/renovate.json index 5d1fa223eb..1f756f6036 100644 --- a/renovate.json +++ b/renovate.json @@ -59,6 +59,11 @@ "fileFilters": ["images/sandbox/Containerfile"], "executionMode": "branch" } + }, + { + "description": "pi releases ~weekly minors and has changed its --mode json wire shape within a minor before (0.84.0); require manual review so the stream parser fixtures (#6464) are re-verified before a pin bump lands, even for patch/pin updates", + "matchPackageNames": ["@earendil-works/pi-coding-agent"], + "automerge": false } ], "postUpdateOptions": ["gomodTidy"], From 6d3e6fe66d1c505511775f3236866ce4a55c52dd Mon Sep 17 00:00:00 2001 From: Wayne Sun Date: Fri, 21 Aug 2026 20:22:38 -0400 Subject: [PATCH 3/3] fix(runtime): address fullsend-ai-review LOW findings on pi stub Derive PiRuntime.EnvExports() paths from ConfigDir() via fmt.Sprintf (matching ClaudeRuntime's pattern) instead of a second raw reference to sandbox.SandboxPiConfig, so a future path change only needs one update. Pass nil (not ui.New(nil)) for the *ui.Printer param in TestPiRuntimeRun_NotImplemented, matching OpenCode's equivalent test. Assisted-by: Claude (fix), Claude (review) Signed-off-by: Wayne Sun --- internal/runtime/pi.go | 6 +++--- internal/runtime/pi_test.go | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/internal/runtime/pi.go b/internal/runtime/pi.go index 1f8f2e280e..2b3693b911 100644 --- a/internal/runtime/pi.go +++ b/internal/runtime/pi.go @@ -41,10 +41,10 @@ func (PiRuntime) WorkspaceDir() string { return sandbox.SandboxWorkspace } // Var names/semantics per earendil-works/pi docs/environment-variables.md // (PI_CODING_AGENT_DIR, PI_CODING_AGENT_SESSION_DIR, PI_OFFLINE, // PI_SKIP_VERSION_CHECK) — re-verify against that doc when PI_VERSION moves. -func (PiRuntime) EnvExports() []string { +func (r PiRuntime) EnvExports() []string { return []string{ - "export PI_CODING_AGENT_DIR=" + sandbox.SandboxPiConfig, - "export PI_CODING_AGENT_SESSION_DIR=" + sandbox.SandboxPiConfig + "/sessions", + fmt.Sprintf("export PI_CODING_AGENT_DIR=%s", r.ConfigDir()), + fmt.Sprintf("export PI_CODING_AGENT_SESSION_DIR=%s/sessions", r.ConfigDir()), "export PI_OFFLINE=1", "export PI_SKIP_VERSION_CHECK=1", } diff --git a/internal/runtime/pi_test.go b/internal/runtime/pi_test.go index c61d955ddf..0e404e96d0 100644 --- a/internal/runtime/pi_test.go +++ b/internal/runtime/pi_test.go @@ -10,7 +10,6 @@ import ( "github.com/stretchr/testify/require" "github.com/fullsend-ai/fullsend/internal/sandbox" - "github.com/fullsend-ai/fullsend/internal/ui" ) func TestPiRuntimeMetadata(t *testing.T) { @@ -35,7 +34,7 @@ func TestPiRuntimeEnvExports(t *testing.T) { func TestPiRuntimeRun_NotImplemented(t *testing.T) { t.Parallel() - exit, err := PiRuntime{}.Run(context.Background(), RunParams{}, ui.New(nil), time.Now(), nil) + exit, err := PiRuntime{}.Run(context.Background(), RunParams{}, nil, time.Now(), nil) assert.Equal(t, -1, exit) require.ErrorContains(t, err, "not yet implemented") }