Files
PaperClipAI/tests/runner-e2e/redaction.ts
T
Dotta 0c1e7504c0 fix(runner): persist warm Daytona workspaces across turns (#12904)
## Thinking Path

> - Daytona preserves a stopped sandbox filesystem, but deleting or
replacing a sandbox removes its only remote copy.
> - Warm reuse therefore improves latency but cannot be Paperclip's
durability boundary.
> - The host execution workspace must remain authoritative after every
successful turn, while same-run recovery must avoid overwriting
unexported remote work.
> - Result proposal, workspace export/merge, and terminal completion
need a durable, replayable ordering so a crash never starts a duplicate
provider turn.
> - A paid browser acceptance suite must exercise both legacy Codex and
Runner Codex for three real turns on one continuously warm Daytona
sandbox.

## Linked Issues or Issue Description

Refs #12901.

Runner Codex did not previously export successful Daytona workspace
changes back to the authoritative host workspace. That made warm reuse
depend on Daytona's remote filesystem and left deleted/replacement
sandboxes without a reliable reconstruction path. The existing paid
fixture also lacked a focused three-turn continuity case for both Codex
adapters.

## What Changed

- Persist versioned, atomic native workspace-sync descriptors and
durable seeds in `PAPERCLIP_HOME`, without credentials or a database
migration.
- Classify fresh, warm, replacement, and same-run-recovery workspace
preparation explicitly; ambiguous lease/root/digest evidence fails
closed.
- Finalize native workspace export/merge after semantic result proposal
and before run completion, with idempotent replay that never submits a
second provider turn.
- Surface legacy Codex workspace restoration failures instead of masking
them, while preserving an earlier provider error when both fail.
- Keep healthy reusable Daytona leases warm for legacy and native
adapters, stamp finalized workspace generations, and retain existing
cleanup behavior for per-turn or unhealthy leases.
- Preserve Runner Codex's provider process/session across warm turns,
including bounded post-terminal tail draining and exact authority
rotation.
- Add the exact paid `daytona-warm-continuity` matrix:
  - `legacy-codex × daytona × warm-three-turn`
  - `runner-codex × daytona × warm-three-turn`
- Drive all three turns through the browser, verify ordered file
continuity and stable lease/workspace/runtime identities, capture
per-turn timings, and delete the sandbox immediately after assertions.
- Document `pnpm test:e2e:runner -- --suite daytona-warm-continuity`; no
package script was added.

## Verification

- `pnpm typecheck` — passed, including migration safety (no migration
added)
- Focused server/runner Vitest coverage — 144 passed
- `pnpm test:e2e:runner:unit` — 114 passed
- `pnpm test:e2e:runner:typecheck` — passed
- `pnpm --filter @paperclipai/paperclip-runner test:codex` — 66 passed,
1 helper ignored
- `native-session-executor.test.ts` — 139 passed, including safe
fail-closed cleanup after remote runner identity capture failure
- Paid local browser acceptance, exact post-rebase Linux/amd64 runner
binary:
- Runner Codex — passed in 1.7m; 3 runs; lease outcomes `created,
resumed, resumed`; 10/10 matchers; cleanup passed
- Legacy Codex — passed in 2.7m; 3 runs; lease outcomes `created,
resumed, resumed`; 10/10 matchers; cleanup passed
- [Protected paid GitHub Actions
campaign](https://github.com/paperclipai/paperclip/actions/runs/34026735033)
against `7da42a91b95fa7fb2df126668ef7e37afb3b2b9d` — passed 2/2:
- Runner Codex — 3 runs; lease outcomes `created, resumed, resumed`;
evidence and cleanup passed
- Legacy Codex — 3 runs; lease outcomes `created, resumed, resumed`;
evidence and cleanup passed
  - Merge/enforcement, S3 history, and Pages publication jobs passed
- Paid result artifacts were scanned for both provider credentials;
neither secret was present.
- Current PR checks — 31 passed, 1 expected Storybook skip; Greptile
5/5; Superagent security scan passed
- `git diff --check origin/master...HEAD` — passed
- Confirmed no `package.json`, lockfile, migration, or SQL changes.

## Risks

- Workspace synchronization now sits on the terminal-success path, so a
remote export failure deliberately prevents false success. Retryable
state retains its lease/seed; loss of the only unexported remote copy
fails closed.
- Warm provider reuse has strict identity and quiescence checks.
Mismatched or ambiguous evidence blocks reuse rather than risking
concurrent provider work.
- The paid suite incurs Daytona and Codex cost only in the existing
protected scheduled/manual workflow and explicitly destroys its sandbox
after each cell.

## Model Used

OpenAI Codex with GPT-5 agentic reasoning, repository inspection, real
browser E2E execution, Rust/TypeScript test execution, and GitHub
Actions diagnostics.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either linked an existing issue or described the issue
in-PR
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name contains no internal ticket id
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have documented the dedicated suite invocation without adding a
package script
- [x] I have considered and documented risks above
- [x] All Paperclip CI gates are green on the current revision
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
on the current revision
- [x] I will address all reviewer comments before requesting merge
2026-09-06 08:16:12 -05:00

197 lines
6.1 KiB
TypeScript

import { createReadStream } from "node:fs";
import { readdir } from "node:fs/promises";
import path from "node:path";
import { redactDiagnosticText } from "../../packages/adapter-utils/src/command-redaction.js";
const SECRET_SHAPES = [
/\bsk-ant-[A-Za-z0-9_-]{16,}\b/g,
/\bsk-(?:proj-)?[A-Za-z0-9_-]{16,}\b/g,
/\b(?:openrouter|daytona)[-_]?(?:api)?[-_]?key["'=:\s]+[A-Za-z0-9._-]{12,}\b/gi,
] as const;
const SENSITIVE_JSON_KEY =
/^(?:api[-_]?key|access[-_]?token|refresh[-_]?token|auth(?:orization)?|bearer|client[-_]?secret|cookie|password|secret|token)$/i;
export function normalizedSecrets(values: readonly (string | undefined)[]) {
return [
...new Set(
values
.map((value) => value?.trim())
.filter((value): value is string => Boolean(value)),
),
].sort((left, right) => right.length - left.length);
}
export function isEphemeralCodexRuntimeAuthFile(
paperclipHome: string,
file: string,
) {
const relative = path.relative(paperclipHome, file).split(path.sep).join("/");
return (
/^instances\/[^/]+\/companies\/[^/]+\/agents\/[^/]+\/codex-home\/auth\.json$/.test(
relative,
) ||
/^instances\/[^/]+\/runtime\/paperclip-runner\/durable-sessions\/[^/]+\/codex-home\/auth\.json$/.test(
relative,
) ||
/^instances\/[^/]+\/runtime\/paperclip-runner\/acpx\/acpx\/[^/]+\/codex-home\/auth\.json$/.test(
relative,
)
);
}
export function redactText(value: string, secrets: readonly string[]) {
let redacted = redactDiagnosticText(value, "[REDACTED]");
return redactKnownSecretsAndShapes(redacted, secrets);
}
function redactKnownSecretsAndShapes(
value: string,
secrets: readonly string[],
) {
let redacted = value;
for (const secret of normalizedSecrets(secrets))
redacted = redacted.split(secret).join("[REDACTED]");
for (const pattern of SECRET_SHAPES)
redacted = redacted.replace(pattern, "[REDACTED_SECRET_SHAPE]");
return redacted;
}
function redactStructuredText(value: string, secrets: readonly string[]) {
const knownSafe = redactKnownSecretsAndShapes(value, secrets);
if (
!/(?:Authorization\s*:\s*Bearer|(?:api[-_]?key|token|secret|password)\s*=)/i.test(
knownSafe,
)
) {
return knownSafe;
}
return redactKnownSecretsAndShapes(
redactDiagnosticText(knownSafe, "[REDACTED]"),
secrets,
);
}
export function findSecretLeak(
value: string | Buffer,
secrets: readonly string[],
options: { includeShapes?: boolean } = {},
): string | null {
const text = Buffer.isBuffer(value) ? value.toString("utf8") : value;
for (const secret of normalizedSecrets(secrets)) {
if (text.includes(secret)) return "exact secret value";
}
if (options.includeShapes !== false) {
for (const pattern of SECRET_SHAPES) {
pattern.lastIndex = 0;
if (pattern.test(text)) return "secret-shaped value";
}
}
return null;
}
/**
* Scan structured JSON one key/value string at a time. Scanning the serialized
* document for provider key shapes can join an object key, punctuation, and an
* unrelated value into a false positive that never existed in the payload.
*/
export function findSecretLeakInJsonValues(
value: unknown,
secrets: readonly string[],
options: { includeShapes?: boolean } = {},
): string | null {
if (typeof value === "string") return findSecretLeak(value, secrets, options);
if (Array.isArray(value)) {
for (const entry of value) {
const leak = findSecretLeakInJsonValues(entry, secrets, options);
if (leak) return leak;
}
return null;
}
if (value && typeof value === "object") {
for (const [key, entry] of Object.entries(value)) {
const keyLeak = findSecretLeak(key, secrets, options);
if (keyLeak) return keyLeak;
const valueLeak = findSecretLeakInJsonValues(entry, secrets, options);
if (valueLeak) return valueLeak;
}
}
return null;
}
export function sanitizeJson(
value: unknown,
secrets: readonly string[],
): unknown {
// Structured values are not shell diagnostics. Applying the command/JWT
// heuristic here would redact stable dotted identifiers such as our schema
// name. Exact loaded secrets and well-known provider key shapes are enough.
if (typeof value === "string") return redactStructuredText(value, secrets);
if (Array.isArray(value))
return value.map((entry) => sanitizeJson(entry, secrets));
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [
key,
SENSITIVE_JSON_KEY.test(key)
? "[REDACTED]"
: sanitizeJson(entry, secrets),
]),
);
}
return value;
}
export function assertSecretFree(
value: string | Buffer,
secrets: readonly string[],
label: string,
options: { includeShapes?: boolean } = {},
) {
const leak = findSecretLeak(value, secrets, options);
if (leak) throw new Error(`Secret leak in ${label}: ${leak}`);
}
export async function findSecretLeakInDirectory(
root: string,
secrets: readonly string[],
options: {
includeShapes?: boolean;
ignoreFile?: (file: string) => boolean;
} = {},
): Promise<{ file: string; reason: string } | null> {
const overlap = Math.max(
256,
...normalizedSecrets(secrets).map((secret) => secret.length + 16),
);
const scan = async (
directory: string,
): Promise<{
file: string;
reason: string;
} | null> => {
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
const file = path.join(directory, entry.name);
if (entry.isDirectory()) {
const leak = await scan(file);
if (leak) return leak;
} else if (entry.isFile()) {
if (options.ignoreFile?.(file)) continue;
let carry = Buffer.alloc(0);
for await (const chunk of createReadStream(file)) {
const data = Buffer.concat([
carry,
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk),
]);
const reason = findSecretLeak(data, secrets, options);
if (reason) return { file, reason };
carry = data.subarray(Math.max(0, data.length - overlap));
}
}
}
return null;
};
return scan(root);
}