Problem class: documented-env-override-inert-server-never-forwards-to-sandboxed-child
PI_MODEL override end-to-end (server → config → sandboxed child)Problem class: documented-env-override-inert-server-never-forwards-to-sandboxed-child
README.md documents PI_MODEL as a verbatim model override for solves, and the shipped wrapper (scripts/pi-agent, symlinked as ~/.local/bin/pi-agent) does honor it: resolveModel() returns process.env.PI_MODEL.trim() before consulting --model or the MODEL_MAP. But the server that builds the solve command, cmd/off-by-one/main.go, hardcoded solver.DefaultModel into solver.Config and never populated Config.ExtraEnv. So the operator's PI_MODEL was read by nobody on the server side and was not carried into the sandboxed child. The override was inert end-to-end.
The fix is a single wiring point: read PI_MODEL once (env → config) and use it both as Config.Model (the --model arg) and as an ExtraEnv entry (PI_MODEL=<id>) so it reaches the child's envp verbatim. The transport already existed; only the source→config wire was missing.
README.md (solver-chain env table):
PI_MODEL| No | Model override passed to pi verbatim, e.g.anthropic/claude-sonnet-4-20250514
scripts/pi-agent (the wrapper, and the pitfall):
function resolveModel(m) {
// $PI_MODEL wins verbatim (caller's override).
if (process.env.PI_MODEL && process.env.PI_MODEL.trim() !== '') {
return process.env.PI_MODEL.trim();
}
...
}
A grep hit here is not evidence the server wires it — the wrapper can only honor a variable that is actually present in its environment.
Pre-fix state (commit 0f1b58b^):
$ git grep -n "PI_MODEL" 0f1b58b^ -- '*.go' ':!*_test.go'
$ echo $?
1 # no non-test Go reader anywhere
cmd/off-by-one/main.go built the solver config by hand, hardcoding the default model and omitting any forwarded environment:
solverExec = solver.NewExecutor(solver.Config{
PiAgentPath: *piAgentPath,
Model: solver.DefaultModel, // <-- documented PI_MODEL ignored
APIKey: apiKey,
Timeout: *solveTimeout,
// ExtraEnv never set // <-- nothing forwarded into the child
}, runner, store)
The deployed service (/etc/systemd/system/off-by-one.service) runs pi-agent solve … --model deepseek-v4-flash and the child environment has no PI_MODEL:
$ tr '\0' '\n' < /proc/1/environ | grep -c '^PI_MODEL='
0
$ tr '\0' ' ' < /proc/1/cmdline | grep -o -- '--model [^ ]*'
--model deepseek-v4-flash
So even though pi-agent would prefer PI_MODEL, it is never present — the default wins.
The sandbox runner already supports extra env and passes it through exec.Cmd.Env (never argv, per the OB-GAP-015 secret-hygiene rule):
internal/solver/piagent.go — env := append([]string{}, e.cfg.ExtraEnv...); … handle.Exec(ctx, path, args, env)internal/solver/bsandbox_runner.go — h.sandbox.RunWithEnv(ctx, name, args, env)internal/sandbox/bwrap.go — cmd.Env = append(os.Environ(), s.cfg.ExtraEnv...); cmd.Env = append(cmd.Env, env...)The fix therefore belongs in exactly one place: cmd/off-by-one/main.go.
Reference commit: 0f1b58b — "fix: honor PI_MODEL end-to-end in the solve path".
cmd/off-by-one/main.go — replace the hardcoded construction// was (buggy):
// solverExec = solver.NewExecutor(solver.Config{
// PiAgentPath: *piAgentPath,
// Model: solver.DefaultModel,
// APIKey: apiKey,
// Timeout: *solveTimeout,
// }, runner, store)
cfg := solverConfigFor(os.Getenv, *piAgentPath, apiKey, *solveTimeout)
solverExec = solver.NewExecutor(cfg, runner, store)
log.Printf("solver ready: pi-agent=%s bwrap=%s model=%s", *piAgentPath, *bwrapPath, cfg.Model)
Add the single resolution function plus its two helpers (env → config → per-call env):
// resolveSolverModel returns the model the solver asks Pi Agent for.
// PI_MODEL, when set to a non-blank value, wins verbatim — the same rule
// the shipped wrapper applies to its own environment
// (scripts/pi-agent:resolveModel). Otherwise the built-in default is used.
// An empty or whitespace-only PI_MODEL is treated as unset.
func resolveSolverModel(getenv func(string) string) string {
if m := strings.TrimSpace(getenv("PI_MODEL")); m != "" {
return m
}
return solver.DefaultModel
}
// solverExtraEnv returns the extra environment entries for a solve.
// PI_MODEL is forwarded only when set, so the wrapper's own resolveModel
// sees the same id verbatim. When unset, the entry is omitted and the
// wrapper maps the --model id itself (bare deepseek-v4-flash → the
// provider-qualified lane that matches the available key).
func solverExtraEnv(getenv func(string) string) []string {
if m := strings.TrimSpace(getenv("PI_MODEL")); m != "" {
return []string{"PI_MODEL=" + m}
}
return nil
}
// solverConfigFor is the single place main.go derives the solve model.
func solverConfigFor(getenv func(string) string, piAgentPath, apiKey string, timeout time.Duration) solver.Config {
return solver.Config{
PiAgentPath: piAgentPath,
Model: resolveSolverModel(getenv),
APIKey: apiKey,
Timeout: timeout,
ExtraEnv: solverExtraEnv(getenv),
}
}
README.md — correct the claimChange the PI_MODEL row from "passed to pi verbatim" to state that the server reads it at startup and passes it both as --model and as PI_MODEL in the sandbox environment, and that unset/blank falls back to deepseek-v4-flash:
PI_MODEL| No | Model override, read by the server at startup and passed to the wrapper both as--modeland asPI_MODELin the sandbox environment, so pi receives it verbatim — use a provider-qualified id, e.g.anthropic/claude-sonnet-4-20250514. Unset or blank falls back todeepseek-v4-flash
cmd/off-by-one/main_test.go:
TestResolveSolverModel — PI_MODEL set/padded/empty/blank precedence.TestSolverExtraEnv_ForwardsPI_MODELOnly — one PI_MODEL=<id> entry when set; nil when unset/blank.TestSolverModelFromProcessEnv — wires the real os.Getenv and asserts cfg.Model == override and cfg.ExtraEnv == ["PI_MODEL="+override], with other config fields preserved.internal/solver/piagent_test.go:
TestExecutor_Solve_PropagatesExtraEnv — Config.ExtraEnv reaches the runner's Exec env verbatim, --model carries the same id, and DEEPSEEK_API_KEY is still present.TestExecutor_Solve_NoPIModelEnvWhenUnset — no PI_MODEL leaks into the child env when there is no operator override.All commands run in a checkout of github.com/totalwindupflightsystems/off-by-one.
$ go test ./cmd/off-by-one/ ./internal/solver/ \
-run 'TestResolveSolverModel|TestSolverExtraEnv_ForwardsPI_MODELOnly|TestSolverModelFromProcessEnv|TestExecutor_Solve_PropagatesExtraEnv|TestExecutor_Solve_NoPIModelEnvWhenUnset' \
-count=1 -v
--- PASS: TestResolveSolverModel (0.00s)
--- PASS: TestResolveSolverModel/unset
--- PASS: TestResolveSolverModel/set
--- PASS: TestResolveSolverModel/padded
--- PASS: TestResolveSolverModel/empty
--- PASS: TestResolveSolverModel/whitespace_only
--- PASS: TestSolverExtraEnv_ForwardsPI_MODELOnly (0.00s)
--- PASS: TestSolverModelFromProcessEnv (0.00s)
--- PASS: TestSolverModelFromProcessEnv/set
--- PASS: TestSolverModelFromProcessEnv/unset
--- PASS: TestExecutor_Solve_PropagatesExtraEnv (0.00s)
--- PASS: TestExecutor_Solve_NoPIModelEnvWhenUnset (0.00s)
ok github.com/totalwindupflightsystems/off-by-one/cmd/off-by-one 0.004s
ok github.com/totalwindupflightsystems/off-by-one/internal/solver 0.008s
Restoring the old behavior (Model: solver.DefaultModel, no ExtraEnv) makes the pinning test fail with exactly the reported symptoms:
$ go test ./cmd/off-by-one/ -run 'TestSolverModelFromProcessEnv' -count=1 -v
=== RUN TestSolverModelFromProcessEnv/set
main_test.go:263: Config.Model = "deepseek-v4-flash", want "anthropic/claude-sonnet-4-20250514"
main_test.go:267: Config.ExtraEnv = [], want [PI_MODEL=anthropic/claude-sonnet-4-20250514]
--- FAIL: TestSolverModelFromProcessEnv (0.00s)
FAIL github.com/totalwindupflightsystems/off-by-one/cmd/off-by-one
Restoring the fix turns it green again.
$ go build ./...
build OK
$ go vet ./...
vet OK
$ go test ./... -short -p 1 -count=1 -timeout 120s
ok .../cmd/off-by-one 0.018s
ok .../internal/api 0.346s
ok .../internal/cron 0.078s
ok .../internal/export 0.728s
ok .../internal/graph 0.089s
ok .../internal/import 1.116s
ok .../internal/ingest 0.058s
ok .../internal/muster 0.033s
FAIL .../internal/sandbox (3 bwrap tests)
ok .../internal/seed 0.026s
FAIL .../internal/solver (TestBSandboxRunner_RoundTrip)
ok .../internal/web 1.716s
ok .../pkg/api 0.012s
The only failures are bwrap execution tests that require unprivileged user namespaces, which this container forbids:
bwrap: No permissions to create a new namespace, likely because the kernel does not allow
non-privileged user namespaces.
They are environmental and unrelated to the PI_MODEL change; every PI_MODEL test passes.
With the server launched under a PI_MODEL value, the child wrapper now sees the override:
$ PI_MODEL='anthropic/claude-sonnet-4-20250514' ~/off-by-one/off-by-one ...
$ tr '\0' '\n' < /proc/<bwrap-pid>/environ | grep '^PI_MODEL='
PI_MODEL=anthropic/claude-sonnet-4-20250514
and the wrapper's resolveModel() returns that id verbatim instead of falling back to deepseek-v4-flash.
solverConfigFor is the only place the solve model is derived; there is no second hardcoded default left in main.go.--model and ExtraEnv, so the wrapper's resolveModel and the server can never disagree.PI_MODEL is treated as unset on both sides, so a blank export never selects a broken lane and never suppresses the wrapper's key-based mapping.exec.Cmd.Env (envp), never argv, so DEEPSEEK_API_KEY-style values stay out of ps listings (OB-GAP-015).# Evidence - Problem class: documented-env-override-inert-server-never-forwards-to-sandboxed-child - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-15T11:32:19.227Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Go service documents an env override (e.g. PI_MODEL) that the child solver/wrapper honors, but the server hardcodes its default and never forwards the variable into the sandboxed child environment, so the documented override is silently inert end-to-end. Diagnose by grepping the binary/CLI code for the documented variable name (zero readers = inert), then wire it in one place (env -> config -> per-call env) and pin it with a test asserting the value reaches the child env verbatim. Pitfall: the wrapper may already implement the override, so a grep hit in the wrapper script is not evidence the server wires it.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "documented-env-override-inert-server-never-forwards-to-sandboxed-child", "provider": "openrouter", "solved_at": "2026-09-15T11:32:19.227Z", "version": ""}PI_MODEL override end-to-end (server → config → sandboxed child)Problem class: documented-env-override-inert-server-never-forwards-to-sandboxed-child
README.md documents PI_MODEL as a verbatim model override for solves, and the shipped wrapper (scripts/pi-agent, symlinked as ~/.local/bin/pi-agent) does honor it: resolveModel() returns process.env.PI_MODEL.trim() before consulting --model or the MODEL_MAP. But the server that builds the solve command, cmd/off-by-one/main.go, hardcoded solver.DefaultModel into solver.Config and never populated Config.ExtraEnv. So the operator's PI_MODEL was read by nobody on the server side and was not carried into the sandboxed child. The override was inert end-to-end.
The fix is a single wiring point: read PI_MODEL once (env → config) and use it both as Config.Model (the --model arg) and as an ExtraEnv entry (PI_MODEL=<id>) so it reaches the child's envp verbatim. The transport already existed; only the source→config wire was missing.
README.md (solver-chain env table):
PI_MODEL| No | Model override passed to pi verbatim, e.g.anthropic/claude-sonnet-4-20250514
scripts/pi-agent (the wrapper, and the pitfall):
function resolveModel(m) {
// $PI_MODEL wins verbatim (caller's override).
if (process.env.PI_MODEL && process.env.PI_MODEL.trim() !== '') {
return process.env.PI_MODEL.trim();
}
...
}
A grep hit here is not evidence the server wires it — the wrapper can only honor a variable that is actually present in its environment.
Pre-fix state (commit 0f1b58b^):
$ git grep -n "PI_MODEL" 0f1b58b^ -- '*.go' ':!*_test.go'
$ echo $?
1 # no non-test Go reader anywhere
cmd/off-by-one/main.go built the solver config by hand, hardcoding the default model and omitting any forwarded environment:
solverExec = solver.NewExecutor(solver.Config{
PiAgentPath: *piAgentPath,
Model: solver.DefaultModel, // <-- documented PI_MODEL ignored
APIKey: apiKey,
Timeout: *solveTimeout,
// ExtraEnv never set // <-- nothing forwarded into the child
}, runner, store)
The deployed service (/etc/systemd/system/off-by-one.service) runs pi-agent solve … --model deepseek-v4-flash and the child environment has no PI_MODEL:
$ tr '\0' '\n' < /proc/1/environ | grep -c '^PI_MODEL='
0
$ tr '\0' ' ' < /proc/1/cmdline | grep -o -- '--model [^ ]*'
--model deepseek-v4-flash
So even though pi-agent would prefer PI_MODEL, it is never present — the default wins.
The sandbox runner already supports extra env and passes it through exec.Cmd.Env (never argv, per the OB-GAP-015 secret-hygiene rule):
internal/solver/piagent.go — env := append([]string{}, e.cfg.ExtraEnv...); … handle.Exec(ctx, path, args, env)internal/solver/bsandbox_runner.go — h.sandbox.RunWithEnv(ctx, name, args, env)internal/sandbox/bwrap.go — cmd.Env = append(os.Environ(), s.cfg.ExtraEnv...); cmd.Env = append(cmd.Env, env...)The fix therefore belongs in exactly one place: cmd/off-by-one/main.go.
Reference commit: 0f1b58b — "fix: honor PI_MODEL end-to-end in the solve path".
cmd/off-by-one/main.go — replace the hardcoded construction// was (buggy):
// solverExec = solver.NewExecutor(solver.Config{
// PiAgentPath: *piAgentPath,
// Model: solver.DefaultModel,
// APIKey: apiKey,
// Timeout: *solveTimeout,
// }, runner, store)
cfg := solverConfigFor(os.Getenv, *piAgentPath, apiKey, *solveTimeout)
solverExec = solver.NewExecutor(cfg, runner, store)
log.Printf("solver ready: pi-agent=%s bwrap=%s model=%s", *piAgentPath, *bwrapPath, cfg.Model)
Add the single resolution function plus its two helpers (env → config → per-call env):
// resolveSolverModel returns the model the solver asks Pi Agent for.
// PI_MODEL, when set to a non-blank value, wins verbatim — the same rule
// the shipped wrapper applies to its own environment
// (scripts/pi-agent:resolveModel). Otherwise the built-in default is used.
// An empty or whitespace-only PI_MODEL is treated as unset.
func resolveSolverModel(getenv func(string) string) string {
if m := strings.TrimSpace(getenv("PI_MODEL")); m != "" {
return m
}
return solver.DefaultModel
}
// solverExtraEnv returns the extra environment entries for a solve.
// PI_MODEL is forwarded only when set, so the wrapper's own resolveModel
// sees the same id verbatim. When unset, the entry is omitted and the
// wrapper maps the --model id itself (bare deepseek-v4-flash → the
// provider-qualified lane that matches the available key).
func solverExtraEnv(getenv func(string) string) []string {
if m := strings.TrimSpace(getenv("PI_MODEL")); m != "" {
return []string{"PI_MODEL=" + m}
}
return nil
}
// solverConfigFor is the single place main.go derives the solve model.
func solverConfigFor(getenv func(string) string, piAgentPath, apiKey string, timeout time.Duration) solver.Config {
return solver.Config{
PiAgentPath: piAgentPath,
Model: resolveSolverModel(getenv),
APIKey: apiKey,
Timeout: timeout,
ExtraEnv: solverExtraEnv(getenv),
}
}
README.md — correct the claimChange the PI_MODEL row from "passed to pi verbatim" to state that the server reads it at startup and passes it both as --model and as PI_MODEL in the sandbox environment, and that unset/blank falls back to deepseek-v4-flash:
PI_MODEL| No | Model override, read by the server at startup and passed to the wrapper both as--modeland asPI_MODELin the sandbox environment, so pi receives it verbatim — use a provider-qualified id, e.g.anthropic/claude-sonnet-4-20250514. Unset or blank falls back todeepseek-v4-flash
cmd/off-by-one/main_test.go:
TestResolveSolverModel — PI_MODEL set/padded/empty/blank precedence.TestSolverExtraEnv_ForwardsPI_MODELOnly — one PI_MODEL=<id> entry when set; nil when unset/blank.TestSolverModelFromProcessEnv — wires the real os.Getenv and asserts cfg.Model == override and cfg.ExtraEnv == ["PI_MODEL="+override], with other config fields preserved.internal/solver/piagent_test.go:
TestExecutor_Solve_PropagatesExtraEnv — Config.ExtraEnv reaches the runner's Exec env verbatim, --model carries the same id, and DEEPSEEK_API_KEY is still present.TestExecutor_Solve_NoPIModelEnvWhenUnset — no PI_MODEL leaks into the child env when there is no operator override.All commands run in a checkout of github.com/totalwindupflightsystems/off-by-one.
$ go test ./cmd/off-by-one/ ./internal/solver/ \
-run 'TestResolveSolverModel|TestSolverExtraEnv_ForwardsPI_MODELOnly|TestSolverModelFromProcessEnv|TestExecutor_Solve_PropagatesExtraEnv|TestExecutor_Solve_NoPIModelEnvWhenUnset' \
-count=1 -v
--- PASS: TestResolveSolverModel (0.00s)
--- PASS: TestResolveSolverModel/unset
--- PASS: TestResolveSolverModel/set
--- PASS: TestResolveSolverModel/padded
--- PASS: TestResolveSolverModel/empty
--- PASS: TestResolveSolverModel/whitespace_only
--- PASS: TestSolverExtraEnv_ForwardsPI_MODELOnly (0.00s)
--- PASS: TestSolverModelFromProcessEnv (0.00s)
--- PASS: TestSolverModelFromProcessEnv/set
--- PASS: TestSolverModelFromProcessEnv/unset
--- PASS: TestExecutor_Solve_PropagatesExtraEnv (0.00s)
--- PASS: TestExecutor_Solve_NoPIModelEnvWhenUnset (0.00s)
ok github.com/totalwindupflightsystems/off-by-one/cmd/off-by-one 0.004s
ok github.com/totalwindupflightsystems/off-by-one/internal/solver 0.008s
Restoring the old behavior (Model: solver.DefaultModel, no ExtraEnv) makes the pinning test fail with exactly the reported symptoms:
$ go test ./cmd/off-by-one/ -run 'TestSolverModelFromProcessEnv' -count=1 -v
=== RUN TestSolverModelFromProcessEnv/set
main_test.go:263: Config.Model = "deepseek-v4-flash", want "anthropic/claude-sonnet-4-20250514"
main_test.go:267: Config.ExtraEnv = [], want [PI_MODEL=anthropic/claude-sonnet-4-20250514]
--- FAIL: TestSolverModelFromProcessEnv (0.00s)
FAIL github.com/totalwindupflightsystems/off-by-one/cmd/off-by-one
Restoring the fix turns it green again.
$ go build ./...
build OK
$ go vet ./...
vet OK
$ go test ./... -short -p 1 -count=1 -timeout 120s
ok .../cmd/off-by-one 0.018s
ok .../internal/api 0.346s
ok .../internal/cron 0.078s
ok .../internal/export 0.728s
ok .../internal/graph 0.089s
ok .../internal/import 1.116s
ok .../internal/ingest 0.058s
ok .../internal/muster 0.033s
FAIL .../internal/sandbox (3 bwrap tests)
ok .../internal/seed 0.026s
FAIL .../internal/solver (TestBSandboxRunner_RoundTrip)
ok .../internal/web 1.716s
ok .../pkg/api 0.012s
The only failures are bwrap execution tests that require unprivileged user namespaces, which this container forbids:
bwrap: No permissions to create a new namespace, likely because the kernel does not allow
non-privileged user namespaces.
They are environmental and unrelated to the PI_MODEL change; every PI_MODEL test passes.
With the server launched under a PI_MODEL value, the child wrapper now sees the override:
$ PI_MODEL='anthropic/claude-sonnet-4-20250514' ~/off-by-one/off-by-one ...
$ tr '\0' '\n' < /proc/<bwrap-pid>/environ | grep '^PI_MODEL='
PI_MODEL=anthropic/claude-sonnet-4-20250514
and the wrapper's resolveModel() returns that id verbatim instead of falling back to deepseek-v4-flash.
solverConfigFor is the only place the solve model is derived; there is no second hardcoded default left in main.go.--model and ExtraEnv, so the wrapper's resolveModel and the server can never disagree.PI_MODEL is treated as unset on both sides, so a blank export never selects a broken lane and never suppresses the wrapper's key-based mapping.exec.Cmd.Env (envp), never argv, so DEEPSEEK_API_KEY-style values stay out of ps listings (OB-GAP-015).# Evidence - Problem class: documented-env-override-inert-server-never-forwards-to-sandboxed-child - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-15T11:32:19.227Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Go service documents an env override (e.g. PI_MODEL) that the child solver/wrapper honors, but the server hardcodes its default and never forwards the variable into the sandboxed child environment, so the documented override is silently inert end-to-end. Diagnose by grepping the binary/CLI code for the documented variable name (zero readers = inert), then wire it in one place (env -> config -> per-call env) and pin it with a test asserting the value reaches the child env verbatim. Pitfall: the wrapper may already implement the override, so a grep hit in the wrapper script is not evidence the server wires it.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "documented-env-override-inert-server-never-forwards-to-sandboxed-child", "provider": "openrouter", "solved_at": "2026-09-15T11:32:19.227Z", "version": ""}