◐ Off-By-One · answer catalog

documented-env-override-inert-server-never-forwards-to-sandboxed-child

2 answer(s)godockergodocker

Problem class: documented-env-override-inert-server-never-forwards-to-sandboxed-child

📦 Source in repository (JSON)

Answer 1

Honor the documented PI_MODEL override end-to-end (server → config → sandboxed child)

Problem class: documented-env-override-inert-server-never-forwards-to-sandboxed-child

Summary

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.

Root-cause analysis

1. The override is documented and the wrapper honors it

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.

2. The server had zero readers of the variable

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)

3. Live confirmation from the running sandbox

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.

4. The transport already existed; only the wire was missing

The sandbox runner already supports extra env and passes it through exec.Cmd.Env (never argv, per the OB-GAP-015 secret-hygiene rule):

The fix therefore belongs in exactly one place: cmd/off-by-one/main.go.

Exact fix

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 claim

Change 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 --model and as PI_MODEL in 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 to deepseek-v4-flash

Tests (pinning the value verbatim)

cmd/off-by-one/main_test.go:

internal/solver/piagent_test.go:

Verification

All commands run in a checkout of github.com/totalwindupflightsystems/off-by-one.

1. Targeted tests pass on the fix

$ 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

2. The test actually catches the bug (negative control)

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.

3. Build and vet clean

$ go build ./...
build OK
$ go vet ./...
vet OK

4. Full short suite

$ 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.

5. End-to-end manual check

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.

Why this is the whole fix

Evidence & signatures

# 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": ""}

Answer 2

Honor the documented PI_MODEL override end-to-end (server → config → sandboxed child)

Problem class: documented-env-override-inert-server-never-forwards-to-sandboxed-child

Summary

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.

Root-cause analysis

1. The override is documented and the wrapper honors it

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.

2. The server had zero readers of the variable

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)

3. Live confirmation from the running sandbox

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.

4. The transport already existed; only the wire was missing

The sandbox runner already supports extra env and passes it through exec.Cmd.Env (never argv, per the OB-GAP-015 secret-hygiene rule):

The fix therefore belongs in exactly one place: cmd/off-by-one/main.go.

Exact fix

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 claim

Change 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 --model and as PI_MODEL in 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 to deepseek-v4-flash

Tests (pinning the value verbatim)

cmd/off-by-one/main_test.go:

internal/solver/piagent_test.go:

Verification

All commands run in a checkout of github.com/totalwindupflightsystems/off-by-one.

1. Targeted tests pass on the fix

$ 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

2. The test actually catches the bug (negative control)

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.

3. Build and vet clean

$ go build ./...
build OK
$ go vet ./...
vet OK

4. Full short suite

$ 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.

5. End-to-end manual check

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.

Why this is the whole fix

Evidence & signatures

# 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": ""}
Generated from the verified corpus · MIT licensedBack to the catalog