From 42a8be8281168f17cd2f10db00be472d2d5fcfa7 Mon Sep 17 00:00:00 2001 From: "mastra-platform[bot]" <284800079+mastra-platform[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 08:19:11 +0000 Subject: [PATCH 1/2] fix: prevent blocked setup probes and writes [#124] Explain effective parent permissions before assigned external probes, preserve an explicit native-only choice, and guard actual configuration destinations independently. Co-Authored-By: mastra-platform[bot] <284800079+mastra-platform[bot]@users.noreply.github.com> --- .../poteto-mode/references/provider-dispatch.md | 8 +++++++- plugins/pstack/skills/setup-pstack/SKILL.md | 15 +++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md b/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md index 854fd7ce..78e73e54 100644 --- a/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md +++ b/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md @@ -69,7 +69,13 @@ Pass arguments as an argv array or quote every path. Never interpolate prompt te Grok authentication preflight has one bounded retry. If the first `grok models` result would be classified as unauthenticated, the runner waits five seconds and tries the same preflight once more. A second failure is terminal. The delay and second attempt share the runner's absolute deadline and cancellation latch, and the receipt keeps evidence from both attempts. Model execution is never retried. -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. +### Host and parent prerequisites + +- 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: diff --git a/plugins/pstack/skills/setup-pstack/SKILL.md b/plugins/pstack/skills/setup-pstack/SKILL.md index a6af3b51..a46e4834 100644 --- a/plugins/pstack/skills/setup-pstack/SKILL.md +++ b/plugins/pstack/skills/setup-pstack/SKILL.md @@ -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 @@ -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. | Family | Pair source | Claude parent route | Codex parent route | Availability proof | @@ -79,7 +88,9 @@ Why and Reflect require the parent's live MCP surface. Keep their investigator, Every non-alias value must match `:@` 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 From e257bad2c7567bd5108c5c654ac27dc920efa5cc Mon Sep 17 00:00:00 2001 From: "mastra-platform[bot]" <284800079+mastra-platform[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 08:23:47 +0000 Subject: [PATCH 2/2] fix: explain likely Codex sandbox child failures [#124] Keep provider-reported reasons authoritative and preserve child-failed receipts while surfacing the confirmed network-disabled parent marker after execution. Co-Authored-By: mastra-platform[bot] <284800079+mastra-platform[bot]@users.noreply.github.com> --- .../poteto-mode/scripts/runner/run.test.ts | 177 ++++++++++++++++++ .../skills/poteto-mode/scripts/runner/run.ts | 14 +- 2 files changed, 188 insertions(+), 3 deletions(-) diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts index 7a4b0008..e6025ae2 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts @@ -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 () => { diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts index ce4ad937..5991b3eb 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts @@ -571,6 +571,8 @@ async function executeLane( ): Promise { 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, @@ -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, @@ -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, }, });