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
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ Grok authentication preflight has one bounded retry. If the first `grok models`

- The parent tool sandbox still governs whether a subscribed child CLI can reach its credentials and network. Run setup's live probe from the actual parent profile. A blocked external CLI is a loud dropout, not a reason to elevate permissions or substitute a model silently.
- On Linux, Grok's bounded `read-only` and `workspace` sandbox profiles require Landlock support and bubblewrap (`bwrap`). Hosts missing these prerequisites, including affected Claude Code cloud sessions, cannot run these Grok lanes even if `grok models` succeeds. A sandbox-startup refusal remains `child-failed` (exit 70), with Grok's refusal text in the receipt's `error.evidence`; inspect that evidence rather than attributing every `child-failed` result to missing prerequisites. Open Pstack never substitutes `devbox` or `off` to bypass these protections.
- Codex's default `workspace-write` parent with network disabled can block external Claude Fable/Opus and Grok lanes: network lookups can fail (for example, `ENOTFOUND`), and required outside-workspace state writes such as `~/.grok/managed_config.toml` can be denied. In operator checks with Codex CLI 0.160.0 on macOS, the restricted session exposed `CODEX_SANDBOX=seatbelt` and `CODEX_SANDBOX_NETWORK_DISABLED=1`; full access exposed neither. Missing markers do not prove full access. Read the parent's effective permission instructions for network and the actual required paths; `workspace-write` alone does not establish either permission, and writable roots can include `CODEX_HOME`.
- `/setup-pstack` checks assigned external lanes' required access before any external CLI check or probe. If access is blocked or unresolved, the operator can restart with suitable permissions or explicitly choose native-only role assignments. For Grok under Codex on macOS, child sandbox profiles do not nest; deliberately starting the parent with `danger-full-access` is the documented operator choice, not an automatic change. Setup separately checks both actual configuration destinations before any write, leaving existing bytes unchanged on refusal. It does not alter Codex permissions, silently replace families, or lower effort.
- A dispatched runner still launches the instructed lane. Only after a plain `child-failed` without a provider-reported reason, an explicit Codex parent with `CODEX_SANDBOX_NETWORK_DISABLED=1` gets a likely-parent-sandbox hint in the existing error message and the front of its bounded evidence. The status remains `child-failed`/70; raw child evidence is retained. Provider-reported reasons, other failure statuses, successful runs, and Claude parents are unchanged. This post-failure hint is not a permission check or proof that every DNS or write error came from a sandbox.

The parent invocation must itself be resumable background work:

Expand Down
177 changes: 177 additions & 0 deletions plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,183 @@ afterEach(() => {
rmSync(scratch, { recursive: true, force: true });
});

describe("Codex parent sandbox diagnostics", () => {
let previousNetworkDisabled: string | undefined;

beforeEach(() => {
previousNetworkDisabled = process.env.CODEX_SANDBOX_NETWORK_DISABLED;
delete process.env.CODEX_SANDBOX_NETWORK_DISABLED;
});

afterEach(() => {
if (previousNetworkDisabled === undefined) {
delete process.env.CODEX_SANDBOX_NETWORK_DISABLED;
} else {
process.env.CODEX_SANDBOX_NETWORK_DISABLED = previousNetworkDisabled;
}
});

it("names likely Codex sandbox cause after child failure", async () => {
process.env.CODEX_SANDBOX_NETWORK_DISABLED = "1";
const invoked = join(scratch, "model-invoked.json");
writeFileSync(join(bin, "claude"), `#!/usr/bin/env bun
import { writeFileSync } from "node:fs";
if (process.argv[2] === "auth") {
console.log(JSON.stringify({ loggedIn: true }));
process.exit(0);
}
writeFileSync(${JSON.stringify(invoked)}, JSON.stringify({
argv: process.argv.slice(2),
networkDisabled: process.env.CODEX_SANDBOX_NETWORK_DISABLED ?? null,
}));
console.error("ENOTFOUND api.anthropic.com");
process.exit(1);
`);
const input = options("claude");
const result = await runLane(input);
const saved = receipt(input.receiptPath);
expect(JSON.parse(readFileSync(invoked, "utf8"))).toMatchObject({
argv: expect.arrayContaining(["--model", "fable"]),
networkDisabled: null,
});
expect(result.exitCode).toBe(70);
expect(saved.status).toBe("child-failed");
expect(saved.exitCode).toBe(1);
expect(saved.preflight.status).toBe("passed");
expect(saved.error?.message).toContain("child exited with status 1");
expect(saved.error?.message).toContain("likely cause: Codex parent sandbox has network disabled");
expect(saved.error?.evidence).toStartWith("likely cause: Codex parent sandbox has network disabled");
expect(saved.error?.evidence).toContain("ENOTFOUND api.anthropic.com");
expect(saved.error?.message).toContain("provider-dispatch.md#host-and-parent-prerequisites");
expect(saved.schemaVersion).toBe(1);
expect(saved.model).toBe(input.model);
expect(saved.effort).toBe(input.effort);
expect(readFileSync(streamPath(saved.stderrPath), "utf8")).toBe("ENOTFOUND api.anthropic.com\n");
expect(existsSync(input.outputPath)).toBe(false);
});

for (const marker of [undefined, "0", "true"]) {
it(`does not infer blocked network from marker ${marker}`, async () => {
if (marker !== undefined) process.env.CODEX_SANDBOX_NETWORK_DISABLED = marker;
writeFileSync(join(bin, "claude"), `#!/usr/bin/env bun
if (process.argv[2] === "auth") {
console.log(JSON.stringify({ loggedIn: true }));
process.exit(0);
}
console.error("ENOTFOUND api.anthropic.com; EACCES local-state");
process.exit(1);
`);
const result = await runLane(options("claude"));
expect(result.exitCode).toBe(70);
expect(result.receipt.error).toEqual({
message: "child exited with status 1",
evidence: "ENOTFOUND api.anthropic.com; EACCES local-state",
});
});
}

it("does not blame a Claude parent even with the network marker", async () => {
process.env.CODEX_SANDBOX_NETWORK_DISABLED = "1";
writeFileSync(join(bin, "grok"), `#!/usr/bin/env bun
if (process.argv[2] === "models") {
console.log("You are logged in with grok.com.\\nAvailable models:\\n * grok-4.6 (default)");
process.exit(0);
}
console.error("ENOTFOUND grok.com");
process.exit(1);
`);
const result = await runLane({ ...options("grok"), parent: "claude" });
expect(result.exitCode).toBe(70);
expect(result.receipt.error).toEqual({
message: "child exited with status 1",
evidence: "ENOTFOUND grok.com",
});
});

it("puts the hint ahead of bounded evidence without changing raw sidecars", async () => {
process.env.CODEX_SANDBOX_NETWORK_DISABLED = "1";
const stderr = "startup warning\n".repeat(400) + "EACCES ~/.grok/managed_config.toml\n";
writeFileSync(join(bin, "grok"), `#!/usr/bin/env bun
if (process.argv[2] === "models") {
console.log("You are logged in with grok.com.\\nAvailable models:\\n * grok-4.6 (default)");
process.exit(0);
}
process.stderr.write(${JSON.stringify(stderr)});
process.exit(1);
`);
const input = { ...options("grok"), parent: "codex" as const };
const result = await runLane(input);
expect(result.exitCode).toBe(70);
expect(result.receipt.status).toBe("child-failed");
expect(result.receipt.error?.evidence).toStartWith("likely cause: Codex parent sandbox has network disabled");
expect(result.receipt.error?.evidence?.length).toBe(4_000);
expect(readFileSync(streamPath(result.receipt.stderrPath), "utf8")).toBe(stderr);
});

for (const scenario of ["api-error", "cancelled"]) {
it(`preserves provider-reported ${scenario} ahead of any sandbox hint`, async () => {
process.env.CODEX_SANDBOX_NETWORK_DISABLED = "1";
const reason = scenario === "api-error" ? "API unavailable" : "User cancelled the execution";
const terminal = JSON.stringify({
type: "result",
subtype: scenario === "api-error" ? "api_error" : "error_during_execution",
is_error: true,
stop_reason: scenario === "api-error" ? "end_turn" : "cancelled",
errors: [reason],
});
writeFileSync(join(bin, "grok"), `#!/usr/bin/env bun
if (process.argv[2] === "models") {
console.log("You are logged in with grok.com.\\nAvailable models:\\n * grok-4.6 (default)");
process.exit(0);
}
console.log(${JSON.stringify(terminal)});
console.error("startup warning".repeat(500));
process.exit(1);
`);
const result = await runLane({ ...options("grok"), parent: "codex" });
expect(result.exitCode).toBe(scenario === "api-error" ? 70 : 130);
expect(result.receipt.status).toBe(scenario === "api-error" ? "child-failed" : "cancelled");
expect(result.receipt.error?.message).toBe(reason);
expect(result.receipt.error?.evidence).toStartWith(reason);
expect(result.receipt.error?.evidence).not.toContain("Codex parent sandbox");
});
}

for (const provider of ["claude", "grok"] as const) {
it(`leaves successful ${provider} lanes unchanged with the marker`, async () => {
process.env.CODEX_SANDBOX_NETWORK_DISABLED = "1";
const result = await runLane({ ...options(provider), parent: "codex" });
expect(result.exitCode).toBe(0);
expect(result.receipt.status).toBe("complete");
expect(result.receipt.error).toBeNull();
});
}

for (const status of ["unavailable-model", "unauthenticated", "timed-out"] as const) {
it(`does not enrich ${status} with a sandbox hint`, async () => {
process.env.CODEX_SANDBOX_NETWORK_DISABLED = "1";
const input = { ...options("grok"), parent: "codex" as const,
timeoutMs: status === "timed-out" ? 150 : null };
if (status === "unavailable-model") process.env.FAKE_INVALID_MODEL = "1";
if (status === "unauthenticated") {
writeFileSync(join(bin, "grok"), `#!/usr/bin/env bun
if (process.argv[2] === "models") {
console.log("You are logged in with grok.com.\\nAvailable models:\\n * grok-4.6 (default)");
process.exit(0);
}
console.error("Not logged in. Run grok auth login.");
process.exit(1);
`);
}
if (status === "timed-out") process.env.FAKE_TIMEOUT = "1";
const result = await runLane(input);
expect(result.receipt.status).toBe(status);
expect(result.receipt.error?.message).not.toContain("Codex parent sandbox");
expect(result.receipt.error?.evidence).not.toContain("Codex parent sandbox");
});
}
});

describe("issue78 terminal results", () => {
for (const scenario of ["cancel-zero", "cancel-nonzero", "api-error", "api-error-nonzero", "success-streams"]) {
it(`issue78 ${scenario}`, async () => {
Expand Down
14 changes: 11 additions & 3 deletions plugins/pstack/skills/poteto-mode/scripts/runner/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,8 @@ async function executeLane(
): Promise<RunResult> {
const startedAt = new Date(started).toISOString();
const prompt = readFileSync(options.promptPath, "utf8");
const parentNetworkDisabled = options.parent === "codex"
&& process.env.CODEX_SANDBOX_NETWORK_DISABLED === "1";
const env = childEnvironment(options.provider);
const executable = Bun.which(invocation.command, {
PATH: env.PATH,
Expand Down Expand Up @@ -818,13 +820,19 @@ async function executeLane(
if (result.cancelledBy !== null || result.timedOut || providerFailure !== null
|| result.exitCode !== 0) {
const rawFailureEvidence = `${result.stderr}\n${result.stdout}`;
const failureEvidence = evidence(providerFailure === null
? rawFailureEvidence : `${providerFailure.message}\n${rawFailureEvidence}`);
const status: ReceiptStatus = result.cancelledBy !== null
? "cancelled"
: result.timedOut
? "timed-out"
: providerFailure?.status ?? unavailableStatus(rawFailureEvidence);
const sandboxHint = status === "child-failed" && providerFailure === null && parentNetworkDisabled
? "likely cause: Codex parent sandbox has network disabled; see provider-dispatch.md#host-and-parent-prerequisites"
: null;
const failureEvidence = evidence(providerFailure !== null
? `${providerFailure.message}\n${rawFailureEvidence}`
: sandboxHint !== null
? `${sandboxHint}\n${rawFailureEvidence}`
: rawFailureEvidence);
receipt = completeReceipt(options, {
...base,
status,
Expand All @@ -841,7 +849,7 @@ async function executeLane(
: `launcher received ${result.cancelledBy} after child exited`
: result.timedOut
? `launcher exceeded the explicit ${options.timeoutMs}ms deadline`
: providerFailure?.message ?? `child exited with status ${result.exitCode}`,
: providerFailure?.message ?? `child exited with status ${result.exitCode}${sandboxHint === null ? "" : `; ${sandboxHint}`}`,
evidence: failureEvidence,
},
});
Expand Down
15 changes: 13 additions & 2 deletions plugins/pstack/skills/setup-pstack/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Claude Code writes `~/.claude/pstack-models.md` and loads it from `~/.claude/CLA
@~/.claude/pstack-models.md
```

Codex writes `~/.codex/pstack-models.md`. Codex has no `@` include, so mirror the sheet's exact bytes inside one bounded block in `~/.codex/AGENTS.md` and retain the sheet as the editable source of truth:
Codex writes `~/.codex/pstack-models.md`. When `CODEX_HOME` is set, resolve both Codex targets under that directory instead of the `~/.codex` examples here. Codex has no `@` include, so mirror the sheet's exact bytes inside one bounded block in `~/.codex/AGENTS.md` and retain the sheet as the editable source of truth:

```text
<!-- pstack:models:begin -->
Expand Down Expand Up @@ -49,6 +49,15 @@ Ask one effort question for each assigned family. Name each model, its current o

### 5. Probe the requested pairs

Before any external CLI availability/authentication check or model probe, resolve the selected families through this parent's route table. On a Codex parent, name the assigned external families (Claude Fable, Claude Opus, and/or Grok) and inspect the parent's effective permission instructions for network access and the actual credential/state paths those CLIs need, including any required outside-workspace writes. Do not inspect credential contents or attempt a denied write to establish permission. `CODEX_SANDBOX_NETWORK_DISABLED=1` in the parent's own environment is evidence that external CLI network access is disabled; inspect it before child-environment scrubbing. `CODEX_SANDBOX` identifies a sandbox backend, not a complete permission policy. Missing markers, a config-file preference, or the name `workspace-write` alone do not prove sufficient access. Use the effective session permissions and required paths, not a blanket ban on every path outside the workspace.

If required access is blocked, explain the affected assigned families and the restriction before invoking their CLIs. If permission information is unavailable or incomplete, call it **unknown**, say which required access cannot be established, and resolve it before external checks or probes. Offer exactly two paths when access is blocked or remains unknown:

1. Restart the parent with suitable permissions, chosen deliberately by the operator; stop this setup without writing. See provider-dispatch's **Host and parent prerequisites**, including Grok's nested-sandbox limitation.
2. Explicitly choose native-only role assignments for this parent. Show each affected role and proposed replacement and obtain the operator's named changes, then return to steps 2–4 to validate the revised map and efforts before native probes. On Codex, Sol is native; `inherit-parent` and `auto` also stay native. Do not change any lane automatically, reduce its effort, or launch an external CLI for a now-unassigned family. State any reduction in provider diversity.

If all chosen families are native, no external CLI permission check or probe is needed; the separate final-write check still applies. When the effective permissions allow the selected external lanes, proceed with the existing routes and requested efforts unchanged. Never change sandbox settings or escalate permissions as part of setup.

Probe only the selected `provider:model@effort` pair of each assigned family. Run one probe per family in the role map, even when two families share a provider. Do not enumerate or offer older models as substitutes. A failed probe writes nothing: report the failing pair and provider, stop, and keep the active sheet plus parent integration bytes unchanged. A failed first run creates neither artifact.

If a Grok probe fails because the host cannot enforce its bounded sandbox, see [Host and parent prerequisites](../poteto-mode/references/provider-dispatch.md#host-and-parent-prerequisites); keep the active configuration unchanged.
Expand Down Expand Up @@ -81,7 +90,9 @@ Why and Reflect require the parent's live MCP surface. Keep their investigator,

Every non-alias value must match `<provider>:<model>@<effort>` and must have passed step 5.

After the operator confirms, write the in-memory render from step 6. Never paste the example below as the result. It is only the complete first-run role map used to seed step 2; selected efforts and explicit role changes always replace its example values before writing.
After the operator confirms, separately check the parent's effective write permission for **both actual destinations** (the sheet and parent integration, resolved through `CODEX_HOME` when set) and any directory creation needed. Render both artifacts in memory and perform this check before the first write or directory creation. Native probes succeeding does not grant configuration-write permission. A restricted parent may still be allowed to write under `CODEX_HOME`; assess each resolved destination rather than rejecting it just because it is outside the workspace. If either destination is denied or its permission remains unknown, stop with the destination-specific explanation: leave every existing artifact byte-identical and create neither artifact on a first run. Do not test permission by overwriting a target, write only the allowed artifact, or automatically request escalation.

Only after both destinations are permitted, write the in-memory render from step 6 using step 8's snapshot and readback procedure. Never paste the example below as the result. It is only the complete first-run role map used to seed step 2; selected efforts and explicit role changes always replace its example values before writing.

```markdown
# pstack model configuration
Expand Down
Loading