diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs index 8e5eb1e699f..e3cfb0c474e 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs @@ -458,11 +458,12 @@ impl AcpxSidecarTransport { let error = response.error.expect("failed response has validated error"); return Ok(CommandOutcome::Rejected(LocalRunnerError::invalid( format!( - "ACPX sidecar command {} was rejected (retryable={}, classification={}){}{}", + "ACPX sidecar command {} was rejected (retryable={}, classification={}){}{}{}", command.as_str(), error.retryable, response_error_classification(&error), response_error_module_fence(&error).map(|fence| format!(" moduleFence={fence}")).unwrap_or_default(), + response_error_module_read(&error).unwrap_or_default(), self.diagnostic_suffix(), ), ))); @@ -926,6 +927,9 @@ fn stderr_diagnostic_categories(value: &str) -> BTreeSet<&'static str> { // Only fixed loader fence codes may cross the private provider-stderr boundary. fn response_error_module_fence(error: &ResponseError) -> Option<&'static str> { + if response_error_module_read(error).is_some() { + return Some("link_count"); + } match error.code.as_str() { "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.RESOLUTION" => Some("resolution"), "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.OPEN" => Some("open"), @@ -948,6 +952,59 @@ fn response_error_module_fence(error: &ResponseError) -> Option<&'static str> { } } +// Independently validate the compact failure-only metadata from the sidecar. +fn response_error_module_read(error: &ResponseError) -> Option { + let suffix = error + .code + .strip_prefix("AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.LINK_COUNT.READ.")?; + let fields: Vec<&str> = suffix.split('.').collect(); + if fields.len() != 5 || !matches!(fields[0], "1" | "2") { + return None; + } + let number = |text: &str| -> Option { + if text.is_empty() + || text.len() > 20 + || !text.bytes().all(|b| b.is_ascii_digit()) + || (text.len() > 1 && text.starts_with('0')) + { + return None; + } + text.parse().ok() + }; + let before = number(fields[1])?; + let after = number(fields[2])?; + if before == after || fields[3] != if after > before { "I" } else { "D" } { + return None; + } + let flags = fields[4]; + if flags.len() != 8 + || !flags.starts_with("111111") + || !flags.bytes().all(|b| matches!(b, b'0' | b'1')) + { + return None; + } + let mut diagnostic = format!( + " moduleReadAttempt={} moduleNlinkBefore={} moduleNlinkAfter={} moduleNlinkDirection={}", + fields[0], + before, + after, + if after > before { + "increase" + } else { + "decrease" + } + ); + for (field, equal) in [ + "Dev", "Inode", "Mode", "Uid", "Gid", "Size", "Mtime", "Ctime", + ] + .into_iter() + .zip(flags.bytes()) + { + diagnostic.push_str(&format!(" module{field}Equal={}", equal == b'1')); + } + Some(diagnostic) +} + fn response_error_classification(error: &ResponseError) -> &'static str { if response_error_module_fence(error).is_some() { return "agent_startup_unverified_module"; @@ -1207,7 +1264,7 @@ mod tests { }) .collect(); let response = json!({ "protocolVersion": GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION, - "id": 1, "ok": false, "error": { "code": "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.REPEATED_CTIME", + "id": 1, "ok": false, "error": { "code": "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.LINK_COUNT.READ.1.2.1.D.11111110", "message": "/private/token-canary", "retryable": false } }); let lines = frames .iter() @@ -1236,7 +1293,12 @@ mod tests { ), "{error}" ); - assert!(error.contains("moduleFence=repeated_ctime"), "{error}"); + assert!(error.contains("moduleFence=link_count"), "{error}"); + assert!(error.contains("moduleReadAttempt=1 moduleNlinkBefore=2 moduleNlinkAfter=1 moduleNlinkDirection=decrease"), "{error}"); + assert!( + error.contains("moduleMtimeEqual=true moduleCtimeEqual=false"), + "{error}" + ); assert!( error.contains("admissionStage=handshake admissionElapsedMs=24"), "{error}" @@ -1434,6 +1496,48 @@ mod tests { } } + #[test] + fn link_count_failure_details_are_bounded_and_keep_the_fence() { + for (suffix, valid) in [ + ("1.1.2.I.11111110", true), + ("2.2.1.D.11111101", true), + ("1.0.18446744073709551615.I.11111111", true), + ("0.1.2.I.11111110", false), + ("3.1.2.I.11111110", false), + ("1.-1.2.I.11111110", false), + ("1.01.2.I.11111110", false), + ("1.1.18446744073709551616.I.11111110", false), + ("1.1.2.D.11111110", false), + ("1.1.1.I.11111110", false), + ("1.1.2.I.01111110", false), + ("1.1.2.I.111111100", false), + ("1.1.2.I.11111110.PRIVATE_CANARY", false), + ] { + let error = ResponseError { + code: format!("AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.LINK_COUNT.READ.{suffix}"), + message: "/private/token-canary".to_owned(), + retryable: false, + }; + let detail = response_error_module_read(&error); + assert_eq!(detail.is_some(), valid, "{suffix}"); + assert_eq!( + response_error_module_fence(&error), + if valid { Some("link_count") } else { None } + ); + assert_eq!( + response_error_classification(&error), + if valid { + "agent_startup_unverified_module" + } else { + "unclassified" + } + ); + if let Some(detail) = detail { + assert!(!detail.contains("private") && !detail.contains("CANARY")); + } + } + } + #[test] fn candidate_auth_diagnostics_use_only_closed_codes_and_never_provider_text() { for (code, expected) in [ diff --git a/packages/paperclip-runner/src/cli/acpx-sidecar-input.test.ts b/packages/paperclip-runner/src/cli/acpx-sidecar-input.test.ts index db636c99b75..9e9cf540b59 100644 --- a/packages/paperclip-runner/src/cli/acpx-sidecar-input.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-sidecar-input.test.ts @@ -1,3 +1,7 @@ +import { createRequire } from "node:module"; +import { readdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; import { @@ -246,3 +250,31 @@ describe("ACPX sidecar input sequencing", () => { ); }); }); + + +describe("closed link-count failure detail", () => { + const classify = (detail: string) => acpxSidecarErrorCode(Object.assign(new Error("private-canary"), { + detailCode: "AGENT_STARTUP_FAILED", stderrSummary: `PAPERCLIP_ACPX_IMPORT_FAILURE:UNVERIFIED_MODULE:LINK_COUNT:END PAPERCLIP_ACPX_LINK_COUNT:${detail}:END private-canary /secret`, + })); + it.each(["1:1:2:I:11111110", "2:2:1:D:11111101", "1:0:18446744073709551615:I:11111111"])("preserves bounded metadata %s", detail => { + expect(classify(detail)).toBe(`AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.LINK_COUNT.READ.${detail.replaceAll(":", ".")}`); + }); + it.each(["0:1:2:I:11111110", "3:1:2:I:11111110", "1:-1:2:I:11111110", "1:01:2:I:11111110", "1:1:18446744073709551616:I:11111110", "1:1:2:D:11111110", "1:1:1:I:11111110", "1:1:2:I:01111110", "1:1:2:I:111111100", "1:1:2:I:11111110.PRIVATE", "1:1:2:I:11111110:END_PRIVATE"])("drops malformed metadata without dropping fence: %s", detail => { + expect(classify(detail)).toBe("AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.LINK_COUNT"); + }); + it("survives the installed ACPX summarizer without leaking or accepting truncated details", async () => { + const directory = dirname(createRequire(import.meta.url).resolve("acpx/runtime")); + const chunk = (await readdir(directory)).find(name => /^live-checkpoint-.*\.js$/.test(name)); + expect(chunk).toBeDefined(); + const exports = await import(pathToFileURL(join(directory, chunk!)).href); + const client = Object.values(exports).find(value => typeof value === "function" && typeof value.prototype?.summarizeStartupStderr === "function") as { prototype: { summarizeStartupStderr: (parts: string[]) => string } }; + const base = "PAPERCLIP_ACPX_IMPORT_FAILURE:UNVERIFIED_MODULE:LINK_COUNT:END"; + const detail = "PAPERCLIP_ACPX_LINK_COUNT:1:2:1:D:11111110:END"; + for (const complete of [true, false]) { + const summary = client.prototype.summarizeStartupStderr([`${base}\n${complete ? detail : detail.slice(0, -1)}\nprivate-canary /secret`]); + const code = acpxSidecarErrorCode(Object.assign(new Error("private-canary"), { detailCode: "AGENT_STARTUP_FAILED", stderrSummary: summary })); + expect(code).toBe(`AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.LINK_COUNT${complete ? ".READ.1.2.1.D.11111110" : ""}`); + expect(code).not.toMatch(/private|secret/); + } + }); +}); diff --git a/packages/paperclip-runner/src/cli/acpx-sidecar-input.ts b/packages/paperclip-runner/src/cli/acpx-sidecar-input.ts index 7243d383304..a3545378340 100644 --- a/packages/paperclip-runner/src/cli/acpx-sidecar-input.ts +++ b/packages/paperclip-runner/src/cli/acpx-sidecar-input.ts @@ -97,7 +97,8 @@ function directAcpxSidecarErrorCode(error: Error): string | null { ?? /(?:^|\n)PAPERCLIP_ACPX_IMPORT_FAILURE:(UNVERIFIED_MODULE|OTHER)(?::([A-Z_]+))?(?:\r?\n|$)/.exec(stderr); if (importFailure?.[1] === "UNVERIFIED_MODULE" && importFailure[2] === undefined) return "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE"; if (importFailure?.[1] === "UNVERIFIED_MODULE" && MODULE_FENCES.has(importFailure[2])) { - return `AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.${importFailure[2]}`; + const detail = importFailure[2] === "LINK_COUNT" ? moduleLinkCountDetail(stderr) : null; + return `AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.${importFailure[2]}${detail ? `.READ.${detail.replaceAll(":", ".")}` : ""}`; } if (/ERR_ACPX_UNVERIFIED_MODULE/.test(stderr)) { return "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE"; @@ -181,3 +182,15 @@ const STABLE_ACPX_SIDECAR_CODES = new Set([ "ACPX_TOOL_CALL_STALE", ...GENERIC_ACPX_OUTPUT_CODES, ]); + + +/** Failure-only unsigned metadata; no provider strings or identifiers cross. */ +function moduleLinkCountDetail(stderr: string): string | null { + const match = /(?:^|\s)PAPERCLIP_ACPX_LINK_COUNT:([12]):(0|[1-9][0-9]{0,19}):(0|[1-9][0-9]{0,19}):([ID]):(111111[01]{2}):END(?=\s|$)/.exec(stderr); + if (!match) return null; + const before = BigInt(match[2]!); + const after = BigInt(match[3]!); + if (before > 18446744073709551615n || after > 18446744073709551615n || before === after + || match[4] !== (after > before ? "I" : "D")) return null; + return match.slice(1).join(":"); +} diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts index e562d591568..29e8fe26e8a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts @@ -1766,6 +1766,10 @@ describe("ACPX installation integrity", () => { if (reasons[transition]) { pass = 0; expect(read).toThrow(expect.objectContaining({ code: "ERR_ACPX_UNVERIFIED_MODULE", moduleFence: reasons[transition] })); + if (transition === "nlink") { + pass = 0; + expect(read).toThrow(expect.objectContaining({ moduleReadDiagnostic: "1:1:2:I:11111110" })); + } } expect(pass).toBeLessThanOrEqual(2); if (transition === "snapshot_second") expect(verify).toHaveBeenCalledTimes(2); @@ -1774,7 +1778,19 @@ describe("ACPX installation integrity", () => { ); }); - it.runIf(process.platform !== "win32").each(["once", "recurring", "bytes", "mode", "rename", "replacement", "symlink"] as const)("loads dependency bytes only after stable ctime-only continuity (%s)", async (transition) => { + it("reports the second read attempt without accepting link drift", () => { + const bytes = Buffer.from("stable bytes"); + const before = { dev: 1n, ino: 2n, mode: 0o100444n, uid: 1n, gid: 1n, nlink: 1n, + size: BigInt(bytes.length), mtimeNs: 10n, ctimeNs: 20n, isFile: () => true }; + let passes = 0; + expect(() => readStableSnapshotModuleSource({ before, maxBytes: 64, verify: () => {}, + read: (buffer, offset, length, position) => { if (position === 0) passes++; return bytes.copy(buffer, offset, position, position + length); }, + stat: () => ({ ...before, ctimeNs: 21n, nlink: passes === 2 ? 0n : 1n }), + })).toThrow(expect.objectContaining({ code: "ERR_ACPX_UNVERIFIED_MODULE", moduleFence: "LINK_COUNT", moduleReadDiagnostic: "2:1:0:D:11111111" })); + expect(passes).toBe(2); + }); + + it.runIf(process.platform !== "win32").each(["once", "recurring", "bytes", "mode", "rename", "replacement", "symlink", "link"] as const)("loads dependency bytes only after stable ctime-only continuity (%s)", async (transition) => { const fixture = await installationFixture(); await writeFile(join(fixture.commandDirectory, "dependency.mjs"), 'export const value = "dependency-success";'); const command = `import fs from 'node:fs'; @@ -1785,6 +1801,7 @@ describe("ACPX installation integrity", () => { if (buffer.subarray(offset, offset + count).includes(Buffer.from('dependency-success'))) { moduleFd = fd; passes += 1; if (${JSON.stringify(transition)} === 'bytes' && passes === 2) buffer[offset] = 0; + if (process.platform === 'linux' && passes === 1 && ${JSON.stringify(transition)} === 'link') fs.linkSync(new URL('./dependency.mjs', import.meta.url), new URL('./dependency-link.mjs', import.meta.url)); if (passes === 1 && ['rename','replacement','symlink'].includes(${JSON.stringify(transition)})) { const named = new URL('./dependency.mjs', import.meta.url); fs.renameSync(named, new URL('./moved.mjs', import.meta.url)); @@ -1799,6 +1816,7 @@ describe("ACPX installation integrity", () => { if (fd === moduleFd && options?.bigint) { metadata.ctimeNs += ${JSON.stringify(transition)} === 'recurring' ? BigInt(passes) : 1n; if (${JSON.stringify(transition)} === 'mode') metadata.mode ^= 0o100n; + if (process.platform !== 'linux' && ${JSON.stringify(transition)} === 'link') metadata.nlink += 1n; } return metadata; }; @@ -1821,7 +1839,12 @@ describe("ACPX installation integrity", () => { const [code] = await closed; expect(code).toBe(transition === "once" ? 0 : 1); expect(stdout).toBe(transition === "once" ? "dependency-success" : ""); - const fences: Record = { recurring: "REPEATED_CTIME", bytes: "BYTE_CONTINUITY", mode: "MODE" }; + const fences: Record = { recurring: "REPEATED_CTIME", bytes: "BYTE_CONTINUITY", mode: "MODE", link: "LINK_COUNT" }; + if (transition === "link") { + expect(stderr).toContain("PAPERCLIP_ACPX_LINK_COUNT:1:1:2:I:11111110:END"); + expect(acpxSidecarErrorCode(Object.assign(new Error("private-canary"), { detailCode: "AGENT_STARTUP_FAILED", stderrSummary: stderr.replace(/\s+/gu, " ") }))) + .toBe("AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.LINK_COUNT.READ.1.1.2.I.11111110"); + } const fence = fences[transition]; if (fence && !(transition === "bytes" && process.platform === "darwin")) { expect(stderr).toContain(`PAPERCLIP_ACPX_IMPORT_FAILURE:UNVERIFIED_MODULE:${fence}:END\n`); diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index b1a3a2f2285..db39129ef66 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -1870,9 +1870,9 @@ export function readStableSnapshotModuleSource(input: { verify: (bytes: Buffer) => void; maxBytes: number; }): Buffer { - const reject = (moduleFence: string): never => { + const reject = (moduleFence: string, moduleReadDiagnostic?: string): never => { throw Object.assign(new Error("ACPX provider module changed while it was read"), { - code: "ERR_ACPX_UNVERIFIED_MODULE", moduleFence, + code: "ERR_ACPX_UNVERIFIED_MODULE", moduleFence, ...(moduleReadDiagnostic ? { moduleReadDiagnostic } : {}), }); }; let before = input.before; @@ -1898,7 +1898,17 @@ export function readStableSnapshotModuleSource(input: { if (before.mode !== after.mode) reject("MODE"); if (before.uid !== after.uid) reject("UID"); if (before.gid !== after.gid) reject("GID"); - if (before.nlink !== after.nlink) reject("LINK_COUNT"); + if (before.nlink !== after.nlink) { + // Failure-only data: never admit a link topology change as hydration. + const bounded = before.nlink >= 0n && after.nlink >= 0n + && before.nlink <= 18446744073709551615n && after.nlink <= 18446744073709551615n; + const equal = [before.dev === after.dev, before.ino === after.ino, before.mode === after.mode, + before.uid === after.uid, before.gid === after.gid, before.size === after.size, + before.mtimeNs === after.mtimeNs, before.ctimeNs === after.ctimeNs].map(value => value ? "1" : "0").join(""); + reject("LINK_COUNT", bounded + ? `${attempt + 1}:${before.nlink}:${after.nlink}:${after.nlink > before.nlink ? "I" : "D"}:${equal}` + : undefined); + } if (before.mtimeNs !== after.mtimeNs) reject("MTIME"); if (first !== null && !first.equals(bytes)) reject("BYTE_CONTINUITY"); if (before.ctimeNs === after.ctimeNs) return bytes; @@ -2060,7 +2070,7 @@ function snapshotBootstrap(format: AcpxCommandFormat, guarded = false): string { guarded ? "const exitOnImportFailure = reapOnGuardianLoss;" : "const exitOnImportFailure = process.exit.bind(process);", - 'import(target).catch((error) => { let category = "OTHER"; try { if (error?.code === "ERR_ACPX_UNVERIFIED_MODULE") { category = "UNVERIFIED_MODULE"; const fence = error.moduleFence; if (["RESOLUTION", "OPEN", "BOUNDS", "ANCESTRY", "FORMAT", "PATH_IDENTITY", "READ_RESULT", "SIZE", "DEVICE", "INODE", "MODE", "UID", "GID", "LINK_COUNT", "MTIME", "BYTE_CONTINUITY", "REPEATED_CTIME"].includes(fence)) category += ":" + fence; } } catch {} try { writeImportFailure(2, "PAPERCLIP_ACPX_IMPORT_FAILURE:" + category + ":END\\n"); console.error(error); } finally { exitOnImportFailure(1); } });', + 'import(target).catch((error) => { let category = "OTHER"; try { if (error?.code === "ERR_ACPX_UNVERIFIED_MODULE") { category = "UNVERIFIED_MODULE"; const fence = error.moduleFence; if (["RESOLUTION", "OPEN", "BOUNDS", "ANCESTRY", "FORMAT", "PATH_IDENTITY", "READ_RESULT", "SIZE", "DEVICE", "INODE", "MODE", "UID", "GID", "LINK_COUNT", "MTIME", "BYTE_CONTINUITY", "REPEATED_CTIME"].includes(fence)) category += ":" + fence; } } catch {} try { writeImportFailure(2, "PAPERCLIP_ACPX_IMPORT_FAILURE:" + category + ":END\\n"); const detail = error?.moduleReadDiagnostic; if (category === "UNVERIFIED_MODULE:LINK_COUNT" && typeof detail === "string" && /^[12]:(0|[1-9][0-9]{0,19}):(0|[1-9][0-9]{0,19}):[ID]:111111[01]{2}$/.test(detail)) writeImportFailure(2, "PAPERCLIP_ACPX_LINK_COUNT:" + detail + ":END\\n"); console.error(error); } finally { exitOnImportFailure(1); } });', ].join(""); }