mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
Reverify qualified runtime after ctime-only hydration
This commit is contained in:
1 parent
d0f7f6f57c
commit
6c2a0a2bd8
2 files changed
+134
-50
No files matched your search
@@ -8,6 +8,8 @@ import {
|
||||
link,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
open,
|
||||
type FileHandle,
|
||||
readFile,
|
||||
realpath,
|
||||
rename,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
symlink,
|
||||
stat,
|
||||
writeFile,
|
||||
utimes,
|
||||
} from "node:fs/promises";
|
||||
import { createServer, type Server } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -25,6 +28,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js";
|
||||
import {
|
||||
verifyProvisionedGrokExecutable,
|
||||
openVerifiedRuntimeExecutable,
|
||||
builtinGrokLauncherPath,
|
||||
awaitVerifiedAcpxProviderExit,
|
||||
awaitVerifiedAcpxProviderOwnership,
|
||||
@@ -54,6 +58,61 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("ACPX installation integrity", () => {
|
||||
it.each(["once", "every_read", "wrong_digest", "mode", "mtime", "replacement", "symlink", "second_content"] as const)("requires stable full re-verification after a ctime-only transition (%s)", async (transition) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "paperclip-runtime-hydration-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const executable = join(directory, "runtime");
|
||||
const bytes = Buffer.from("qualified runtime fixture");
|
||||
await writeFile(executable, bytes, { mode: 0o755 });
|
||||
const digest = `sha256:${createHash("sha256").update(transition === "wrong_digest" ? "wrong" : bytes).digest("hex")}`;
|
||||
const probe = await open(executable, "r");
|
||||
const prototype = Object.getPrototypeOf(probe) as FileHandle;
|
||||
const originalRead = probe.read;
|
||||
await probe.close();
|
||||
let reads = 0;
|
||||
const readSpy = vi.spyOn(prototype, "read").mockImplementation(async function (this: FileHandle, ...args: Parameters<FileHandle["read"]>) {
|
||||
if (reads === 1 && transition === "second_content") {
|
||||
await writeFile(executable, Buffer.alloc(bytes.length, 120));
|
||||
}
|
||||
const result = await Reflect.apply(originalRead, this, args);
|
||||
reads += 1;
|
||||
if (reads === 1 || transition === "every_read") {
|
||||
const before = await stat(executable, { bigint: true });
|
||||
// chmod to the existing mode models Boat hydration: ctime alone changes.
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await chmod(executable, 0o755);
|
||||
if ((await stat(executable, { bigint: true })).ctimeNs !== before.ctimeNs) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
const after = await stat(executable, { bigint: true });
|
||||
expect(after.ctimeNs).not.toBe(before.ctimeNs);
|
||||
expect([after.dev, after.ino, after.size, after.mode, after.mtimeNs]).toEqual([before.dev, before.ino, before.size, before.mode, before.mtimeNs]);
|
||||
if (transition === "mode") await chmod(executable, 0o700);
|
||||
if (transition === "mtime") await utimes(executable, new Date(0), new Date(0));
|
||||
if (transition === "replacement" || transition === "symlink") {
|
||||
const moved = `${executable}.original`;
|
||||
await rename(executable, moved);
|
||||
if (transition === "symlink") await symlink(moved, executable);
|
||||
else await writeFile(executable, bytes, { mode: 0o755 });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
try {
|
||||
if (transition === "once") {
|
||||
const verified = await openVerifiedRuntimeExecutable(executable, digest, "fixture");
|
||||
try {
|
||||
const stable = await verified.handle.stat({ bigint: true });
|
||||
expect(verified.identity.changedNanoseconds).toBe(stable.ctimeNs.toString());
|
||||
expect(verified.identity.modifiedNanoseconds).toBe(stable.mtimeNs.toString());
|
||||
} finally { await verified.handle.close(); }
|
||||
} else {
|
||||
await expect(openVerifiedRuntimeExecutable(executable, digest, "fixture")).rejects.toThrow(transition === "wrong_digest" ? "digest mismatch" : "changed while it was verified");
|
||||
}
|
||||
expect(reads).toBe(["once", "every_read", "second_content"].includes(transition) ? 2 : 1);
|
||||
} finally { readSpy.mockRestore(); }
|
||||
});
|
||||
|
||||
it.each([false, true])("admits only the declared qualified Codex platform in its npm-hoisted slot (published platform declaration: %s)", async (publishedDeclaration) => {
|
||||
const parent = await realpath(await mkdtemp(join(tmpdir(), "paperclip-codex-npm-hoist-")));
|
||||
temporaryDirectories.push(parent);
|
||||
|
||||
@@ -1166,7 +1166,7 @@ async function inspectCommand(
|
||||
}
|
||||
}
|
||||
|
||||
async function openVerifiedRuntimeExecutable(
|
||||
export async function openVerifiedRuntimeExecutable(
|
||||
executablePath: string,
|
||||
expectedDigest: string,
|
||||
agent: string,
|
||||
@@ -1197,59 +1197,84 @@ async function openVerifiedRuntimeExecutable(
|
||||
}
|
||||
|
||||
try {
|
||||
const before = await handle.stat({ bigint: true });
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.size < 1n ||
|
||||
before.size > BigInt(MAX_ACPX_RUNTIME_EXECUTABLE_BYTES) ||
|
||||
(before.mode & 0o111n) === 0n
|
||||
) {
|
||||
throw new Error(
|
||||
`ACPX ${agent} runtime executable must be a bounded executable file`,
|
||||
);
|
||||
}
|
||||
const hash = createHash("sha256");
|
||||
const buffer = Buffer.alloc(1024 * 1024);
|
||||
let position = 0;
|
||||
try {
|
||||
while (position < Number(before.size)) {
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
0,
|
||||
Math.min(buffer.length, Number(before.size) - position),
|
||||
position,
|
||||
let lexicalBaseline = lexicalBefore;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const before = await handle.stat({ bigint: true });
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.size < 1n ||
|
||||
before.size > BigInt(MAX_ACPX_RUNTIME_EXECUTABLE_BYTES) ||
|
||||
(before.mode & 0o111n) === 0n
|
||||
) {
|
||||
throw new Error(
|
||||
`ACPX ${agent} runtime executable must be a bounded executable file`,
|
||||
);
|
||||
if (bytesRead === 0) break;
|
||||
hash.update(buffer.subarray(0, bytesRead));
|
||||
position += bytesRead;
|
||||
}
|
||||
} finally {
|
||||
buffer.fill(0);
|
||||
}
|
||||
const after = await handle.stat({ bigint: true });
|
||||
const lexicalAfter = await lstat(executablePath, { bigint: true }).catch(
|
||||
() => null,
|
||||
);
|
||||
const beforeIdentity = fileIdentity(before);
|
||||
const afterIdentity = fileIdentity(after);
|
||||
if (
|
||||
position !== Number(before.size) ||
|
||||
lexicalAfter === null ||
|
||||
lexicalAfter.isSymbolicLink() ||
|
||||
!lexicalAfter.isFile() ||
|
||||
!sameIdentity(fileIdentity(lexicalBefore), fileIdentity(lexicalAfter)) ||
|
||||
!sameIdentity(fileIdentity(lexicalAfter), afterIdentity) ||
|
||||
!sameIdentity(beforeIdentity, afterIdentity)
|
||||
) {
|
||||
throw new Error(
|
||||
`ACPX ${agent} runtime executable changed while it was verified`,
|
||||
const beforeIdentity = fileIdentity(before);
|
||||
if (
|
||||
!sameIdentity(fileIdentity(lexicalBaseline), beforeIdentity) ||
|
||||
lexicalBaseline.mode !== before.mode
|
||||
) {
|
||||
throw new Error(
|
||||
`ACPX ${agent} runtime executable changed while it was verified`,
|
||||
);
|
||||
}
|
||||
const hash = createHash("sha256");
|
||||
const buffer = Buffer.alloc(1024 * 1024);
|
||||
let position = 0;
|
||||
try {
|
||||
while (position < Number(before.size)) {
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
0,
|
||||
Math.min(buffer.length, Number(before.size) - position),
|
||||
position,
|
||||
);
|
||||
if (bytesRead === 0) break;
|
||||
hash.update(buffer.subarray(0, bytesRead));
|
||||
position += bytesRead;
|
||||
}
|
||||
} finally {
|
||||
buffer.fill(0);
|
||||
}
|
||||
const after = await handle.stat({ bigint: true });
|
||||
const lexicalAfter = await lstat(executablePath, { bigint: true }).catch(
|
||||
() => null,
|
||||
);
|
||||
const afterIdentity = fileIdentity(after);
|
||||
if (
|
||||
position !== Number(before.size) ||
|
||||
lexicalAfter === null ||
|
||||
lexicalAfter.isSymbolicLink() ||
|
||||
!lexicalAfter.isFile() ||
|
||||
!sameIdentity(fileIdentity(lexicalAfter), afterIdentity) ||
|
||||
lexicalAfter.mode !== after.mode ||
|
||||
before.mode !== after.mode ||
|
||||
!sameIdentity(
|
||||
{ ...beforeIdentity, changedNanoseconds: afterIdentity.changedNanoseconds },
|
||||
afterIdentity,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`ACPX ${agent} runtime executable changed while it was verified`,
|
||||
);
|
||||
}
|
||||
const digest = `sha256:${hash.digest("hex")}`;
|
||||
if (digest !== expectedDigest) {
|
||||
throw new Error(`ACPX ${agent} runtime executable digest mismatch`);
|
||||
}
|
||||
if (sameIdentity(beforeIdentity, afterIdentity)) {
|
||||
return { handle, identity: afterIdentity };
|
||||
}
|
||||
// A lazy filesystem restore can change ctime on the first read. Only
|
||||
// already-qualified bytes with otherwise identical metadata may receive
|
||||
// one complete re-read on this same descriptor. The final pass must be
|
||||
// fully stable; later launch identity comparisons remain strict.
|
||||
lexicalBaseline = lexicalAfter;
|
||||
}
|
||||
const digest = `sha256:${hash.digest("hex")}`;
|
||||
if (digest !== expectedDigest) {
|
||||
throw new Error(`ACPX ${agent} runtime executable digest mismatch`);
|
||||
}
|
||||
return { handle, identity: afterIdentity };
|
||||
throw new Error(
|
||||
`ACPX ${agent} runtime executable changed while it was verified`,
|
||||
);
|
||||
} catch (error) {
|
||||
await handle.close();
|
||||
throw error;
|
||||
|
||||
Reference in new issue
Block a user