fix(runner): stabilize warmed Codex isolation evidence

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-08 03:11:05 +00:00
1 parent 373b609ff1
commit eef193508d
9 files changed
+5733 -6152

No files matched your search

@@ -70,10 +70,19 @@ The trusted app-server process has an allowlisted environment. It retains host
`HOME` and `CODEX_HOME` only so the provider can authenticate. Model-issued
commands have a separate boundary: an empty-by-default environment with no
`HOME` or `CODEX_HOME`, no network, and a named Codex
permission profile with read-only minimal runtime files, write access only to
the assigned workspace, and no access to the rest of the filesystem. The
driver refuses filesystem-root workspaces, workspaces containing host `HOME`,
and any workspace overlapping host `CODEX_HOME`.
permission profile requesting read-only minimal runtime files, no host-home or
Codex-home access, and write access to the assigned workspace. The driver
refuses filesystem-root workspaces, workspaces containing host `HOME`, and any
workspace overlapping host `CODEX_HOME`.
The returned sandbox facts remain authoritative. Codex 0.132.0 may inject a
provider-managed writable root such as `~/.codex/memories` after a first run,
even with `features.memories=false` and an explicit Codex-home deny. That makes
the Codex-home directory discoverable in a warmed environment, so Phase 4 does
not claim whole-directory unreadability. Its authenticated proof instead
requires each readable `auth.json`/`config.toml` file and an unrelated host
secret to remain unreadable and unwritable, while recording any injected root
in `context.sandbox.legacyPolicy`.
Paperclip bearer values, `OPENAI_API_KEY`, arbitrary skill paths, and other
inherited variables are not passed. Diagnostics redact bearer/basic
@@ -143,9 +152,11 @@ tracer requires byte-equivalent live and replay snapshots.
`trace:phase4` starts a real local `codex app-server` session through the mock
core. Its safe task creates `hello.txt` with network disabled. The evidence
recorder additionally probes a readable host Codex credential/config path and
an unrelated host secret, requiring both reads and writes to be denied while
workspace output and app-server authentication still succeed. See the
recorder additionally probes all readable host Codex credential/config files
and an unrelated host secret, requiring reads and writes to be denied while
workspace output and app-server authentication still succeed. It gates output
reads on an accepted `done` result and reports missing files by name rather
than surfacing a raw filesystem `ENOENT`. See the
[Phase 4 tutorial](tutorials/phase-04-skillless-codex.md).
This phase changes no browser surface, so no new browser screenshot applies.
@@ -29,6 +29,13 @@ codex --version
codex app-server --help
```
Run the real-Codex steps from an ordinary operator shell with a workspace that
has no `AGENTS.md` in that directory or any ancestor. A child directory of this
repository is not skillless because Codex discovers the repository
`AGENTS.md`. In a Paperclip-managed agent run, `PAPERCLIP_WORKSPACE_CWD` also
prevents `mktemp -d` from selecting a directory outside the assigned workspace;
use a separate clean operator workspace rather than bypassing that guard.
## Step 1: Run the focused conformance checks
```sh
@@ -92,9 +99,12 @@ Confirm:
- `modelInputKinds` contains only `text`;
- the semantic tools are `paperclip_finish` and `paperclip_block`;
- no environment **value** is present;
- the permission profile denies root access, exposes only read-only minimal
runtime files, grants write access to the assigned workspace, and disables
network access;
- the requested permission profile denies root and host-home access, exposes
read-only minimal runtime files, grants write access to the assigned
workspace, and disables network access;
- the returned legacy sandbox facts are inspected separately because Codex may
add provider-managed state roots such as `~/.codex/memories` even when memory
instructions are disabled;
- command `HOME` and `CODEX_HOME` are absent;
- the envelope contains only this safe task and its completion criteria.
@@ -133,11 +143,19 @@ To record the authenticated isolation proof, run:
pnpm --filter @paperclipai/paperclip-runner record:phase4
```
The recorder selects an existing readable host Codex credential/config path,
creates a separate host secret, and asks the model to prove that neither is
readable or writable. The run must still authenticate, create the workspace
The recorder selects every existing readable host Codex credential/config file
(`auth.json` and/or `config.toml`), creates a separate host secret, and asks the
model to prove that those files, the host-home root, and the unrelated secret
are neither readable nor writable. It intentionally does not assert that the
whole Codex-home directory is unreadable: warmed Codex 0.132.0 environments can
inject `~/.codex/memories` as a writable legacy sandbox root. The credential
files remain denied. The run must still authenticate, create the workspace
files, retain the successful command output, and pass every trace assertion.
If the tracer returns `blocked`/`needs_review`, or an accepted `done` result is
missing an expected file, the recorder exits with a named diagnostic before it
attempts to consume absent outputs.
## Step 5: Try steering
Use a new directory:
@@ -45,6 +45,12 @@ pnpm --filter @paperclipai/paperclip-runner verify
- All live events pass the existing Phase 1 validator/reducer. Replaying them
produces the same snapshot.
- The package boundary rejects imports from Paperclip core packages.
- The authenticated isolation proof targets each readable host
`auth.json`/`config.toml` file plus an unrelated secret, not the whole
Codex-home directory. The trace records Codex's injected memories root while
proving the credential files remain unreadable and unwritable.
- The recorder requires an accepted `done` result before reading outputs and
turns an absent `hello.txt` or `isolation-ok.txt` into a named diagnostic.
# Recorded outputs
@@ -52,24 +58,28 @@ pnpm --filter @paperclipai/paperclip-runner verify
- [Driver reference](../../docs/phase-04-skillless-codex-driver.md)
- [Hands-on tutorial](../../docs/tutorials/phase-04-skillless-codex.md)
The trace normalizes its temporary working directory and writable Codex state
root. It contains no credential values. This phase changes no browser surface,
so a browser screenshot is not applicable.
The trace normalizes its temporary working directory, both readable Codex
credential/config paths, and writable Codex state root. It contains no
credential values. This phase changes no browser surface, so a browser
screenshot is not applicable.
# Local verification result
- Codex CLI/app-server: `0.132.0`.
- Real model/provider: `gpt-5.5` / `openai`.
- Focused recorder regressions: 3 passed, covering a warmed memories directory,
non-`done` output gating, and missing-file diagnostics.
- Focused driver tests: 7 passed.
- Full TypeScript suite: 73 passed.
- Full Rust suite: 39 passed (37 unit and 2 process-supervisor tests).
- Existing browser suite: 9 passed with the rootless Linux library path.
- The real safe task created `hello.txt` with exactly `hello from phase 4`.
- The real trace contains 340 canonical events and all eight assertions pass:
exactly one terminal result, live/replay parity, stable run/session identity,
contiguous source sequence, stable item identity, skillless context,
unrelated-skill absence, and credential absence. The recorder separately
verifies the exact safe-file content.
- The refreshed warmed-environment trace contains 374 canonical events and all
nine assertions pass: exactly one terminal result, accepted proposal,
live/replay parity, stable run/session identity, contiguous source sequence,
stable item identity, skillless context, unrelated-skill absence, and
credential absence. The recorder separately verifies the exact safe-file
content.
- Real steering and interruption commands preserved the session identity and
ended with exactly one result and terminal event.
- TypeScript, Rust, and browser builds and typechecks passed. Golden, parity,
@@ -90,6 +100,15 @@ so a browser screenshot is not applicable.
- The normal package verifier reached Playwright but the host lacked
`libatk-1.0.so.0`. The supported rootless verifier supplied private browser
libraries, then completed the same `verify` path with all nine browser tests.
- The original recorder required the whole `~/.codex` directory to be
unreadable. Codex 0.132.0 injects `~/.codex/memories` as a legacy writable
root after warm-up, making that directory check unsatisfiable while leaving
`auth.json` and `config.toml` protected. The proof now checks credential files
directly and the refreshed trace records the injected root.
- A valid `blocked` result previously fell through to unconditional output
reads and surfaced an unhandled `ENOENT`. The recorder now validates the
decision/disposition first and guards each expected output with a clear
filename-specific diagnostic.
# Review state
File diff suppressed because it is too large. Load diff
@@ -35,8 +35,11 @@ Paperclip production integration or browser surface.
interrupt, usage, structured output, dynamic tools, and reconciliation.
9. Normalize every provider notification through the existing PRP validator,
reducer, and replay path.
10. Keep the real example safe: workspace-write sandbox, no network request,
one allowed file, and exact output verification.
10. Keep the real example safe: requested workspace-write sandbox, no network
request, one allowed file, and exact output verification. Treat returned
provider sandbox facts as authoritative when they add managed state roots.
11. Prove credential-file and unrelated-secret isolation directly. Do not use
whole Codex-home unreadability as a proxy in warmed environments.
# Evidence
@@ -61,6 +64,13 @@ and read the [driver reference](../../docs/phase-04-skillless-codex-driver.md).
- The first redaction test stopped reading before the diagnostic event. It now
consumes the complete deterministic event sequence before asserting that the
secret is absent.
- The original authenticated proof denied the entire Codex home. Codex 0.132.0
materializes and injects `~/.codex/memories` as a writable legacy root, so the
parent directory becomes readable after warm-up even though `auth.json` and
`config.toml` remain protected. The recorder now probes those files directly.
- A non-`done` semantic result could reach unconditional `hello.txt` and
`isolation-ok.txt` reads. The recorder now stops on the result diagnostic and
reports any missing accepted-result file by name.
# Known gaps
@@ -69,6 +79,10 @@ and read the [driver reference](../../docs/phase-04-skillless-codex-driver.md).
implementation language.
- Real-model choices are nondeterministic. The focused fake-transport suite is
the conformance authority; the recorded real trace is a hands-on proof.
- The Codex permission profile is a requested policy, not stronger evidence
than the returned sandbox. Current Codex versions may expose provider-managed
state roots such as `~/.codex/memories`; Phase 4 guarantees credential-file
isolation rather than whole Codex-home directory isolation.
- The safe task may use a command instead of a file-change item. Deterministic
tests cover both file and request mappings.
- This package proof does not supply production MCP bindings, approval UI,
+1 -1
View File
@@ -24,7 +24,7 @@
"typecheck:typescript": "pnpm run check:protocol-types && tsc -p tsconfig.json --noEmit",
"typecheck:rust": "cargo check --manifest-path runner/Cargo.toml --locked --workspace",
"typecheck:browser": "tsc -p tsconfig.browser.json --noEmit",
"test": "pnpm run build:phase2-binaries && pnpm run test:typescript && pnpm run test:rust && node --test scripts/check-forbidden-imports.test.mjs",
"test": "pnpm run build:phase2-binaries && pnpm run test:typescript && pnpm run test:rust && node --test scripts/check-forbidden-imports.test.mjs scripts/record-phase4-evidence.test.mjs",
"test:typescript": "vitest run",
"test:rust": "cargo test --manifest-path runner/Cargo.toml --locked --workspace",
"test:browser": "pnpm run build:typescript && pnpm run build:phase2-binaries && pnpm run build:browser && playwright test --config devtools/browser/playwright.config.ts",
@@ -0,0 +1,80 @@
import { constants } from "node:fs";
import { access, readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
export function codexCredentialFileCandidates(codexHome) {
return [join(codexHome, "auth.json"), join(codexHome, "config.toml")];
}
export async function findReadableCodexCredentialFiles(codexHome) {
const readable = [];
for (const candidate of codexCredentialFileCandidates(codexHome)) {
try {
await access(candidate, constants.R_OK);
readable.push(resolve(candidate));
} catch {
// A signed-in Codex installation may use either file. Probe both.
}
}
return readable;
}
export function createPhase4DeniedHostPaths({
hostHome,
codexCredentialFiles,
unrelatedSecretPath,
}) {
return [...new Set([
resolve(hostHome),
...codexCredentialFiles.map((path) => resolve(path)),
resolve(unrelatedSecretPath),
])];
}
export function assertPhase4TraceReadyForOutputs(trace) {
const decision = trace?.resultDecision?.status;
const disposition = trace?.result?.reportedWorkDisposition;
if (decision !== "accepted" || disposition !== "done") {
const summary = phase4TraceSummary(trace);
throw new Error(
"Phase 4 recorder requires an accepted `done` result before reading workspace outputs; " +
`received decision=${decision ?? "missing"}, disposition=${disposition ?? "missing"}` +
`${summary ? ` (${summary})` : ""}. Expected workspace outputs were not read.`,
);
}
const failedAssertions = Object.entries(trace?.assertions ?? {})
.filter(([, passed]) => passed !== true)
.map(([name]) => name);
if (failedAssertions.length > 0) {
throw new Error(
`Phase 4 tracer reported failed assertions: ${failedAssertions.join(", ")}`,
);
}
}
export async function readRequiredPhase4Output(path, displayName) {
try {
return await readFile(path, "utf8");
} catch (error) {
if (error && typeof error === "object" && error.code === "ENOENT") {
throw new Error(
`Phase 4 recorder expected ${displayName} after an accepted \`done\` result, but the file was not created.`,
);
}
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`Phase 4 recorder could not read ${displayName}: ${detail}`);
}
}
function phase4TraceSummary(trace) {
if (typeof trace?.result?.summary === "string" && trace.result.summary.trim().length > 0) {
return trace.result.summary.trim();
}
const issues = trace?.resultDecision?.issues;
if (!Array.isArray(issues)) return "";
return issues
.map((issue) => issue?.message)
.filter((message) => typeof message === "string" && message.trim().length > 0)
.join("; ");
}
@@ -1,122 +1,147 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { constants } from "node:fs";
import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
assertPhase4TraceReadyForOutputs,
createPhase4DeniedHostPaths,
findReadableCodexCredentialFiles,
readRequiredPhase4Output,
} from "./lib/phase4-evidence.mjs";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const scratchRoot =
process.env.PAPERCLIP_RUN_SCRATCH_DIR ??
process.env.PAPERCLIP_SCRATCH_DIR ??
tmpdir();
await mkdir(scratchRoot, { recursive: true });
const workspace = await mkdtemp(join(scratchRoot, "paperclip-runner-phase4-"));
const hostileHostDirectory = await mkdtemp(join(scratchRoot, "paperclip-runner-phase4-host-"));
const unrelatedSecretPath = join(hostileHostDirectory, "unrelated-secret.txt");
await writeFile(unrelatedSecretPath, "must remain outside the model sandbox", { mode: 0o600 });
const hostHome = process.env.HOME;
if (!hostHome) throw new Error("HOME is required to locate the host Codex configuration");
const codexHome = resolve(process.env.CODEX_HOME ?? join(hostHome, ".codex"));
const credentialCandidates = [join(codexHome, "auth.json"), join(codexHome, "config.toml"), codexHome];
let codexCredentialPath;
for (const candidate of credentialCandidates) {
async function recordPhase4Evidence() {
const scratchRoot =
process.env.PAPERCLIP_RUN_SCRATCH_DIR ??
process.env.PAPERCLIP_SCRATCH_DIR ??
tmpdir();
await mkdir(scratchRoot, { recursive: true });
const workspace = await mkdtemp(join(scratchRoot, "paperclip-runner-phase4-"));
const hostileHostDirectory = await mkdtemp(join(scratchRoot, "paperclip-runner-phase4-host-"));
const unrelatedSecretPath = join(hostileHostDirectory, "unrelated-secret.txt");
await writeFile(unrelatedSecretPath, "must remain outside the model sandbox", { mode: 0o600 });
const hostHome = process.env.HOME;
if (!hostHome) throw new Error("HOME is required to locate the host Codex configuration");
const codexHome = resolve(process.env.CODEX_HOME ?? join(hostHome, ".codex"));
const codexCredentialFiles = await findReadableCodexCredentialFiles(codexHome);
if (codexCredentialFiles.length === 0) {
throw new Error(
"No readable host Codex auth.json or config.toml file was available for the isolation probe",
);
}
const deniedHostPaths = createPhase4DeniedHostPaths({
hostHome,
codexCredentialFiles,
unrelatedSecretPath,
});
const rawTracePath = join(workspace, "trace.raw.json");
const evidencePath = join(
packageRoot,
"knowledge/evidence/phase-04-codex-trace.json",
);
try {
await access(candidate, constants.R_OK);
codexCredentialPath = candidate;
break;
} catch {
// Keep looking for a host path that proves the former read path was reachable.
const run = spawnSync(
process.execPath,
[
join(packageRoot, "dist/cli/phase4-codex.js"),
"--working-directory",
workspace,
"--output",
rawTracePath,
...deniedHostPaths.flatMap((path) => ["--deny-read-write-path", path]),
],
{
cwd: packageRoot,
env: { ...process.env, PAPERCLIP_WORKSPACE_CWD: workspace },
stdio: "inherit",
},
);
if (run.error) {
throw new Error(`Phase 4 tracer could not start: ${run.error.message}`);
}
let trace;
try {
trace = JSON.parse(await readFile(rawTracePath, "utf8"));
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(
`Phase 4 tracer did not produce a readable trace (status ${run.status ?? "unknown"}): ${detail}`,
);
}
assertPhase4TraceReadyForOutputs(trace);
if (run.status !== 0) {
throw new Error(
`Phase 4 tracer exited with status ${run.status ?? "unknown"} after reporting an accepted \`done\` result`,
);
}
const hello = await readRequiredPhase4Output(join(workspace, "hello.txt"), "hello.txt");
if (hello !== "hello from phase 4") {
throw new Error("Phase 4 safe task produced unexpected hello.txt content");
}
const isolationProof = await readRequiredPhase4Output(
join(workspace, "isolation-ok.txt"),
"isolation-ok.txt",
);
if (isolationProof !== "isolation enforced") {
throw new Error("Phase 4 isolation probe did not produce the expected proof file");
}
const commandObserved = trace.events?.some((event) =>
event.eventType === "item.started" &&
event.payload?.kind === "commandExecution" &&
deniedHostPaths.every((path) => JSON.stringify(event.payload).includes(path))
);
const proofOutputObserved = trace.events?.some((event) =>
(event.eventType === "item.delta" || event.eventType === "item.completed") &&
event.payload?.kind === "commandExecution" &&
(
String(event.payload?.text).includes("isolation enforced") ||
String(event.payload?.item?.aggregatedOutput).includes("isolation enforced")
)
);
if (!commandObserved || !proofOutputObserved) {
throw new Error("Phase 4 trace did not retain the successful hostile-path isolation command");
}
const replacements = [
[workspace, "$PHASE4_WORKSPACE"],
...codexCredentialFiles.map((path) => [
path,
path.endsWith("auth.json") ? "$HOST_CODEX_AUTH_PATH" : "$HOST_CODEX_CONFIG_PATH",
]),
[codexHome, "$HOST_CODEX_HOME"],
[unrelatedSecretPath, "$HOST_UNRELATED_SECRET"],
[hostileHostDirectory, "$HOST_SECRET_DIRECTORY"],
[resolve(hostHome), "$HOST_HOME"],
];
const writableRoots = trace.context?.sandbox?.writableRoots;
if (Array.isArray(writableRoots)) {
writableRoots.forEach((root, index) => {
if (typeof root === "string" && root.length > 0) {
replacements.push([root, `$CODEX_WRITABLE_ROOT_${index + 1}`]);
}
});
}
const normalized = normalizeStrings(trace, replacements);
await writeFile(evidencePath, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
process.stdout.write(`Recorded ${evidencePath}\n`);
} finally {
await rm(workspace, { recursive: true, force: true });
await rm(hostileHostDirectory, { recursive: true, force: true });
}
}
if (!codexCredentialPath) {
throw new Error("No readable host Codex credential/config path was available for the isolation probe");
}
const deniedHostPaths = [...new Set([
resolve(hostHome),
codexHome,
codexCredentialPath,
unrelatedSecretPath,
])];
const rawTracePath = join(workspace, "trace.raw.json");
const evidencePath = join(
packageRoot,
"knowledge/evidence/phase-04-codex-trace.json",
);
try {
const run = spawnSync(
process.execPath,
[
join(packageRoot, "dist/cli/phase4-codex.js"),
"--working-directory",
workspace,
"--output",
rawTracePath,
...deniedHostPaths.flatMap((path) => ["--deny-read-write-path", path]),
],
{
cwd: packageRoot,
env: { ...process.env, PAPERCLIP_WORKSPACE_CWD: workspace },
stdio: "inherit",
},
);
if (run.status !== 0) {
throw new Error(`Phase 4 tracer exited with status ${run.status ?? "unknown"}`);
}
const trace = JSON.parse(await readFile(rawTracePath, "utf8"));
if (Object.values(trace.assertions ?? {}).some((passed) => passed !== true)) {
throw new Error("Phase 4 tracer reported a failed assertion");
}
const hello = await readFile(join(workspace, "hello.txt"), "utf8");
if (hello !== "hello from phase 4") {
throw new Error("Phase 4 safe task produced unexpected hello.txt content");
}
const isolationProof = await readFile(join(workspace, "isolation-ok.txt"), "utf8");
if (isolationProof !== "isolation enforced") {
throw new Error("Phase 4 isolation probe did not produce the expected proof file");
}
const commandObserved = trace.events?.some((event) =>
event.eventType === "item.started" &&
event.payload?.kind === "commandExecution" &&
deniedHostPaths.every((path) => JSON.stringify(event.payload).includes(path))
);
const proofOutputObserved = trace.events?.some((event) =>
(event.eventType === "item.delta" || event.eventType === "item.completed") &&
event.payload?.kind === "commandExecution" &&
(
String(event.payload?.text).includes("isolation enforced") ||
String(event.payload?.item?.aggregatedOutput).includes("isolation enforced")
)
);
if (!commandObserved || !proofOutputObserved) {
throw new Error("Phase 4 trace did not retain the successful hostile-path isolation command");
}
const replacements = [
[workspace, "$PHASE4_WORKSPACE"],
[codexCredentialPath, "$HOST_CODEX_CREDENTIAL_PATH"],
[codexHome, "$HOST_CODEX_HOME"],
[unrelatedSecretPath, "$HOST_UNRELATED_SECRET"],
[hostileHostDirectory, "$HOST_SECRET_DIRECTORY"],
[resolve(hostHome), "$HOST_HOME"],
];
const writableRoots = trace.context?.sandbox?.writableRoots;
if (Array.isArray(writableRoots)) {
writableRoots.forEach((root, index) => {
if (typeof root === "string" && root.length > 0) {
replacements.push([root, `$CODEX_WRITABLE_ROOT_${index + 1}`]);
}
});
}
const normalized = normalizeStrings(trace, replacements);
await writeFile(evidencePath, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
process.stdout.write(`Recorded ${evidencePath}\n`);
} finally {
await rm(workspace, { recursive: true, force: true });
await rm(hostileHostDirectory, { recursive: true, force: true });
await recordPhase4Evidence();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`Phase 4 evidence recording failed: ${message}\n`);
process.exitCode = 1;
}
function normalizeStrings(value, replacements) {
@@ -0,0 +1,75 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
assertPhase4TraceReadyForOutputs,
createPhase4DeniedHostPaths,
findReadableCodexCredentialFiles,
readRequiredPhase4Output,
} from "./lib/phase4-evidence.mjs";
test("a warmed Codex home contributes credential files, not the readable Codex directory", async () => {
const root = await mkdtemp(join(tmpdir(), "paperclip-phase4-recorder-test-"));
try {
const hostHome = join(root, "home");
const codexHome = join(hostHome, ".codex");
const memories = join(codexHome, "memories");
const auth = join(codexHome, "auth.json");
const config = join(codexHome, "config.toml");
const unrelatedSecretPath = join(root, "secret.txt");
await mkdir(memories, { recursive: true });
await writeFile(auth, "auth");
await writeFile(config, "config");
await writeFile(unrelatedSecretPath, "secret");
const credentialFiles = await findReadableCodexCredentialFiles(codexHome);
assert.deepEqual(credentialFiles, [auth, config]);
const deniedPaths = createPhase4DeniedHostPaths({
hostHome,
codexCredentialFiles: credentialFiles,
unrelatedSecretPath,
});
assert.deepEqual(deniedPaths, [hostHome, auth, config, unrelatedSecretPath]);
assert.equal(deniedPaths.includes(codexHome), false);
assert.equal(deniedPaths.includes(memories), false);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("a non-done trace fails before the recorder reads workspace outputs", () => {
assert.throws(
() => assertPhase4TraceReadyForOutputs({
resultDecision: { status: "accepted", issues: [] },
result: {
reportedWorkDisposition: "blocked",
summary: "The required isolation command failed.",
},
assertions: { proposalAccepted: true },
}),
/requires an accepted `done` result.*disposition=blocked.*Expected workspace outputs were not read/,
);
});
test("a missing accepted-result output has a named recorder diagnostic", async () => {
const root = await mkdtemp(join(tmpdir(), "paperclip-phase4-recorder-test-"));
try {
await assert.rejects(
readRequiredPhase4Output(join(root, "hello.txt"), "hello.txt"),
(error) => {
assert.equal(
error.message,
"Phase 4 recorder expected hello.txt after an accepted `done` result, but the file was not created.",
);
assert.doesNotMatch(error.message, /ENOENT/);
return true;
},
);
} finally {
await rm(root, { recursive: true, force: true });
}
});