mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
Reconcile sandbox runtime hardening with current recovery
Combine task-scoped workspace adoption with chat isolation, bounded shutdown draining, and provider identity handling. Preserve per-run capability rotation evidence and the new master warm-session transition coverage. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
cd008e3444
1324 files changed
+1488084
-52191
No files matched your search
@@ -222,6 +222,7 @@ test("the trusted PR workflow limits full CI to merge-relevant stack layers", ()
|
||||
for (const jobId of [
|
||||
"typecheck_release_registry",
|
||||
"general_tests",
|
||||
"verify_paperclip_runner",
|
||||
"build",
|
||||
"verify_serialized_server",
|
||||
"canary_dry_run",
|
||||
@@ -243,11 +244,16 @@ test("the trusted PR workflow limits full CI to merge-relevant stack layers", ()
|
||||
const verify = jobs.get("verify");
|
||||
assert.match(
|
||||
verify,
|
||||
/^ {4}needs: \[gate, policy, typecheck_release_registry, general_tests, build, docker_context_integrity\]$/m,
|
||||
/^ {4}needs: \[gate, policy, typecheck_release_registry, general_tests, verify_paperclip_runner, build, docker_context_integrity\]$/m,
|
||||
);
|
||||
assert.match(verify, /POLICY_RESULT: \$\{\{ needs\.policy\.result \}\}/);
|
||||
assert.match(verify, /test "\$TYPECHECK_RELEASE_REGISTRY_RESULT" = "skipped"/);
|
||||
assert.match(verify, /test "\$GENERAL_TESTS_RESULT" = "skipped"/);
|
||||
// Runner verification must participate in the legacy aggregate required
|
||||
// check, or a runner regression could be merged while `verify` succeeds.
|
||||
assert.match(verify, /RUNNER_VERIFICATION_RESULT: \$\{\{ needs\.verify_paperclip_runner\.result \}\}/);
|
||||
assert.match(verify, /test "\$RUNNER_VERIFICATION_RESULT" = "success"/);
|
||||
assert.match(verify, /test "\$RUNNER_VERIFICATION_RESULT" = "skipped"/);
|
||||
assert.match(verify, /test "\$BUILD_RESULT" = "skipped"/);
|
||||
// Both halves of the docker-context lane's gating: the result must be
|
||||
// wired into the aggregate's env AND asserted successful on full CI —
|
||||
@@ -327,7 +333,7 @@ test("the trusted PR workflow regenerates stale stacked lockfiles", () => {
|
||||
const restoreSteps = workflow.match(
|
||||
/- name: Restore regenerated PR lockfile \(if policy uploaded one\)\n if: needs\.policy\.outputs\.lockfile_regenerated == '1'/g,
|
||||
) ?? [];
|
||||
assert.equal(restoreSteps.length, 6, "every downstream install job must restore a required regenerated artifact");
|
||||
assert.equal(restoreSteps.length, 7, "every downstream install job must restore a required regenerated artifact");
|
||||
assert.doesNotMatch(
|
||||
workflow,
|
||||
/- name: Restore regenerated PR lockfile \(if policy uploaded one\)[\s\S]{0,220}continue-on-error:/,
|
||||
|
||||
@@ -16,7 +16,7 @@ const testPath = [path.dirname(process.execPath), "/usr/bin", "/bin"].join(":");
|
||||
const cleanupDirs = [];
|
||||
|
||||
function makeTempDir(prefix) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
|
||||
cleanupDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
@@ -102,8 +102,9 @@ process.exit(0);
|
||||
return baseCwd;
|
||||
}
|
||||
|
||||
function runProvision(baseCwd, { pathPrefix } = {}) {
|
||||
const worktreeCwd = makeTempDir("paperclip-provision-worktree-");
|
||||
function runProvision(baseCwd, { pathPrefix, setupWorktree, existingWorktree } = {}) {
|
||||
const worktreeCwd = existingWorktree ?? makeTempDir("paperclip-provision-worktree-");
|
||||
setupWorktree?.(worktreeCwd);
|
||||
const worktreesHome = makeTempDir("paperclip-provision-home-");
|
||||
const paperclipHome = makeInstanceHome();
|
||||
const result = spawnSync("bash", [script], {
|
||||
@@ -524,3 +525,53 @@ test("every pnpm install call site silences DEP0169 without overwriting NODE_OPT
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const failure of ["ERR_PNPM_LOCKFILE_CONFIG_MISMATCH", "ERR_PNPM_OUTDATED_LOCKFILE", "ENOTFOUND", "retry-fails"]) {
|
||||
test(`dependency provisioning preserves failures and bounds recovery: ${failure}`, () => {
|
||||
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 });
|
||||
const bin = makeTempDir("paperclip-fake-pnpm-");
|
||||
fs.writeFileSync(path.join(bin, "pnpm"), `#!/bin/sh
|
||||
printf '%s\\n' "$*" >> pnpm-calls
|
||||
case "$*" in
|
||||
*--frozen-lockfile*) echo '${failure === "retry-fails" ? "ERR_PNPM_LOCKFILE_CONFIG_MISMATCH" : failure}' >&2; exit 42 ;;
|
||||
*) ${failure === "retry-fails" ? "exit 43" : "mkdir -p node_modules; exit 0"} ;;
|
||||
esac
|
||||
`, { mode: 0o700 });
|
||||
const { result, worktreeCwd } = runProvision(baseCwd, {
|
||||
pathPrefix: bin,
|
||||
setupWorktree(root) {
|
||||
fs.writeFileSync(path.join(root, "package.json"), "{}\n");
|
||||
fs.writeFileSync(path.join(root, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
|
||||
},
|
||||
});
|
||||
const recovers = failure.startsWith("ERR_PNPM_");
|
||||
assert.equal(result.status, recovers ? 0 : failure === "ENOTFOUND" ? 42 : 1, result.stderr);
|
||||
assert.equal(fs.existsSync(path.join(worktreeCwd, ".paperclip/pnpm-install-fingerprint")), recovers);
|
||||
const calls = fs.readFileSync(path.join(worktreeCwd, "pnpm-calls"), "utf8").trim().split("\n").filter((call) => call.startsWith("install "));
|
||||
assert.equal(calls.length, failure === "ENOTFOUND" ? 1 : 2);
|
||||
if (calls.length === 2) assert.match(calls[1], /--no-frozen-lockfile/);
|
||||
});
|
||||
}
|
||||
|
||||
test("patch content changes invalidate an otherwise matching install fingerprint", () => {
|
||||
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 });
|
||||
const bin = makeTempDir("paperclip-patch-pnpm-");
|
||||
fs.writeFileSync(path.join(bin, "pnpm"), '#!/bin/sh\ncase "$1" in install) echo install >> pnpm-calls; mkdir -p node_modules cli/node_modules ;; esac\n', { mode: 0o700 });
|
||||
const first = runProvision(baseCwd, { pathPrefix: bin, setupWorktree(root) {
|
||||
fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ pnpm: { patchedDependencies: { "dependency@1": "patches/dependency.diff" } } }));
|
||||
fs.writeFileSync(path.join(root, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
|
||||
fs.mkdirSync(path.join(root, "patches"));
|
||||
fs.writeFileSync(path.join(root, "patches/dependency.diff"), "first patch");
|
||||
} });
|
||||
assert.equal(first.result.status, 0, first.result.stderr);
|
||||
const options = { pathPrefix: bin, existingWorktree: first.worktreeCwd };
|
||||
assert.equal(runProvision(baseCwd, options).result.status, 0);
|
||||
const callsPath = path.join(first.worktreeCwd, "pnpm-calls");
|
||||
assert.equal(fs.readFileSync(callsPath, "utf8"), "install\n");
|
||||
fs.writeFileSync(path.join(first.worktreeCwd, "unrelated.patch"), "unrelated change");
|
||||
assert.equal(runProvision(baseCwd, options).result.status, 0);
|
||||
assert.equal(fs.readFileSync(callsPath, "utf8"), "install\n");
|
||||
fs.writeFileSync(path.join(first.worktreeCwd, "patches/dependency.diff"), "changed patch");
|
||||
assert.equal(runProvision(baseCwd, options).result.status, 0);
|
||||
assert.equal(fs.readFileSync(callsPath, "utf8"), "install\ninstall\n");
|
||||
});
|
||||
@@ -14,13 +14,37 @@ function readWorkflow(name) {
|
||||
return readFileSync(path.join(repoRoot, ".github/workflows", name), "utf8");
|
||||
}
|
||||
|
||||
test("release workflow delegates stable and canary verification to the reusable workflow", () => {
|
||||
const releaseWorkflow = readWorkflow("release.yml");
|
||||
test("chaos verification isolates callers that verify the same source commit", () => {
|
||||
const chaosWorkflow = readWorkflow("runner-chaos-evals.yml");
|
||||
const group = chaosWorkflow.match(/^ group: (.+)$/m)?.[1];
|
||||
assert.ok(group, "chaos verification must define its concurrency group");
|
||||
|
||||
assert.match(
|
||||
releaseWorkflow,
|
||||
/verify_canary:\n\s+if: github\.event_name == 'push'\n\s+uses: \.\/\.github\/workflows\/release-verify\.yml\n\s+with:\n\s+ref: \$\{\{ github\.sha \}\}/,
|
||||
);
|
||||
// GitHub supplies the top-level caller's workflow name to reusable calls.
|
||||
const resolveGroup = (caller, ref) => group
|
||||
.replaceAll("${{ github.workflow }}", readWorkflow(caller).match(/^name: (.+)$/m)[1])
|
||||
.replaceAll("${{ inputs.ref || github.ref }}", ref)
|
||||
.toLowerCase();
|
||||
const sha = "a".repeat(40);
|
||||
const callers = ["cloud-readiness.yml", "release.yml", "runner-chaos-evals.yml"];
|
||||
const groups = callers.map((caller) => resolveGroup(caller, sha));
|
||||
assert.equal(new Set(groups).size, callers.length,
|
||||
"Cloud readiness, Release, and standalone evals must not cancel each other");
|
||||
assert.ok(groups.every((value) => !value.includes("${{")), "resolve every group input");
|
||||
assert.notEqual(resolveGroup("cloud-readiness.yml", sha),
|
||||
resolveGroup("cloud-readiness.yml", "b".repeat(40)), "different sources remain independent");
|
||||
assert.match(chaosWorkflow, /cancel-in-progress: true/);
|
||||
});
|
||||
|
||||
test("canary reuses exact-source proof while stable keeps full verification", () => {
|
||||
const releaseWorkflow = readWorkflow("release.yml");
|
||||
const canary = releaseWorkflow.split(" verify_canary:\n")[1].split("\n publish_canary:")[0];
|
||||
assert.match(canary, /github\.repository == 'paperclipai\/paperclip' && github\.event_name == 'push' && github\.ref == 'refs\/heads\/master'/);
|
||||
assert.match(canary, /actions: read/);
|
||||
assert.match(canary, /ref: \$\{\{ github\.sha \}\}/);
|
||||
assert.match(canary, /SOURCE_SHA: \$\{\{ github\.sha \}\}/);
|
||||
assert.match(canary, /run: node scripts\/cloud-source-verification\.mjs "\$SOURCE_SHA"/);
|
||||
assert.doesNotMatch(canary, /release-verify\.yml|continue-on-error|always\(\)/);
|
||||
assert.match(releaseWorkflow, /publish_canary:\n\s+if: github\.event_name == 'push'\n\s+needs: verify_canary/);
|
||||
// The stable lane is gated on the stable channel since the nightly lane
|
||||
// was added; a `needs:` line (for example a preflight job) may sit between
|
||||
// the gate and the delegation.
|
||||
@@ -36,6 +60,17 @@ test("release workflow delegates stable and canary verification to the reusable
|
||||
);
|
||||
});
|
||||
|
||||
test("source proof requires every source check and does not wait on image publication", () => {
|
||||
const readiness = readWorkflow("cloud-readiness.yml");
|
||||
const proof = readiness.split(" source_verified:\n")[1].split("\n ready:")[0];
|
||||
assert.match(proof, /name: Cloud source verified v1/);
|
||||
assert.match(proof, /needs: \[verify\]/);
|
||||
assert.match(proof, /node --test scripts\/cloud-source-verification.test.mjs/);
|
||||
assert.match(proof, /SOURCE_SHA: \$\{\{ github\.sha \}\}/);
|
||||
assert.doesNotMatch(proof, /always\(\)|continue-on-error|needs:.*(?:image|artifacts)/);
|
||||
assert.match(readiness.split(" ready:\n")[1], /needs: \[verify, image, artifacts\]/);
|
||||
});
|
||||
|
||||
test("onboard smoke container binds beyond loopback so the mapped port is reachable", () => {
|
||||
const dockerfile = readFileSync(
|
||||
path.join(repoRoot, "docker/Dockerfile.onboard-smoke"),
|
||||
@@ -202,28 +237,20 @@ test("release verify workflow covers the same split test surface as stable PR ve
|
||||
);
|
||||
assert.match(verifyWorkflow, /pnpm test:runner-workflow-evals/);
|
||||
|
||||
for (const group of [
|
||||
"general-server",
|
||||
"general-workspaces-a",
|
||||
"general-workspaces-b",
|
||||
]) {
|
||||
const buildJob = verifyWorkflow.match(/ build:\n[\s\S]*?(?=\n [A-Za-z0-9_-]+:|$)/)?.[0] ?? "";
|
||||
assert.match(buildJob, /persist-credentials: false/);
|
||||
assert.doesNotMatch(buildJob, /cache: pnpm/);
|
||||
|
||||
for (const group of ["general-server-without-chat", "general-chat", "general-workspaces-a", "general-workspaces-b"]) {
|
||||
assert.match(verifyWorkflow, new RegExp(`group: ${group}`));
|
||||
}
|
||||
|
||||
for (const shardIndex of [0, 1, 2]) {
|
||||
assert.match(
|
||||
verifyWorkflow,
|
||||
new RegExp(
|
||||
`group: general-server[\\s\\S]*?shard_index: ${shardIndex}[\\s\\S]*?shard_count: 3`,
|
||||
),
|
||||
);
|
||||
for (const [group, count] of [["general-server-without-chat", 10], ["general-chat", 3]]) {
|
||||
const rows = [...verifyWorkflow.matchAll(new RegExp(`group: ${group}\\n\\s+group_label: [^\\n]+\\n\\s+shard_index: (\\d+)\\n\\s+shard_count: (\\d+)`, "g"))];
|
||||
assert.deepEqual(rows.map((row) => [Number(row[1]), Number(row[2])]),
|
||||
Array.from({ length: count }, (_, index) => [index, count]));
|
||||
}
|
||||
|
||||
for (const shardIndex of [0, 1, 2, 3, 4]) {
|
||||
assert.match(
|
||||
verifyWorkflow,
|
||||
new RegExp(`shard_index: ${shardIndex}[\\s\\S]*?shard_count: 5`),
|
||||
);
|
||||
assert.match(verifyWorkflow, new RegExp(`shard_index: ${shardIndex}[\\s\\S]*?shard_count: 5`));
|
||||
}
|
||||
|
||||
// workspaces-a splits with Vitest native --shard in pr.yml; release
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
partitionGeneralServerSuites,
|
||||
} from "../general-server-shard.mjs";
|
||||
|
||||
import { assertSelectedTests, partitionTestLines } from "../test-line-shard.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const script = path.join(repoRoot, "scripts", "run-vitest-stable.mjs");
|
||||
const durationsManifest = path.join(repoRoot, "scripts", "general-server-shard-durations.json");
|
||||
@@ -172,6 +174,46 @@ test("the checked-in manifest loads and covers most of the current suite set", (
|
||||
);
|
||||
});
|
||||
|
||||
test("the measured chat integration cohort does not share a general-server shard", () => {
|
||||
const chatSuite = "server/src/__tests__/chat-channels.integration.test.ts";
|
||||
const durations = loadShardDurations(durationsManifest);
|
||||
assert.ok(
|
||||
Number.isFinite(durations[chatSuite]),
|
||||
"the full chat cohort must have a measured duration, not the median fallback",
|
||||
);
|
||||
const unsharded = dryRunJson([
|
||||
"--mode",
|
||||
"general",
|
||||
"--group",
|
||||
"general-server",
|
||||
"--shard-index",
|
||||
"0",
|
||||
"--shard-count",
|
||||
"1",
|
||||
]);
|
||||
const shards = partitionGeneralServerSuites(
|
||||
unsharded.selectedGeneralServerSuites,
|
||||
SHARD_COUNT,
|
||||
durations,
|
||||
);
|
||||
const chatShards = shards.filter((shard) => shard.files.includes(chatSuite));
|
||||
assert.equal(
|
||||
chatShards.length,
|
||||
1,
|
||||
"the full chat cohort must run exactly once",
|
||||
);
|
||||
assert.deepEqual(
|
||||
chatShards[0].files,
|
||||
[chatSuite],
|
||||
"its measured cost must reserve one existing shard without other suites",
|
||||
);
|
||||
assert.deepEqual(
|
||||
shards.flatMap((shard) => shard.files).sort(),
|
||||
[...unsharded.selectedGeneralServerSuites].sort(),
|
||||
"duration balancing must not omit or duplicate any general-server suite",
|
||||
);
|
||||
});
|
||||
|
||||
test("the checked-in serialized manifest loads and covers most of the current suite set", () => {
|
||||
const durations = loadShardDurations(serializedDurationsManifest);
|
||||
assert.ok(Object.keys(durations).length > 0, "manifest must parse to a non-empty duration map");
|
||||
@@ -224,3 +266,53 @@ test("the real shard partition is duration-balanced", () => {
|
||||
`shard weight spread ${maxTotal - minTotal}ms exceeds heaviest suite ${heaviest}ms: ${totals.join(", ")}`,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test("release server shards plus the dedicated chat file cover the original server group exactly", () => {
|
||||
const full = dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", "0", "--shard-count", "1"]);
|
||||
const shards = Array.from({ length: 10 }, (_, index) => dryRunJson([
|
||||
"--mode", "general", "--group", "general-server-without-chat",
|
||||
"--shard-index", String(index), "--shard-count", "10",
|
||||
]));
|
||||
const files = shards.flatMap((shard) => shard.selectedGeneralServerSuites);
|
||||
const chat = "server/src/__tests__/chat-channels.integration.test.ts";
|
||||
assert.ok(!files.includes(chat));
|
||||
assert.deepEqual([...files, chat].sort(), full.selectedGeneralServerSuites.sort());
|
||||
assert.equal(new Set(files).size, files.length);
|
||||
const defaultRun = dryRunJson([]);
|
||||
assert.ok(defaultRun.generalServerSuiteCount === full.generalServerSuiteCount);
|
||||
});
|
||||
|
||||
const lineShardFile = path.join(repoRoot, "server/src/__tests__/chat-channels.integration.test.ts");
|
||||
const caseAt = (line, name) => ({ name, file: lineShardFile, projectName: "@paperclipai/server", location: { line, column: 3 } });
|
||||
|
||||
test("test-line shards cover nested and parameterized cases exactly once without splitting a source line", () => {
|
||||
const cases = [caseAt(10, "suite > nested > first"), caseAt(10, "suite > nested > second"),
|
||||
caseAt(20, "same name"), caseAt(30, "same name"), caseAt(40, "last"), caseAt(50, "new case")];
|
||||
const shards = partitionTestLines(cases, 3, lineShardFile);
|
||||
assert.deepEqual(shards.map((shard) => shard.tests.length), [2, 2, 2]);
|
||||
assert.equal(shards.filter((shard) => shard.lines.includes(10)).length, 1);
|
||||
assert.equal(shards.find((shard) => shard.lines.includes(10)).tests.length, 2);
|
||||
assert.equal(shards.flatMap((shard) => shard.lines).length, 5);
|
||||
assert.deepEqual(shards.flatMap((shard) => shard.tests).sort((a, b) => a.location.line - b.location.line), cases);
|
||||
assert.deepEqual(partitionTestLines([...cases].reverse(), 3, lineShardFile).map((shard) => shard.lines), shards.map((shard) => shard.lines));
|
||||
});
|
||||
|
||||
test("line-shard collection rejects empty, foreign, or unlocated tests and invalid shard counts", () => {
|
||||
const good = caseAt(10, "valid");
|
||||
for (const input of [[], null, [{ ...good, file: "/another.test.ts" }], [{ ...good, projectName: "wrong" }],
|
||||
[{ ...good, location: undefined }], [{ ...good, location: { line: 0 } }], [{ ...good, name: "" }]]) {
|
||||
assert.throws(() => partitionTestLines(input, 1, lineShardFile));
|
||||
}
|
||||
for (const count of [0, -1, 1.5, Infinity, 2]) assert.throws(() => partitionTestLines([good], count, lineShardFile));
|
||||
});
|
||||
|
||||
test("filtered collection must match the exact assigned case identities, including duplicates", () => {
|
||||
const expected = [caseAt(10, "same"), caseAt(10, "same"), caseAt(20, "nested > case")];
|
||||
assertSelectedTests(expected, [...expected].reverse(), lineShardFile);
|
||||
for (const actual of [expected.slice(1), [...expected, caseAt(30, "extra")],
|
||||
[expected[0], expected[1], caseAt(20, "renamed")],
|
||||
[expected[0], expected[1], caseAt(21, "nested > case")]]) {
|
||||
assert.throws(() => assertSelectedTests(expected, actual, lineShardFile));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import authorize from "../../.github/scripts/authorize-storybook-deploy.cjs";
|
||||
|
||||
const ownerFile = ".github/** @cryppadotta @devinfoley @nickyleach @forgottendev\n";
|
||||
function fixture(overrides = {}) {
|
||||
const calls = [];
|
||||
const context = {
|
||||
repo: { owner: "paperclipai", repo: "paperclip" },
|
||||
eventName: "workflow_dispatch",
|
||||
ref: "refs/heads/codex/example",
|
||||
actor: "cryppadotta",
|
||||
...overrides.context,
|
||||
};
|
||||
const environment = {
|
||||
can_admins_bypass: false,
|
||||
protection_rules: [{
|
||||
type: "required_reviewers",
|
||||
reviewers: [{ type: "User", reviewer: { login: "cryppadotta" } }],
|
||||
}],
|
||||
...overrides.environment,
|
||||
};
|
||||
const github = { rest: { repos: {
|
||||
get: async () => ({ data: { default_branch: "master" } }),
|
||||
getContent: async (params) => {
|
||||
calls.push(params);
|
||||
if (overrides.apiError) throw new Error("GitHub unavailable");
|
||||
return { data: { encoding: "base64", content: Buffer.from(overrides.codeowners ?? ownerFile).toString("base64") } };
|
||||
},
|
||||
getEnvironment: async () => ({ data: environment }),
|
||||
} } };
|
||||
return { github, context, calls };
|
||||
}
|
||||
|
||||
// Tests are serial because the Actions rerunner is an environment variable.
|
||||
process.env.GITHUB_TRIGGERING_ACTOR = "cryppadotta";
|
||||
test("allows each current CODEOWNER on a feature branch; reads policy from master", async () => {
|
||||
for (const actor of ["cryppadotta", "devinfoley", "nickyleach", "forgottendev"]) {
|
||||
const f = fixture({ context: { actor } });
|
||||
await authorize(f);
|
||||
assert.equal(f.calls[0].ref, "master");
|
||||
assert.equal(f.calls[0].path, ".github/CODEOWNERS");
|
||||
}
|
||||
});
|
||||
test("rejects non-owner initiators", async () => {
|
||||
await assert.rejects(authorize(fixture({ context: { actor: "contributor" } })), /Only default-branch CODEOWNERS/);
|
||||
});
|
||||
test("rejects non-owner and missing rerunners, including deployment-only reruns", async () => {
|
||||
for (const actor of ["contributor", ""]) {
|
||||
process.env.GITHUB_TRIGGERING_ACTOR = actor;
|
||||
await assert.rejects(authorize(fixture()), /Only default-branch CODEOWNERS/);
|
||||
}
|
||||
process.env.GITHUB_TRIGGERING_ACTOR = "cryppadotta";
|
||||
});
|
||||
test("comments, teams, emails and partial account matches do not grant access", async () => {
|
||||
for (const codeowners of [
|
||||
"# @cryppadotta\n.github/** @other",
|
||||
".github/** @other # @cryppadotta",
|
||||
".github/** @paperclipai/cryppadotta",
|
||||
".github/** cryppadotta@example.com",
|
||||
".github/** @cryppadotta-extra",
|
||||
"",
|
||||
]) await assert.rejects(authorize(fixture({ codeowners })), /CODEOWNERS/);
|
||||
});
|
||||
test("case-insensitive GitHub login matching", async () => {
|
||||
await authorize(fixture({ context: { actor: "CryppaDotta" } }));
|
||||
});
|
||||
test("rejects forks, PR events, automatic events and tags", async () => {
|
||||
for (const context of [
|
||||
{ repo: { owner: "outsider", repo: "paperclip" } },
|
||||
{ eventName: "pull_request" }, { eventName: "push" },
|
||||
{ eventName: "workflow_call" }, { ref: "refs/tags/release" },
|
||||
]) await assert.rejects(authorize(fixture({ context })));
|
||||
});
|
||||
test("fails closed when GitHub cannot return authoritative CODEOWNERS", async () => {
|
||||
await assert.rejects(authorize(fixture({ apiError: true })), /GitHub unavailable/);
|
||||
});
|
||||
test("requires CODEOWNER environment reviewers with administrator bypass disabled", async () => {
|
||||
for (const environment of [
|
||||
{ can_admins_bypass: true },
|
||||
{ protection_rules: [] },
|
||||
{ protection_rules: [{ type: "required_reviewers", reviewers: [] }] },
|
||||
{ protection_rules: [{ type: "required_reviewers", reviewers: [{ type: "User", reviewer: { login: "contributor" } }] }] },
|
||||
{ protection_rules: [{ type: "required_reviewers", reviewers: [{ type: "Team", reviewer: { login: "cryppadotta" } }] }] },
|
||||
]) await assert.rejects(authorize(fixture({ environment })), /must require CODEOWNER reviewers/);
|
||||
});
|
||||
test("workflow keeps branch build read-only and reauthorizes the protected deploy", () => {
|
||||
const workflow = readFileSync(new URL("../../.github/workflows/storybook-deploy.yml", import.meta.url), "utf8");
|
||||
const [build, deploy] = workflow.split(" build:")[1].split(" deploy:");
|
||||
assert.doesNotMatch(build, /pages: write|id-token: write|secrets\./);
|
||||
assert.match(build, /permissions: \{\}/);
|
||||
assert.doesNotMatch(build, /actions\/checkout|cache: pnpm/);
|
||||
assert.match(build, /package-manager-cache: false/);
|
||||
assert.match(build, /pnpm install --frozen-lockfile --ignore-scripts/);
|
||||
assert.match(deploy, /name: storybook-deploy/);
|
||||
assert.match(deploy, /authorize-storybook-deploy.cjs/);
|
||||
assert.match(deploy, /name: \$\{\{ needs.build.outputs.artifact_name \}\}/);
|
||||
assert.doesNotMatch(workflow.split("permissions:")[0], /push:|pull_request:/);
|
||||
});
|
||||
|
||||
import { storybookDestination, branchIndex } from '../../.github/scripts/storybook-destination.cjs';
|
||||
const input = { branch: 'feature/foo', sha: 'a'.repeat(40), runId: 123, runAttempt: 1,
|
||||
bucket: 'storybook-test', baseUrl: 'https://example.cloudfront.net' };
|
||||
test('different branches have distinct stable URLs, including names that sanitize alike', () => {
|
||||
const branches = ['feature/foo', 'feature-foo', 'Feature/foo', 'master', 'feature_foo', 'a'.repeat(100), 'a'.repeat(101)];
|
||||
const urls = branches.map(branch => storybookDestination({ ...input, branch }).url);
|
||||
assert.equal(new Set(urls).size, branches.length);
|
||||
assert.ok(urls.every(url => /^https:\/\/example.cloudfront.net\/storybook\/branches\/[A-Za-z0-9_~\-]+\/$/.test(url)));
|
||||
assert.equal(storybookDestination({ ...input, branch: 'master' }).url,
|
||||
'https://example.cloudfront.net/storybook/branches/master/');
|
||||
assert.equal(storybookDestination(input).url,
|
||||
'https://example.cloudfront.net/storybook/branches/feature~2Ffoo/');
|
||||
});
|
||||
test('bookmark paths cannot collide with other branches or existing immutable build directories', () => {
|
||||
const branches = ['feature/foo', 'feature~2Ffoo', '../master', 'master/index.html',
|
||||
'master', storybookDestination({ ...input, branch: 'master' }).branchKey,
|
||||
'a'.repeat(1000), 'a'.repeat(1001), 'café', 'caf~C3~A9'];
|
||||
const destinations = branches.map(branch => storybookDestination({ ...input, branch }));
|
||||
assert.equal(new Set(destinations.map(d => d.url)).size, branches.length);
|
||||
for (const d of destinations) {
|
||||
assert.doesNotMatch(d.bookmarkPrefix.slice('storybook/branches/'.length), /[/.]/);
|
||||
assert.ok(Buffer.byteLength(`${d.bookmarkPrefix}/index.html`) <= 1024);
|
||||
assert.ok(destinations.every(other => d.bookmarkPrefix !== other.prefix));
|
||||
}
|
||||
});
|
||||
test('redeploying a branch preserves its entry URL and creates a new build URL', () => {
|
||||
const a = storybookDestination(input);
|
||||
const b = storybookDestination({ ...input, sha: 'b'.repeat(40), runId: 124 });
|
||||
assert.equal(a.url, b.url);
|
||||
assert.notEqual(a.buildUrl, b.buildUrl);
|
||||
assert.notEqual(a.buildUrl, storybookDestination({ ...input, runAttempt: 2 }).buildUrl);
|
||||
});
|
||||
test('invalid source and destination inputs fail closed', () => {
|
||||
for (const change of [{ branch: '' }, { branch: 'a\nb' }, { sha: 'master' }, { runId: '../x' },
|
||||
{ runAttempt: 0 }, { bucket: '../bucket' }, { baseUrl: 'http://example.com' },
|
||||
{ baseUrl: 'https://user:password@example.com' }, { baseUrl: 'https://example.com/path' },
|
||||
{ baseUrl: 'https://example.com?x=y' }]) {
|
||||
assert.throws(() => storybookDestination({ ...input, ...change }));
|
||||
}
|
||||
});
|
||||
test('branch entry preserves Storybook query and fragment deep links', () => {
|
||||
const html = branchIndex(storybookDestination(input).buildUrl);
|
||||
assert.match(html, /target.search = location.search/);
|
||||
assert.match(html, /target.hash = location.hash/);
|
||||
assert.match(html, /location.replace/);
|
||||
});
|
||||
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
const publisher = fileURLToPath(new URL('../../.github/scripts/publish-storybook.cjs', import.meta.url));
|
||||
function publishFixture(options = {}) {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'storybook-publish-test-'));
|
||||
mkdirSync(path.join(dir, 'storybook-static'));
|
||||
mkdirSync(path.join(dir, 'bin'));
|
||||
for (const name of ['index.html', 'iframe.html', 'index.json']) writeFileSync(path.join(dir, 'storybook-static', name), 'fixture');
|
||||
if (options.symlink) symlinkSync('/etc/passwd', path.join(dir, 'storybook-static', 'unsafe'));
|
||||
const stub = path.join(dir, 'bin', 'aws');
|
||||
writeFileSync(stub, `#!${process.execPath}\nconst fs=require('node:fs');fs.appendFileSync(process.env.UPLOAD_LOG,JSON.stringify(process.argv.slice(2))+'\\n');if(process.env.FAIL_UPLOAD==='1')process.exit(1);\n`);
|
||||
chmodSync(stub, 0o755);
|
||||
const result = spawnSync(process.execPath, [publisher], { cwd: dir, encoding: 'utf8', env: {
|
||||
...process.env, PATH: `${path.join(dir, 'bin')}:${process.env.PATH}`, RUNNER_TEMP: dir,
|
||||
SOURCE_BRANCH: input.branch, SOURCE_SHA: input.sha, GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '1',
|
||||
STORYBOOK_S3_BUCKET: input.bucket, STORYBOOK_PUBLIC_BASE_URL: input.baseUrl,
|
||||
GITHUB_OUTPUT: path.join(dir, 'output'), GITHUB_STEP_SUMMARY: path.join(dir, 'summary'),
|
||||
UPLOAD_LOG: path.join(dir, 'uploads'), FAIL_UPLOAD: options.fail ? '1' : '0',
|
||||
} });
|
||||
let uploads = [];
|
||||
try { uploads = readFileSync(path.join(dir, 'uploads'), 'utf8').trim().split('\n').map(JSON.parse); } catch {}
|
||||
let report = '';
|
||||
let summary = '';
|
||||
if (result.status === 0) {
|
||||
report = readFileSync(path.join(dir, 'storybook-deployment.md'), 'utf8');
|
||||
summary = readFileSync(path.join(dir, 'summary'), 'utf8');
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
return { result, uploads, report, summary };
|
||||
}
|
||||
test('publisher uploads a complete build then updates only that branch entry', () => {
|
||||
const { result, uploads } = publishFixture();
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(uploads.length, 3);
|
||||
const d = storybookDestination(input);
|
||||
assert.ok(uploads[0].includes(`s3://${input.bucket}/${d.buildPrefix}/`));
|
||||
assert.ok(uploads[1].includes(`s3://${input.bucket}/${d.prefix}/index.html`));
|
||||
assert.ok(uploads[2].includes(`s3://${input.bucket}/${d.bookmarkPrefix}/index.html`));
|
||||
assert.ok(uploads[2].includes('no-cache,max-age=0,must-revalidate'));
|
||||
assert.ok(uploads[0].includes('--no-follow-symlinks'));
|
||||
assert.doesNotMatch(JSON.stringify(uploads), /--delete/);
|
||||
});
|
||||
test('a failed build upload never changes the stable branch entry', () => {
|
||||
const { result, uploads } = publishFixture({ fail: true });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(uploads.length, 1);
|
||||
assert.ok(uploads[0].includes('--recursive'));
|
||||
});
|
||||
test('artifact symlinks fail before any upload', () => {
|
||||
const { result, uploads } = publishFixture({ symlink: true });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(uploads.length, 0);
|
||||
});
|
||||
|
||||
test('successful publication produces a downloadable Markdown report matching the run summary', () => {
|
||||
const { result, report, summary } = publishFixture();
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const d = storybookDestination(input);
|
||||
assert.ok(report.includes(`[Branch Storybook](${d.url})`));
|
||||
assert.ok(report.includes(`[This build](${d.buildUrl})`));
|
||||
assert.ok(report.includes(d.sha));
|
||||
assert.equal(report, summary);
|
||||
});
|
||||
|
||||
import { verifyStorybook } from '../../.github/scripts/verify-storybook.cjs';
|
||||
test('public verification retries a stale stable branch entry until it points to the new build', async () => {
|
||||
const d = storybookDestination(input);
|
||||
let indexReads = 0;
|
||||
await verifyStorybook({ branchUrl: d.url, buildUrl: d.buildUrl, sha: d.sha,
|
||||
sleep: async () => {}, attempts: 2, fetch: async (url) => String(url).endsWith('deployment.json')
|
||||
? new Response(JSON.stringify({ sha: d.sha }))
|
||||
: new Response(branchIndex(++indexReads === 1 ? d.buildUrl.replace('123-1', '122-1') : d.buildUrl)) });
|
||||
assert.equal(indexReads, 2);
|
||||
});
|
||||
test('public verification rejects a permanently stale branch URL or wrong source commit', async () => {
|
||||
const d = storybookDestination(input);
|
||||
for (const wrong of ['branch', 'sha']) {
|
||||
await assert.rejects(verifyStorybook({ branchUrl: d.url, buildUrl: d.buildUrl, sha: d.sha,
|
||||
attempts: 1, fetch: async (url) => String(url).endsWith('deployment.json')
|
||||
? new Response(JSON.stringify({ sha: wrong === 'sha' ? 'b'.repeat(40) : d.sha }))
|
||||
: new Response(branchIndex(wrong === 'branch' ? d.buildUrl.replace('123-1', '122-1') : d.buildUrl)) }),
|
||||
wrong === 'branch' ? /does not point to this build/ : /wrong source commit/);
|
||||
}
|
||||
});
|
||||
@@ -13,6 +13,8 @@ import { readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { createRequire } from "node:module";
|
||||
import { runInNewContext } from "node:vm";
|
||||
|
||||
import cliEsbuildConfig from "../cli/esbuild.config.mjs";
|
||||
import { bundledCliNpmDependencies } from "./cli-bundled-npm-dependencies.mjs";
|
||||
@@ -43,10 +45,68 @@ const acpxRuntimePatch = await readFile(
|
||||
"utf8",
|
||||
);
|
||||
const claudeAcpPatch = await readFile(
|
||||
new URL("../patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch", import.meta.url),
|
||||
new URL("../patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
for (const version of ["0.12.0", "0.13.1"]) {
|
||||
test(`ACPX ${version} release patch uses portable generated unified hunks`, async () => {
|
||||
const patch = await readFile(
|
||||
new URL(`../patches/acpx@${version}.patch`, import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const lines = patch.split("\n");
|
||||
let hunkCount = 0;
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const header = lines[index].match(
|
||||
/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/,
|
||||
);
|
||||
if (!header) continue;
|
||||
hunkCount += 1;
|
||||
const body = [];
|
||||
let oldLines = 0;
|
||||
let newLines = 0;
|
||||
while (index + 1 < lines.length && (oldLines < Number(header[2] ?? 1) || newLines < Number(header[4] ?? 1))) {
|
||||
const line = lines[++index];
|
||||
// Unified diff's EOF marker is metadata, not a source/destination
|
||||
// line, and may occur between the removed and added final lines.
|
||||
if (line === "\\ No newline at end of file") continue;
|
||||
// Git accepts an empty context line with its optional space omitted.
|
||||
assert.ok(line === "" || /^[ +\-]/.test(line), `invalid unified hunk line: ${line}`);
|
||||
const normalized = line === "" ? " " : line;
|
||||
body.push(normalized);
|
||||
if (!normalized.startsWith("+")) oldLines += 1;
|
||||
if (!normalized.startsWith("-")) newLines += 1;
|
||||
}
|
||||
assert.equal(
|
||||
body.filter((line) => !line.startsWith("+")).length,
|
||||
Number(header[2] ?? 1),
|
||||
);
|
||||
assert.equal(
|
||||
body.filter((line) => !line.startsWith("-")).length,
|
||||
Number(header[4] ?? 1),
|
||||
);
|
||||
const prefix = body.findIndex((line) => !line.startsWith(" "));
|
||||
const suffix = body
|
||||
.slice()
|
||||
.reverse()
|
||||
.findIndex((line) => !line.startsWith(" "));
|
||||
// pnpm patch-commit emits three context lines. Hand-added asymmetric
|
||||
// context can force GNU patch's locate_hunk() to require EOF even when
|
||||
// BSD patch and git apply accept the same source and hunk.
|
||||
assert.ok(
|
||||
prefix >= 0 && prefix <= 3,
|
||||
`regenerate ${version} hunk at old line ${header[1]} with pnpm patch-commit (prefix ${prefix})`,
|
||||
);
|
||||
assert.ok(
|
||||
suffix >= 0 && suffix <= 3,
|
||||
`regenerate ${version} hunk at old line ${header[1]} with pnpm patch-commit (suffix ${suffix})`,
|
||||
);
|
||||
}
|
||||
assert.ok(hunkCount > 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("published packages preserve the patched ACPX runtime", () => {
|
||||
assert.equal(
|
||||
rootPackage.pnpm.patchedDependencies["acpx@0.12.0"],
|
||||
@@ -59,7 +119,7 @@ test("published packages preserve the patched ACPX runtime", () => {
|
||||
assert.equal(adapterUtilsPackage.dependencies.acpx, "0.12.0");
|
||||
assert.deepEqual(adapterUtilsPackage.bundleDependencies, ["acpx"]);
|
||||
assert.equal(serverPackage.dependencies.acpx, "0.13.1");
|
||||
assert.deepEqual(serverPackage.bundleDependencies, ["acpx"]);
|
||||
assert.ok(serverPackage.bundleDependencies.includes("acpx"));
|
||||
assert.equal(bundledCliNpmDependencies.has("acpx"), true);
|
||||
assert.equal(cliEsbuildConfig.external.includes("acpx"), false);
|
||||
});
|
||||
@@ -67,11 +127,11 @@ test("published packages preserve the patched ACPX runtime", () => {
|
||||
test("Paperclip Runner pins the qualified ACPX host callbacks", () => {
|
||||
assert.equal(rootPackage.pnpm.patchedDependencies["acpx@0.13.1"], "patches/acpx@0.13.1.patch");
|
||||
assert.equal(
|
||||
rootPackage.pnpm.patchedDependencies["@agentclientprotocol/claude-agent-acp@0.70.0"],
|
||||
"patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch",
|
||||
rootPackage.pnpm.patchedDependencies["@agentclientprotocol/claude-agent-acp@0.73.0"],
|
||||
"patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch",
|
||||
);
|
||||
assert.equal(runnerPackage.dependencies.acpx, "0.13.1");
|
||||
assert.equal(runnerPackage.dependencies["@agentclientprotocol/claude-agent-acp"], "0.70.0");
|
||||
assert.equal(runnerPackage.dependencies["@agentclientprotocol/claude-agent-acp"], "0.73.0");
|
||||
assert.equal(runnerPackage.dependencies["@agentclientprotocol/codex-acp"], "1.6.2");
|
||||
for (const callback of [
|
||||
"spawnEnvironment", "spawnCwd", "spawnAgent", "isPlainStringEnvironment",
|
||||
@@ -224,7 +284,7 @@ test("bundled package patch selection rejects an unpatched installed version", (
|
||||
);
|
||||
});
|
||||
|
||||
test("server package staging bundles and patches the vendored runner's acpx runtime", (t) => {
|
||||
test("server package staging applies every bundled runtime patch and preserves the vendored runner", (t) => {
|
||||
const fixtureDir = mkdtempSync(join(tmpdir(), "paperclip-bundled-stage-"));
|
||||
const sourceDir = join(fixtureDir, "source");
|
||||
const destinationDir = join(fixtureDir, "destination");
|
||||
@@ -261,7 +321,7 @@ mkdir -p "$destination/node_modules/.pnpm"
|
||||
set -euo pipefail
|
||||
printf 'npm %s\\n' "$*" >> "$FAKE_CALL_LOG"
|
||||
[ "$*" = "install --omit=dev --ignore-scripts --no-audit --no-fund" ]
|
||||
node -e 'const pkg = require("./package.json"); if ("devDependencies" in pkg) process.exit(1)'
|
||||
node -e 'const fs = require("node:fs"); const pkg = require("./package.json"); if ("devDependencies" in pkg) process.exit(1); for (const [name, version] of Object.entries(pkg.dependencies)) { const dir = "node_modules/" + name; fs.mkdirSync(dir + "/dist", { recursive: true }); fs.writeFileSync(dir + "/package.json", JSON.stringify({ name, version })); }'
|
||||
mkdir -p node_modules/acpx/dist
|
||||
printf 'unpatched runtime\\n' > node_modules/acpx/dist/runtime.js
|
||||
printf '{"name":"acpx","version":"0.13.1"}\\n' > node_modules/acpx/package.json
|
||||
@@ -282,6 +342,10 @@ while [ "$#" -gt 0 ]; do
|
||||
fi
|
||||
done
|
||||
patch_input="$(cat)"
|
||||
printf '%s\\n' "$patch_input" > "$target/applied.patch"
|
||||
if [[ "$target" != */acpx ]]; then
|
||||
exit 0
|
||||
fi
|
||||
grep -q spawnEnvironment <<< "$patch_input"
|
||||
grep -q spawnAgent <<< "$patch_input"
|
||||
grep -q onAgentStderr <<< "$patch_input"
|
||||
@@ -291,7 +355,11 @@ printf 'patched spawnEnvironment runtime\\n' > "$target/dist/runtime.js"
|
||||
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[new URL("./prepare-bundled-package.mjs", import.meta.url).pathname, sourceDir, destinationDir],
|
||||
[
|
||||
new URL("./prepare-bundled-package.mjs", import.meta.url).pathname,
|
||||
sourceDir,
|
||||
destinationDir,
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -316,9 +384,23 @@ printf 'patched spawnEnvironment runtime\\n' > "$target/dist/runtime.js"
|
||||
/patch -p1 --forward -d .*node_modules\/acpx/,
|
||||
);
|
||||
assert.equal(
|
||||
readFileSync(callLog, "utf8").split("\n").filter((line) => line.startsWith("patch ")).length,
|
||||
1,
|
||||
readFileSync(callLog, "utf8")
|
||||
.split("\n")
|
||||
.filter((line) => line.startsWith("patch ")).length,
|
||||
serverPackage.bundleDependencies.length,
|
||||
);
|
||||
for (const name of serverPackage.bundleDependencies) {
|
||||
const specifier = `${name}@${serverPackage.dependencies[name]}`;
|
||||
const patchPath = rootPackage.pnpm.patchedDependencies[specifier];
|
||||
assert.equal(
|
||||
readFileSync(
|
||||
join(destinationDir, "node_modules", name, "applied.patch"),
|
||||
"utf8",
|
||||
),
|
||||
`${readFileSync(new URL(`../${patchPath}`, import.meta.url), "utf8").trimEnd()}\n`,
|
||||
`${specifier} receives its own full configured patch`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("bundled package dry runs preview without querying published versions", () => {
|
||||
@@ -342,3 +424,23 @@ test("npm builds use corepack instead of requiring a global pnpm", () => {
|
||||
assert.match(buildNpmScript, /corepack pnpm -r typecheck/);
|
||||
assert.doesNotMatch(buildNpmScript, /^\s*pnpm -r typecheck/m);
|
||||
});
|
||||
|
||||
|
||||
test("installed ACPX runtime persists and restores optional goal capabilities", () => {
|
||||
const requireRunner = createRequire(new URL("../packages/paperclip-runner/package.json", import.meta.url));
|
||||
const runtimeSource = readFileSync(requireRunner.resolve("acpx/runtime"), "utf8");
|
||||
const start = runtimeSource.indexOf("function persistedGoalCapability(");
|
||||
const end = runtimeSource.indexOf("function planUpdateEvent(", start);
|
||||
assert.ok(start >= 0 && end > start, "the installed patch must define both goal helpers");
|
||||
const helpers = runInNewContext(runtimeSource.slice(start, end) + ";({ persistedGoalCapability, restoredGoalCapability })", {
|
||||
isRecord: (value) => value !== null && typeof value === "object" && !Array.isArray(value),
|
||||
});
|
||||
assert.equal(helpers.persistedGoalCapability(undefined), undefined);
|
||||
assert.equal(helpers.restoredGoalCapability(undefined), undefined);
|
||||
const goal = { version: 1, controlMethod: "_session/goal", actions: ["set", "pause", "clear"] };
|
||||
const saved = JSON.parse(JSON.stringify(helpers.persistedGoalCapability(goal)));
|
||||
assert.equal(saved.control_method, "_session/goal");
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(helpers.restoredGoalCapability(saved))), goal);
|
||||
assert.equal(helpers.persistedGoalCapability({ ...goal, version: 2 }), undefined);
|
||||
assert.equal(helpers.persistedGoalCapability({ ...goal, actions: ["set"] }), undefined);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/** Isolated, repeatable preparation benchmark. Run with pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-skill-preparation.ts. */
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { companies, companySkills, createDb, startEmbeddedPostgresTestDatabase } from "../packages/db/src/index.js";
|
||||
import { removeRuntimeSkillCache } from "../server/src/services/runtime-skill-cache.js";
|
||||
import { companySkillService } from "../server/src/services/company-skills.js";
|
||||
|
||||
const child = process.argv.includes("--warm-child");
|
||||
const home = child ? process.env.PAPERCLIP_HOME! : await fs.mkdtemp(path.join(os.tmpdir(), "skill-preparation-benchmark-"));
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "default";
|
||||
const database = child ? null : await startEmbeddedPostgresTestDatabase("skill-preparation-benchmark-");
|
||||
const db = createDb(database?.connectionString ?? process.env.SKILL_BENCH_DATABASE_URL!);
|
||||
const companyId = child ? process.env.SKILL_BENCH_COMPANY_ID! : randomUUID();
|
||||
const svc = companySkillService(db);
|
||||
let upstreamFetches = 0;
|
||||
let inventoryRefreshes = 0;
|
||||
let rebuilds = 0;
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input) => { upstreamFetches++; return new Response(`# Fixture\n${String(input)}\n`); };
|
||||
const originalSelect = db.select.bind(db);
|
||||
db.select = ((fields?: Record<string, unknown>) => {
|
||||
if (fields && Object.keys(fields).length === 1 && fields.id === companies.id) inventoryRefreshes++;
|
||||
return originalSelect(fields as never);
|
||||
}) as typeof db.select;
|
||||
const originalMkdtemp = fs.mkdtemp;
|
||||
fs.mkdtemp = ((prefix: string, ...args: unknown[]) => {
|
||||
if (String(prefix).includes("__runtime_cache_v1__") && String(prefix).includes(".staging-")) rebuilds++;
|
||||
return Reflect.apply(originalMkdtemp, fs, [prefix, ...args]);
|
||||
}) as typeof fs.mkdtemp;
|
||||
|
||||
async function measure(label: string) {
|
||||
upstreamFetches = inventoryRefreshes = rebuilds = 0;
|
||||
const start = performance.now();
|
||||
const entries = await svc.listRuntimeSkillEntries(companyId);
|
||||
const durationMs = performance.now() - start;
|
||||
const missingEntries = entries.filter((entry) => entry.sourceStatus === "missing").length;
|
||||
assert.equal(entries.length, 114);
|
||||
assert.equal(missingEntries, 0);
|
||||
assert.equal(inventoryRefreshes, 1);
|
||||
if (label !== "cold") { assert.equal(upstreamFetches, 0); assert.equal(rebuilds, 0); }
|
||||
// Check every synthetic supporting file against its installed revision, outside the timed section.
|
||||
const installed = await db.select().from(companySkills);
|
||||
for (const skill of installed.filter((skill) => skill.sourceType === "github" || skill.sourceType === "skills_sh")) {
|
||||
const entry = entries.find((entry) => entry.key === skill.key)!;
|
||||
for (const file of skill.fileInventory) {
|
||||
const expected = `# Fixture\nhttps://raw.githubusercontent.com/fixture/skills/${skill.sourceRef}/${skill.slug}/${file.path}\n`;
|
||||
assert.equal(await fs.readFile(path.join(entry.source, file.path), "utf8"), expected);
|
||||
}
|
||||
}
|
||||
return { label, durationMs: Math.round(durationMs * 100) / 100, inventoryRefreshes, upstreamFetches, rebuilds, missingEntries, entries: entries.length };
|
||||
}
|
||||
|
||||
try {
|
||||
if (child) {
|
||||
console.log(JSON.stringify(await measure("warm-new-process")));
|
||||
} else {
|
||||
await db.insert(companies).values({ id: companyId, name: "Skill benchmark", issuePrefix: "BENCH" });
|
||||
const bundled = await svc.listFull(companyId);
|
||||
const additional = 114 - bundled.length;
|
||||
assert.ok(additional >= 6, "Bundled inventory leaves insufficient room for mixed benchmark fixtures");
|
||||
const remoteCount = 33;
|
||||
const catalogCount = 21;
|
||||
assert.ok(additional > remoteCount + catalogCount, "Bundled inventory leaves no room for local fixtures");
|
||||
const filesPerRemote = Math.max(9, Math.ceil(400 / remoteCount));
|
||||
for (let index = 0; index < additional; index++) {
|
||||
const slug = `fixture-${index}`;
|
||||
const sourceType = index < remoteCount ? (index < 28 ? "github" : "skills_sh")
|
||||
: index === remoteCount ? "url" : index <= remoteCount + catalogCount ? "catalog" : "local_path";
|
||||
const localDir = path.join(home, "instances", "default", "skills", companyId, slug);
|
||||
if (sourceType === "local_path" || sourceType === "catalog") {
|
||||
await fs.mkdir(localDir, { recursive: true });
|
||||
await fs.writeFile(path.join(localDir, "SKILL.md"), `---\nname: ${slug}\ndescription: Benchmark\n---\n# Local\n`);
|
||||
}
|
||||
await db.insert(companySkills).values({
|
||||
id: randomUUID(), companyId, key: `company/${companyId}/${slug}`, slug, name: slug,
|
||||
markdown: `# ${slug}`, sourceType, sourceLocator: sourceType === "local_path" || sourceType === "catalog" ? localDir : `https://example.com/${slug}`,
|
||||
sourceRef: index < remoteCount ? "a".repeat(40) : null,
|
||||
trustLevel: "markdown_only", compatibility: "compatible",
|
||||
metadata: { owner: "fixture", repo: "skills", repoSkillDir: slug },
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill" }, ...Array.from({ length: index < remoteCount ? filesPerRemote - 1 : 0 }, (_, file) => ({ path: `references/${file}.md`, kind: "reference" as const }))],
|
||||
});
|
||||
}
|
||||
const samples = [await measure("cold")];
|
||||
for (let index = 0; index < 9; index++) samples.push(await measure(`warm-${index + 1}`));
|
||||
const childOutput = await new Promise<string>((resolve, reject) => {
|
||||
const proc = spawn(process.execPath, ["--import", fileURLToPath(new URL("../server/node_modules/tsx/dist/loader.mjs", import.meta.url)), fileURLToPath(import.meta.url), "--warm-child"], {
|
||||
env: { ...process.env, SKILL_BENCH_DATABASE_URL: database!.connectionString, SKILL_BENCH_COMPANY_ID: companyId },
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
});
|
||||
let output = "";
|
||||
proc.stdout.on("data", (chunk) => { output += chunk; });
|
||||
proc.on("error", reject);
|
||||
proc.on("exit", (code) => code === 0 ? resolve(output) : reject(new Error(`Warm subprocess failed: ${code}`)));
|
||||
});
|
||||
samples.push(JSON.parse(childOutput.trim().split("\n").at(-1)!));
|
||||
const warm = samples.slice(1).map((sample) => sample.durationMs).sort((a, b) => a - b);
|
||||
console.log(JSON.stringify({ inventory: 114, materializedRemoteFiles: remoteCount * filesPerRemote,
|
||||
warmMedianMs: (warm[4] + warm[5]) / 2, warmMaxMs: warm.at(-1), samples }, null, 2));
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
fs.mkdtemp = originalMkdtemp;
|
||||
if (!child) for (const skill of await db.select().from(companySkills)) {
|
||||
await removeRuntimeSkillCache(path.join(home, "instances", "default", "skills", companyId), skill.id);
|
||||
}
|
||||
await db.$client.end();
|
||||
await database?.cleanup();
|
||||
if (!child) await fs.rm(home, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
import {
|
||||
createBundledInstallManifest,
|
||||
materializePublishManifest,
|
||||
selectBundledDependencyPatches,
|
||||
} from "./prepare-bundled-package.mjs";
|
||||
|
||||
const repoRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||
const rootPackage = JSON.parse(
|
||||
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
||||
);
|
||||
const serverPackage = JSON.parse(
|
||||
readFileSync(new URL("../server/package.json", import.meta.url), "utf8"),
|
||||
);
|
||||
const workspace = readFileSync(
|
||||
new URL("../pnpm-workspace.yaml", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const required = [
|
||||
["@chat-adapter/discord", "4.39.0"],
|
||||
["@chat-adapter/github", "4.39.0"],
|
||||
["@chat-adapter/slack", "4.39.0"],
|
||||
["@chat-adapter/teams", "4.39.0"],
|
||||
["@chat-adapter/telegram", "4.39.0"],
|
||||
["@discordjs/ws", "1.2.3"],
|
||||
];
|
||||
|
||||
for (const [name, version] of required) {
|
||||
test(`published server retains the patched ${name}@${version} runtime`, () => {
|
||||
const published = materializePublishManifest(serverPackage);
|
||||
const installed = createBundledInstallManifest(
|
||||
published,
|
||||
serverPackage.bundleDependencies,
|
||||
);
|
||||
assert.equal(
|
||||
serverPackage.dependencies[name],
|
||||
version,
|
||||
"the patched runtime must have an exact direct version",
|
||||
);
|
||||
assert.ok(
|
||||
serverPackage.bundleDependencies.includes(name),
|
||||
"npm consumers cannot apply this repository's pnpm patches",
|
||||
);
|
||||
assert.equal(
|
||||
installed.dependencies[name],
|
||||
version,
|
||||
"release staging must install the runtime before patching",
|
||||
);
|
||||
assert.ok(
|
||||
published.bundleDependencies.includes(name),
|
||||
"the patched runtime must remain in the published tarball",
|
||||
);
|
||||
const specifier = `${name}@${version}`;
|
||||
const patchPath = rootPackage.pnpm.patchedDependencies[specifier];
|
||||
assert.equal(typeof patchPath, "string");
|
||||
assert.ok(
|
||||
workspace.includes(`"${specifier}": ${patchPath}`),
|
||||
"both supported pnpm configuration paths must match",
|
||||
);
|
||||
// Parse the actual full patch, not merely its configured filename. This is
|
||||
// read-only; clean-package materialization is a separate qualification.
|
||||
execFileSync("git", ["apply", "--stat", patchPath], {
|
||||
cwd: repoRoot,
|
||||
stdio: "pipe",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("release selection includes every chat runtime patch and the existing ACPX patch", (t) => {
|
||||
const destination = mkdtempSync(
|
||||
join(tmpdir(), "paperclip-chat-release-contract-"),
|
||||
);
|
||||
t.after(() => rmSync(destination, { recursive: true, force: true }));
|
||||
for (const [name, version] of [...required, ["acpx", "0.13.1"]]) {
|
||||
const directory = join(destination, "node_modules", name);
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(
|
||||
join(directory, "package.json"),
|
||||
JSON.stringify({ name, version }),
|
||||
);
|
||||
}
|
||||
const selected = selectBundledDependencyPatches(
|
||||
destination,
|
||||
serverPackage.bundleDependencies,
|
||||
rootPackage.pnpm.patchedDependencies,
|
||||
);
|
||||
assert.deepEqual(
|
||||
selected.map(({ specifier }) => specifier).sort(),
|
||||
[
|
||||
...required.map(([name, version]) => `${name}@${version}`),
|
||||
"acpx@0.13.1",
|
||||
].sort(),
|
||||
);
|
||||
writeFileSync(
|
||||
join(destination, "node_modules", "@discordjs/ws", "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@discordjs/ws",
|
||||
version: "1.2.4",
|
||||
}),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
selectBundledDependencyPatches(
|
||||
destination,
|
||||
serverPackage.bundleDependencies,
|
||||
rootPackage.pnpm.patchedDependencies,
|
||||
),
|
||||
/installed @discordjs\/ws@1\.2\.4/,
|
||||
);
|
||||
});
|
||||
@@ -104,8 +104,18 @@ export function scanModuleBoundaries({
|
||||
addViolation(violations, sourceLabel, "domain", specifier, "domain cannot import database packages");
|
||||
} else if (specifier.startsWith("node:")) {
|
||||
addViolation(violations, sourceLabel, "domain", specifier, "domain cannot import Node.js runtime modules");
|
||||
} else if (targetSegments.includes("services") || targetSegments.includes("routes")) {
|
||||
addViolation(violations, sourceLabel, "domain", specifier, "domain cannot import server services or routes");
|
||||
} else if (
|
||||
targetSegments.includes("services") ||
|
||||
targetSegments.includes("routes") ||
|
||||
targetSegments.includes("adapters")
|
||||
) {
|
||||
addViolation(
|
||||
violations,
|
||||
sourceLabel,
|
||||
"domain",
|
||||
specifier,
|
||||
"domain cannot import server services, routes, or adapters",
|
||||
);
|
||||
} else if (targetLocation?.layer === "application" || targetLocation?.layer === "adapters") {
|
||||
addViolation(violations, sourceLabel, "domain", specifier, "domain cannot depend on outer module layers");
|
||||
}
|
||||
@@ -114,8 +124,10 @@ export function scanModuleBoundaries({
|
||||
if (sourceLocation?.layer === "application") {
|
||||
if (isDatabasePackage(specifier)) {
|
||||
addViolation(violations, sourceLabel, "application", specifier, "application cannot import database packages");
|
||||
} else if (targetLocation?.layer === "adapters") {
|
||||
} else if (targetLocation?.layer === "adapters" || targetSegments.includes("adapters")) {
|
||||
addViolation(violations, sourceLabel, "application", specifier, "application cannot import concrete adapters");
|
||||
} else if (targetSegments.includes("services") || targetSegments.includes("routes")) {
|
||||
addViolation(violations, sourceLabel, "application", specifier, "application cannot import server services or routes");
|
||||
} else if (targetSegments.join("/") === "errors.js" || targetSegments.join("/") === "errors.ts") {
|
||||
addViolation(violations, sourceLabel, "application", specifier, "application cannot import HTTP error helpers");
|
||||
}
|
||||
|
||||
@@ -32,13 +32,16 @@ test("scanModuleBoundaries rejects outward dependencies and module-internal impo
|
||||
write("modules/watchdog/domain/policy.ts", [
|
||||
'import { eq } from "drizzle-orm";',
|
||||
'import { service } from "../../../services/example.js";',
|
||||
'import { parse } from "../../../adapters/utils.js";',
|
||||
'import { run } from "../application/run.js";',
|
||||
].join("\n"));
|
||||
write(
|
||||
"modules/watchdog/application/run.ts",
|
||||
[
|
||||
'import { adapter } from "../adapters/postgres.js";',
|
||||
'import { parse } from "../../../adapters/application-utils.js";',
|
||||
'import { forbidden } from "../../../errors.js";',
|
||||
'import { helper } from "../../../services/example.js";',
|
||||
'import db = require("@paperclipai/db");',
|
||||
].join("\n"),
|
||||
);
|
||||
@@ -51,10 +54,25 @@ test("scanModuleBoundaries rejects outward dependencies and module-internal impo
|
||||
violations.map(({ specifier, reason }) => ({ specifier, reason })),
|
||||
[
|
||||
{ specifier: "../adapters/postgres.js", reason: "application cannot import concrete adapters" },
|
||||
{
|
||||
specifier: "../../../adapters/application-utils.js",
|
||||
reason: "application cannot import concrete adapters",
|
||||
},
|
||||
{ specifier: "../../../errors.js", reason: "application cannot import HTTP error helpers" },
|
||||
{
|
||||
specifier: "../../../services/example.js",
|
||||
reason: "application cannot import server services or routes",
|
||||
},
|
||||
{ specifier: "@paperclipai/db", reason: "application cannot import database packages" },
|
||||
{ specifier: "drizzle-orm", reason: "domain cannot import database packages" },
|
||||
{ specifier: "../../../services/example.js", reason: "domain cannot import server services or routes" },
|
||||
{
|
||||
specifier: "../../../services/example.js",
|
||||
reason: "domain cannot import server services, routes, or adapters",
|
||||
},
|
||||
{
|
||||
specifier: "../../../adapters/utils.js",
|
||||
reason: "domain cannot import server services, routes, or adapters",
|
||||
},
|
||||
{ specifier: "../application/run.js", reason: "domain cannot depend on outer module layers" },
|
||||
{
|
||||
specifier: "../modules/watchdog/application/run.js",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { imageExists, packageExists, versionFor } from "./preview-artifacts.mjs";
|
||||
|
||||
/** Read-only availability gate. Deployment still resolves and pins artifacts. */
|
||||
export async function waitForCloudArtifacts(sha, {
|
||||
fetchImpl = fetch,
|
||||
now = () => performance.now(),
|
||||
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
timeoutMs = 30 * 60_000,
|
||||
intervalMs = 20_000,
|
||||
log = console.log,
|
||||
} = {}) {
|
||||
const version = versionFor(sha);
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || !Number.isFinite(intervalMs) || intervalMs <= 0) {
|
||||
throw new Error("Cloud readiness requires positive finite timeout and poll interval.");
|
||||
}
|
||||
const deadline = now() + timeoutMs;
|
||||
let previous;
|
||||
let missing = ["image", "shared", "db"];
|
||||
while (now() < deadline) {
|
||||
// Recheck every artifact on the successful poll. Only an explicit 404
|
||||
// means publication is pending; identity errors and upstream outages fail.
|
||||
const results = await Promise.all([
|
||||
imageExists(sha, fetchImpl),
|
||||
packageExists("@paperclipai/shared", sha, fetchImpl),
|
||||
packageExists("@paperclipai/db", sha, fetchImpl),
|
||||
]);
|
||||
missing = ["image", "shared", "db"].filter((_, index) => !results[index]);
|
||||
if (missing.length === 0) {
|
||||
log(`Cloud artifacts available for ${sha}: verified image and exact-source migrator ${version}.`);
|
||||
return { version: 1, sha, packageVersion: version };
|
||||
}
|
||||
const state = missing.join(", ");
|
||||
if (state !== previous) log(`Waiting for cloud artifacts for ${sha}: ${state}.`);
|
||||
previous = state;
|
||||
const remaining = deadline - now();
|
||||
if (remaining > 0) await sleep(Math.min(intervalMs, remaining));
|
||||
}
|
||||
throw new Error(`Cloud artifacts timed out for ${sha}; missing: ${missing.join(", ")}.`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
try { await waitForCloudArtifacts(process.argv[2]); }
|
||||
catch (error) { console.error(error.message); process.exitCode = 1; }
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { appendFile } from "node:fs/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const repository = "paperclipai/paperclip";
|
||||
const workflowPath = ".github/workflows/cloud-readiness.yml";
|
||||
export const sourceVerificationJob = "Cloud source verified v1";
|
||||
|
||||
function assertSha(sha) {
|
||||
if (!/^[a-f0-9]{40}$/.test(sha ?? "")) throw new Error("A full lowercase source SHA is required.");
|
||||
}
|
||||
|
||||
function trustedRun(run, sha, workflowId) {
|
||||
return run.workflow_id === workflowId && run.path === workflowPath &&
|
||||
run.repository?.full_name === repository && run.head_repository?.full_name === repository &&
|
||||
run.head_sha === sha && run.head_branch === "master" && run.event === "push" &&
|
||||
Number.isSafeInteger(run.id) && run.id > 0 &&
|
||||
Number.isSafeInteger(run.run_attempt) && run.run_attempt > 0;
|
||||
}
|
||||
|
||||
// Consume one versioned job, independent of image/migrator availability. A
|
||||
// failed image build must not invalidate source checks that already passed.
|
||||
export async function readSourceVerification(sha, api) {
|
||||
assertSha(sha);
|
||||
const workflow = await api(`/repos/${repository}/actions/workflows/cloud-readiness.yml`);
|
||||
if (workflow.path !== workflowPath || !Number.isSafeInteger(workflow.id) || workflow.id < 1) {
|
||||
throw new Error("Cloud readiness workflow identity does not match.");
|
||||
}
|
||||
const listing = await api(`/repos/${repository}/actions/workflows/${workflow.id}/runs?head_sha=${sha}&event=push&branch=master&per_page=100`);
|
||||
if (!Array.isArray(listing.workflow_runs) || !Number.isSafeInteger(listing.total_count) ||
|
||||
listing.total_count < 0 || listing.total_count > 100 || listing.workflow_runs.length !== listing.total_count) {
|
||||
throw new Error("Cloud readiness run listing is incomplete.");
|
||||
}
|
||||
const run = listing.workflow_runs.filter((candidate) => trustedRun(candidate, sha, workflow.id))
|
||||
.sort((a, b) => b.id - a.id)[0];
|
||||
if (!run) return undefined;
|
||||
|
||||
// Attempt-specific jobs prevent an earlier successful attempt from blessing
|
||||
// a later rerun. Keep pagination even though today's matrix fits one page.
|
||||
const jobs = [];
|
||||
for (let page = 1; page <= 10; page += 1) {
|
||||
const batch = await api(`/repos/${repository}/actions/runs/${run.id}/attempts/${run.run_attempt}/jobs?per_page=100&page=${page}`);
|
||||
if (!Array.isArray(batch.jobs)) throw new Error("Cloud readiness job listing is malformed.");
|
||||
jobs.push(...batch.jobs);
|
||||
if (batch.jobs.length < 100) break;
|
||||
if (page === 10) throw new Error("Cloud readiness job listing is incomplete.");
|
||||
}
|
||||
const matches = jobs.filter((job) => job.name === sourceVerificationJob);
|
||||
if (matches.length > 1) throw new Error("Cloud source verification job is ambiguous.");
|
||||
const job = matches[0];
|
||||
if (job?.status === "completed" && job.conclusion === "success" &&
|
||||
job.head_sha === sha && job.run_id === run.id && job.run_attempt === run.run_attempt) {
|
||||
// Re-read the run after its jobs: a rerun that started during polling must
|
||||
// not let the previous attempt through. Changes are retried next poll.
|
||||
const current = await api(`/repos/${repository}/actions/runs/${run.id}`);
|
||||
if (!trustedRun(current, sha, workflow.id) || current.run_attempt !== run.run_attempt) return undefined;
|
||||
return { sha, runId: run.id, attempt: run.run_attempt, jobId: job.id };
|
||||
}
|
||||
if (job?.status === "completed" || run.status === "completed") {
|
||||
throw new Error(`Cloud source verification did not pass for ${sha} (run ${run.id}, attempt ${run.run_attempt}). Rerun Cloud readiness before retrying the release.`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function waitForSourceVerification(sha, {
|
||||
api, now = Date.now, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
timeoutMs = 45 * 60_000, intervalMs = 30_000, log = console.log,
|
||||
} = {}) {
|
||||
assertSha(sha);
|
||||
const deadline = now() + timeoutMs;
|
||||
log(`Waiting for ${sourceVerificationJob} for ${sha}.`);
|
||||
while (now() < deadline) {
|
||||
const result = await readSourceVerification(sha, api);
|
||||
if (result) return result;
|
||||
const remaining = deadline - now();
|
||||
if (remaining > 0) await sleep(Math.min(intervalMs, remaining));
|
||||
}
|
||||
throw new Error(`Cloud source verification timed out for ${sha}. Rerun Cloud readiness before retrying the release.`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
try {
|
||||
if (!process.env.GITHUB_TOKEN) throw new Error("GITHUB_TOKEN with Actions read access is required.");
|
||||
const api = async (path) => {
|
||||
const response = await fetch(`https://api.github.com${path}`, {
|
||||
headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" },
|
||||
signal: AbortSignal.timeout(30_000), redirect: "error",
|
||||
});
|
||||
if (!response.ok) throw new Error(`GitHub Actions read failed (HTTP ${response.status}).`);
|
||||
return response.json();
|
||||
};
|
||||
const proof = await waitForSourceVerification(process.argv[2], { api });
|
||||
const message = `Source verification passed for ${proof.sha}: https://github.com/${repository}/actions/runs/${proof.runId}/attempts/${proof.attempt} (job ${proof.jobId}).`;
|
||||
console.log(message);
|
||||
if (process.env.GITHUB_STEP_SUMMARY) await appendFile(process.env.GITHUB_STEP_SUMMARY, `${message}\n`);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readSourceVerification, sourceVerificationJob, waitForSourceVerification } from "./cloud-source-verification.mjs";
|
||||
|
||||
const sha = "a".repeat(40);
|
||||
const workflow = { id: 123, path: ".github/workflows/cloud-readiness.yml" };
|
||||
const baseRun = {
|
||||
id: 456, workflow_id: workflow.id, path: workflow.path, run_attempt: 2,
|
||||
repository: { full_name: "paperclipai/paperclip" }, head_repository: { full_name: "paperclipai/paperclip" },
|
||||
head_sha: sha, head_branch: "master", event: "push", status: "in_progress", conclusion: null,
|
||||
};
|
||||
const baseJob = { id: 789, name: sourceVerificationJob, run_id: 456, run_attempt: 2, head_sha: sha, status: "completed", conclusion: "success" };
|
||||
|
||||
function fixture({ runs = [baseRun], jobs = [baseJob], current = baseRun, total, workflowRecord = workflow } = {}) {
|
||||
const calls = [];
|
||||
const api = async (path) => {
|
||||
calls.push(path);
|
||||
if (path.endsWith("/workflows/cloud-readiness.yml")) return workflowRecord;
|
||||
if (path.includes("/workflows/123/runs?")) return { total_count: total ?? runs.length, workflow_runs: runs };
|
||||
if (path.includes("/attempts/")) {
|
||||
assert.ok(path.includes(`/attempts/${runs.at(-1).run_attempt}/jobs?`));
|
||||
const page = Number(new URL("https://api.github.com" + path).searchParams.get("page"));
|
||||
return { jobs: jobs.slice((page - 1) * 100, page * 100) };
|
||||
}
|
||||
if (path.endsWith("/runs/456")) return current;
|
||||
throw new Error(`Unexpected API path: ${path}`);
|
||||
};
|
||||
return { api, calls };
|
||||
}
|
||||
|
||||
test("source proof passes while image work is still running, or has failed", async () => {
|
||||
for (const overrides of [{}, { status: "completed", conclusion: "failure" }]) {
|
||||
const run = { ...baseRun, ...overrides };
|
||||
const { api, calls } = fixture({ runs: [run], current: run });
|
||||
assert.deepEqual(await readSourceVerification(sha, api), { sha, runId: 456, attempt: 2, jobId: 789 });
|
||||
assert.ok(calls.some((path) => path.includes(`head_sha=${sha}&event=push&branch=master`)));
|
||||
assert.ok(calls.some((path) => path.includes("/attempts/2/jobs?")));
|
||||
}
|
||||
});
|
||||
|
||||
test("only the expected workflow, repository, master push, and full SHA can supply proof", async () => {
|
||||
for (const overrides of [
|
||||
{ workflow_id: 999 }, { path: ".github/workflows/pr.yml" },
|
||||
{ repository: { full_name: "other/paperclip" } }, { head_repository: { full_name: "fork/paperclip" } },
|
||||
{ head_sha: "b".repeat(40) }, { head_branch: "feature" }, { event: "workflow_dispatch" },
|
||||
{ run_attempt: undefined }, { run_attempt: 0 },
|
||||
]) {
|
||||
const { api, calls } = fixture({ runs: [{ ...baseRun, ...overrides }] });
|
||||
assert.equal(await readSourceVerification(sha, api), undefined, JSON.stringify(overrides));
|
||||
assert.equal(calls.length, 2);
|
||||
}
|
||||
});
|
||||
|
||||
test("a newer pending run cannot fall back to an older successful run", async () => {
|
||||
const newer = { ...baseRun, id: 457, run_attempt: 1 };
|
||||
const { api } = fixture({ runs: [baseRun, newer], jobs: [] });
|
||||
assert.equal(await readSourceVerification(sha, api), undefined);
|
||||
});
|
||||
|
||||
test("job identities and terminal failures fail closed", async () => {
|
||||
for (const overrides of [
|
||||
{ head_sha: "b".repeat(40) }, { run_id: 999 }, { run_attempt: 1 },
|
||||
{ conclusion: "failure" }, { conclusion: "cancelled" }, { conclusion: "skipped" },
|
||||
]) {
|
||||
const { api } = fixture({ jobs: [{ ...baseJob, ...overrides }] });
|
||||
await assert.rejects(readSourceVerification(sha, api), /did not pass/);
|
||||
}
|
||||
});
|
||||
|
||||
test("a rerun racing the jobs read cannot reuse the previous attempt", async () => {
|
||||
const { api } = fixture({ current: { ...baseRun, run_attempt: 3 } });
|
||||
assert.equal(await readSourceVerification(sha, api), undefined);
|
||||
});
|
||||
|
||||
test("the versioned proof must exist and be unique", async () => {
|
||||
for (const jobs of [[], [{ ...baseJob, name: "Cloud deployable v1" }]]) {
|
||||
await assert.rejects(readSourceVerification(sha, fixture({ runs: [{ ...baseRun, status: "completed" }], jobs }).api), /did not pass/);
|
||||
}
|
||||
await assert.rejects(readSourceVerification(sha, fixture({ jobs: [baseJob, baseJob] }).api), /ambiguous/);
|
||||
});
|
||||
|
||||
test("proof may appear after the first page of jobs", async () => {
|
||||
const jobs = [...Array.from({ length: 100 }, (_, index) => ({ ...baseJob, name: `test ${index}` })), baseJob];
|
||||
const { api, calls } = fixture({ jobs });
|
||||
assert.equal((await readSourceVerification(sha, api)).jobId, 789);
|
||||
assert.ok(calls.some((path) => path.endsWith("page=2")));
|
||||
});
|
||||
|
||||
test("incomplete discovery and API failures cannot bless a release", async () => {
|
||||
for (const total of [2, 101, -1]) {
|
||||
await assert.rejects(readSourceVerification(sha, fixture({ total }).api), /incomplete/);
|
||||
}
|
||||
await assert.rejects(readSourceVerification(sha, fixture({ workflowRecord: { ...workflow, path: ".github/workflows/pr.yml" } }).api), /identity/);
|
||||
await assert.rejects(readSourceVerification(sha, async () => { throw new Error("API unavailable"); }), /API unavailable/);
|
||||
});
|
||||
|
||||
test("a missing or pending source proof waits and has a bounded deadline", async () => {
|
||||
let time = 0;
|
||||
let polls = 0;
|
||||
const { api } = fixture({ runs: [] });
|
||||
await assert.rejects(waitForSourceVerification(sha, {
|
||||
api: async (path) => { if (path.endsWith(".yml")) polls += 1; return api(path); },
|
||||
now: () => time, sleep: async (ms) => { time += ms; }, timeoutMs: 50, intervalMs: 30, log: () => {},
|
||||
}), /timed out/);
|
||||
assert.equal(time, 50);
|
||||
assert.equal(polls, 2);
|
||||
});
|
||||
|
||||
test("pending verification succeeds when its exact job completes", async () => {
|
||||
let time = 0;
|
||||
const pending = fixture({ jobs: [{ ...baseJob, status: "in_progress", conclusion: null }] });
|
||||
const passed = fixture();
|
||||
const proof = await waitForSourceVerification(sha, {
|
||||
api: (path) => (time ? passed.api : pending.api)(path), now: () => time,
|
||||
sleep: async (ms) => { time += ms; }, timeoutMs: 100, intervalMs: 30, log: () => {},
|
||||
});
|
||||
assert.equal(proof.sha, sha);
|
||||
assert.equal(time, 30);
|
||||
});
|
||||
|
||||
test("malformed source refs are rejected before any request", async () => {
|
||||
for (const ref of ["master", "a".repeat(7), "A".repeat(40), undefined]) {
|
||||
await assert.rejects(readSourceVerification(ref, () => assert.fail("must not request")), /full lowercase/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import test from "node:test";
|
||||
|
||||
const require = createRequire(new URL("../packages/paperclip-runner/package.json", import.meta.url));
|
||||
const source = readFileSync(require.resolve("@agentclientprotocol/codex-acp"), "utf8");
|
||||
const helperStart = source.indexOf("function paperclipSandboxPolicy(");
|
||||
const helperEnd = source.indexOf("\nvar CodexAcpClient", helperStart);
|
||||
const methodStart = source.indexOf(" async sendPrompt(");
|
||||
const methodEnd = source.indexOf("\n async runAgentFileChangeReport", methodStart);
|
||||
assert.ok(helperStart >= 0 && helperEnd > helperStart, "installed Codex ACP must contain the network policy patch");
|
||||
assert.ok(methodStart >= 0 && methodEnd > methodStart, "installed Codex ACP must expose the patched turn boundary");
|
||||
|
||||
async function turnPolicy(policy, env) {
|
||||
const context = {
|
||||
process: { env },
|
||||
buildPromptItems: (prompt) => prompt,
|
||||
addAdditionalDirectoriesToSandboxPolicy: (value) => value,
|
||||
};
|
||||
const client = runInNewContext(
|
||||
source.slice(helperStart, helperEnd) + "\n({" + source.slice(methodStart, methodEnd) + "})",
|
||||
context,
|
||||
);
|
||||
client.refreshSkills = async () => {};
|
||||
client.codexClient = { runTurn: async (params) => params };
|
||||
return client.sendPrompt(
|
||||
{ sessionId: "fresh-or-resumed-thread", prompt: [] },
|
||||
{ approvalPolicy: "on-request", sandboxPolicy: policy },
|
||||
{ model: "test-model", effort: "low" },
|
||||
null, false, "/workspace", [], undefined, undefined,
|
||||
);
|
||||
}
|
||||
|
||||
test("ACP forwards workspace networking to the actual Codex turn without changing confinement", async () => {
|
||||
const policy = { type: "workspaceWrite", writableRoots: ["/workspace"], networkAccess: false };
|
||||
const result = await turnPolicy(policy, { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "true" });
|
||||
assert.equal(result.sandboxPolicy.networkAccess, true);
|
||||
assert.equal(result.sandboxPolicy.type, "workspaceWrite");
|
||||
assert.equal(result.sandboxPolicy.writableRoots, policy.writableRoots);
|
||||
assert.equal(result.approvalPolicy, "on-request");
|
||||
assert.equal(policy.networkAccess, false, "must not mutate the shared mode preset");
|
||||
});
|
||||
|
||||
test("ACP preserves operator and execution-target network denials", async () => {
|
||||
const policy = { type: "workspaceWrite", networkAccess: true };
|
||||
for (const env of [
|
||||
{ PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "false" },
|
||||
{ PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "true", PAPERCLIP_RUNNER_NETWORK_ACCESS: "disabled" },
|
||||
]) {
|
||||
assert.equal((await turnPolicy(policy, env)).sandboxPolicy.networkAccess, false);
|
||||
}
|
||||
});
|
||||
|
||||
test("ACP leaves other sandbox modes and callers without a valid override unchanged", async () => {
|
||||
for (const policy of [{ type: "readOnly", networkAccess: false }, { type: "dangerFullAccess" }]) {
|
||||
assert.equal((await turnPolicy(policy, { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "true" })).sandboxPolicy, policy);
|
||||
}
|
||||
const policy = { type: "workspaceWrite", networkAccess: false };
|
||||
for (const value of [undefined, "", "1", "invalid"]) {
|
||||
assert.equal((await turnPolicy(policy, { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: value })).sandboxPolicy, policy);
|
||||
}
|
||||
});
|
||||
@@ -12,7 +12,7 @@ const DURATIONS_MANIFEST = path.join(HERE, "e2e-shard-durations.json");
|
||||
// Specs the default local_trusted Playwright project deliberately skips. Keep
|
||||
// this in sync with `testIgnore` in tests/e2e/playwright.config.ts — the unit
|
||||
// test in scripts/__tests__/e2e-shard.test.mjs fails if the two ever drift.
|
||||
export const IGNORED_SPECS = ["multi-user.spec.ts", "multi-user-authenticated.spec.ts"];
|
||||
export const IGNORED_SPECS = ["in-feed-native/**", "multi-user.spec.ts", "multi-user-authenticated.spec.ts"];
|
||||
|
||||
// Enumerates the specs the default e2e lane actually runs, as repo-relative
|
||||
// paths so the output can be handed straight to `playwright test`.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"$comment": "Per-suite wall-clock durations (ms) for the general-server vitest lane, used by scripts/general-server-shard.mjs to balance suites across the PR shard matrix. Sampled from a real PR run of .github/workflows/pr.yml (actions run 32708351172, 2026-08-24) by diffing consecutive per-suite completion timestamps in the 'Run grouped general test suites' logs \u2014 that captures each suite's true serial cost (import + collect + tests), not just the vitest-reported test time. Suites missing here get the median weight, so the manifest only needs occasional refreshes.",
|
||||
"$chatSample": "chat-channels.integration.test.ts: actions run 34405038082, job 102646337040, 2026-09-09. The first suite completed at 21:19:52.9026416Z after Vitest RUN at 21:09:07.5491992Z: 645354ms rounded up, including startup/import/collection; the 985 tests themselves took 629654ms. All 2972 tests in the shard passed, but the job exceeded its unchanged 20-minute bound during cleanup. Recording this missing heavy-suite weight lets the existing LPT partition reserve one of the existing five shards without changing test coverage, isolation, or deadlines.",
|
||||
"$nativeRunnerSample": "native-codex-runner.integration.test.ts: actions run 34555686996, job 103127786254, 2026-09-11. Consecutive suite completions at 02:50:34.2830368Z and 02:55:07.9837588Z give 273701ms including import/collection (test body 270773ms). This previously unweighted suite made one four-way server shard take 14m38s; recording its cost lets the existing LPT partition balance it in both PR and release runs.",
|
||||
"durations": {
|
||||
"server/src/services/native-runtime/native-codex-runner.integration.test.ts": 273701,
|
||||
"server/src/__tests__/access-service.test.ts": 4757,
|
||||
"server/src/__tests__/access-validators.test.ts": 645,
|
||||
"server/src/__tests__/activity-log-responsible-user.test.ts": 4407,
|
||||
@@ -40,6 +43,7 @@
|
||||
"server/src/__tests__/built-in-agents.test.ts": 10282,
|
||||
"server/src/__tests__/bundled-plugins.test.ts": 235,
|
||||
"server/src/__tests__/change-consent-gate.test.ts": 4215,
|
||||
"server/src/__tests__/chat-channels.integration.test.ts": 645354,
|
||||
"server/src/__tests__/claude-local-adapter-environment.test.ts": 1105,
|
||||
"server/src/__tests__/claude-local-adapter.test.ts": 751,
|
||||
"server/src/__tests__/claude-local-execute.test.ts": 3248,
|
||||
|
||||
+1553
-176
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,74 @@
|
||||
const REJECTION_REASONS = new Set([
|
||||
"method",
|
||||
"host",
|
||||
"path",
|
||||
"malformed_target",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Observe only a classified rejection, without reading or resuming its body.
|
||||
* The caller retains all HTTP policy and explicitly counts bytes it consumes.
|
||||
* `bytes` means observed body bytes before the terminal response/abort event,
|
||||
* not Content-Length and not bytes attributed from a shared keep-alive socket.
|
||||
*/
|
||||
export function observeChatWebhookProxyRejection(req, res, options = {}) {
|
||||
const now = options.now ?? (() => performance.now());
|
||||
const emit = options.emit ?? (() => {});
|
||||
const connectionId =
|
||||
Number.isSafeInteger(options.connectionId) && options.connectionId > 0
|
||||
? options.connectionId
|
||||
: null;
|
||||
let start;
|
||||
try {
|
||||
start = now();
|
||||
} catch {
|
||||
start = 0;
|
||||
}
|
||||
let reason = null;
|
||||
let bytes = 0;
|
||||
let recorded = false;
|
||||
const record = () => {
|
||||
if (recorded) return;
|
||||
recorded = true;
|
||||
req.removeListener("aborted", record);
|
||||
res.removeListener("finish", record);
|
||||
res.removeListener("close", record);
|
||||
if (reason === null) return;
|
||||
try {
|
||||
const elapsed = now() - start;
|
||||
const result = emit({
|
||||
event: "chat_webhook_proxy_rejection",
|
||||
reason,
|
||||
connectionId,
|
||||
statusCode:
|
||||
res.headersSent &&
|
||||
Number.isInteger(res.statusCode) &&
|
||||
res.statusCode >= 100 &&
|
||||
res.statusCode <= 599
|
||||
? res.statusCode
|
||||
: null,
|
||||
durationMs: Number.isFinite(elapsed)
|
||||
? Math.min(86_400_000, Math.max(0, Math.round(elapsed * 1000) / 1000))
|
||||
: 0,
|
||||
bytes,
|
||||
});
|
||||
if (result instanceof Promise) void result.catch(() => {});
|
||||
} catch {
|
||||
// Diagnostics must not change the response or expose arbitrary errors.
|
||||
}
|
||||
};
|
||||
req.once("aborted", record);
|
||||
res.once("finish", record);
|
||||
res.once("close", record);
|
||||
return {
|
||||
reject(value) {
|
||||
if (!recorded && reason === null && REJECTION_REASONS.has(value))
|
||||
reason = value;
|
||||
},
|
||||
countBytes(length) {
|
||||
if (!recorded && Number.isSafeInteger(length) && length > 0) {
|
||||
bytes = Math.min(Number.MAX_SAFE_INTEGER, bytes + length);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6,18 +6,88 @@ function writeMessage(message) {
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
let supportsTypedSessionFailure = false;
|
||||
|
||||
async function handleRequest(request) {
|
||||
if (request.method === "initialize") {
|
||||
process.stderr.write("Error handling request { method: 'nes/close' } { code: -32601 }\n");
|
||||
const air = request.params?.clientCapabilities?._meta?.jetbrains?.air;
|
||||
supportsTypedSessionFailure =
|
||||
Number.isInteger(air?.version) &&
|
||||
air.version >= 1 &&
|
||||
Array.isArray(air?.capabilities) &&
|
||||
air.capabilities.includes("sessionFailure");
|
||||
process.stderr.write(
|
||||
"Error handling request { method: 'nes/close' } { code: -32601 }\n",
|
||||
);
|
||||
process.stderr.write("paperclip-acp-echo-agent started\n");
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
agentCapabilities: { loadSession: false, sessionCapabilities: { close: {} } },
|
||||
agentCapabilities: {
|
||||
loadSession: false,
|
||||
sessionCapabilities: { close: {} },
|
||||
},
|
||||
agentInfo: { name: "paperclip-acp-echo-agent", version: "1.0.0" },
|
||||
};
|
||||
}
|
||||
if (request.method === "session/new") return { sessionId: randomUUID() };
|
||||
if (request.method === "session/prompt") {
|
||||
const typedFailureCanary = process.env.PAPERCLIP_ACPX_TYPED_FAILURE_CANARY;
|
||||
if (typedFailureCanary) {
|
||||
if (!supportsTypedSessionFailure) {
|
||||
throw new Error(
|
||||
"client did not advertise typed session-failure support",
|
||||
);
|
||||
}
|
||||
const sessionFailure = {
|
||||
id: `${request.params.sessionId}:error`,
|
||||
revision: 1,
|
||||
category: "request",
|
||||
severity: "error",
|
||||
title: typedFailureCanary,
|
||||
actions: [],
|
||||
};
|
||||
writeMessage({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: request.params.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "session_info_update",
|
||||
_meta: { jetbrains: { air: { version: 1, sessionFailure } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
stopReason: "end_turn",
|
||||
_meta: { jetbrains: { air: { version: 1, sessionFailure } } },
|
||||
};
|
||||
}
|
||||
const typedWarningCanary = process.env.PAPERCLIP_ACPX_TYPED_WARNING_CANARY;
|
||||
let responseMeta;
|
||||
if (typedWarningCanary) {
|
||||
if (!supportsTypedSessionFailure) {
|
||||
throw new Error(
|
||||
"client did not advertise typed session-failure support",
|
||||
);
|
||||
}
|
||||
const sessionFailure = {
|
||||
id: `${request.params.sessionId}:warning`,
|
||||
revision: 1,
|
||||
category: "connection",
|
||||
severity: "warning",
|
||||
title: typedWarningCanary,
|
||||
actions: [],
|
||||
};
|
||||
responseMeta = { jetbrains: { air: { version: 1, sessionFailure } } };
|
||||
writeMessage({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: request.params.sessionId,
|
||||
update: { sessionUpdate: "session_info_update", _meta: responseMeta },
|
||||
},
|
||||
});
|
||||
}
|
||||
writeMessage({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
@@ -25,13 +95,24 @@ async function handleRequest(request) {
|
||||
sessionId: request.params.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: process.env.PAPERCLIP_ACPX_SPAWN_SMOKE ?? "missing" },
|
||||
content: {
|
||||
type: "text",
|
||||
text: process.env.PAPERCLIP_ACPX_SPAWN_SMOKE ?? "missing",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return { stopReason: "end_turn" };
|
||||
return {
|
||||
stopReason: "end_turn",
|
||||
...(responseMeta ? { _meta: responseMeta } : {}),
|
||||
};
|
||||
}
|
||||
if (request.method === "session/close" || request.method === "session/set_mode" || request.method === "session/set_config_option") return {};
|
||||
if (
|
||||
request.method === "session/close" ||
|
||||
request.method === "session/set_mode" ||
|
||||
request.method === "session/set_config_option"
|
||||
)
|
||||
return {};
|
||||
if (request.method === "session/cancel") return null;
|
||||
throw new Error(`Unsupported ACP method: ${request.method}`);
|
||||
}
|
||||
@@ -42,10 +123,15 @@ lines.on("line", async (line) => {
|
||||
try {
|
||||
request = JSON.parse(line);
|
||||
const result = await handleRequest(request);
|
||||
if (request.id !== undefined && result !== null) writeMessage({ jsonrpc: "2.0", id: request.id, result });
|
||||
if (request.id !== undefined && result !== null)
|
||||
writeMessage({ jsonrpc: "2.0", id: request.id, result });
|
||||
} catch (error) {
|
||||
if (request?.id !== undefined) {
|
||||
writeMessage({ jsonrpc: "2.0", id: request.id, error: { code: -32603, message: String(error?.message ?? error) } });
|
||||
writeMessage({
|
||||
jsonrpc: "2.0",
|
||||
id: request.id,
|
||||
error: { code: -32603, message: String(error?.message ?? error) },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env node
|
||||
// Deterministic ACP process for interruption and same-session continuation tests.
|
||||
import fs from 'node:fs';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { createInterface } from 'node:readline';
|
||||
const root = process.env.PAPERCLIP_STOP_FIXTURE_ROOT ?? process.cwd();
|
||||
const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
let active;
|
||||
let timer;
|
||||
if (process.env.PAPERCLIP_STOP_FIXTURE_HANG_ON_CLOSE === '1') {
|
||||
process.on('SIGTERM', () => {});
|
||||
setInterval(() => {}, 1000);
|
||||
}
|
||||
const update = (sessionId, value) => send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: value } });
|
||||
async function request(message) {
|
||||
fs.appendFileSync(`${root}/requests`, `${Date.now()} ${message.method}\n`);
|
||||
switch (message.method) {
|
||||
case 'initialize': return { protocolVersion: 1, agentCapabilities: { loadSession: true, sessionCapabilities: { close: {} } }, agentInfo: { name: 'stop-fixture', version: '1' } };
|
||||
case 'session/new': {
|
||||
const sessionId = randomUUID();
|
||||
fs.writeFileSync(`${root}/session`, sessionId);
|
||||
return { sessionId };
|
||||
}
|
||||
case 'session/load':
|
||||
if (!fs.existsSync(`${root}/session`) || fs.readFileSync(`${root}/session`, 'utf8') !== message.params.sessionId) throw Error('Unknown session');
|
||||
return {};
|
||||
case 'session/prompt': {
|
||||
fs.appendFileSync(`${root}/prompts`, `${JSON.stringify(message.params)}\n`);
|
||||
fs.appendFileSync(`${root}/run-env`, `${JSON.stringify({ runId: process.env.PAPERCLIP_RUN_ID, tokenHash: createHash('sha256').update(process.env.PAPERCLIP_API_KEY ?? '').digest('hex'), scratchDir: process.env.PAPERCLIP_RUN_SCRATCH_DIR })}\n`);
|
||||
if (fs.existsSync(`${root}/continued`)) {
|
||||
const paused = JSON.stringify(message.params.prompt).includes('tree-hold interaction: yes');
|
||||
if (!paused) {
|
||||
fs.appendFileSync(`${root}/completed`, 'follow-up\n');
|
||||
// Browser journeys finish the task through the normal agent API so
|
||||
// the scheduler does not need a separate successful-run handoff.
|
||||
if (process.env.PAPERCLIP_STOP_FIXTURE_FINISH_TASK === '1') {
|
||||
const base = process.env.PAPERCLIP_API_URL.replace(/\/api\/?$/, '').replace(/\/$/, '');
|
||||
const response = await fetch(`${base}/api/issues/${process.env.PAPERCLIP_TASK_ID}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json', authorization: `Bearer ${process.env.PAPERCLIP_API_KEY}`, 'X-Paperclip-Run-Id': process.env.PAPERCLIP_RUN_ID },
|
||||
body: JSON.stringify({ status: 'done' }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Task completion failed: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
}
|
||||
update(message.params.sessionId, { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: paused ? 'Task remains paused. Use Resume work to continue.' : 'Answered the pending follow-up once.' } });
|
||||
return { stopReason: 'end_turn' };
|
||||
}
|
||||
active = message;
|
||||
if (process.env.PAPERCLIP_STOP_FIXTURE_TOOL === 'read') {
|
||||
update(message.params.sessionId, { sessionUpdate: 'tool_call', toolCallId: 'read-1', title: 'Read local file', kind: 'read', status: 'completed' });
|
||||
}
|
||||
if (process.env.PAPERCLIP_STOP_FIXTURE_TOOL === 'write') {
|
||||
update(message.params.sessionId, { sessionUpdate: 'tool_call', toolCallId: 'write-1', title: 'Write local file', kind: 'edit', status: 'in_progress' });
|
||||
timer = setInterval(() => fs.appendFileSync(`${root}/writes`, 'tick\n'), 20);
|
||||
}
|
||||
update(message.params.sessionId, { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Waiting for Stop.' } });
|
||||
return undefined;
|
||||
}
|
||||
case 'session/cancel':
|
||||
clearInterval(timer);
|
||||
fs.writeFileSync(`${root}/continued`, 'ready');
|
||||
if (active) send({ jsonrpc: '2.0', id: active.id, result: { stopReason: 'cancelled' } });
|
||||
active = undefined;
|
||||
return undefined;
|
||||
case 'session/close': clearInterval(timer); return {};
|
||||
case 'session/set_mode': case 'session/set_config_option': return {};
|
||||
default: throw Error(`Unsupported method: ${message.method}`);
|
||||
}
|
||||
}
|
||||
createInterface({ input: process.stdin }).on('line', async (line) => {
|
||||
const message = JSON.parse(line);
|
||||
try {
|
||||
const result = await request(message);
|
||||
if (message.id !== undefined && result !== undefined) send({ jsonrpc: '2.0', id: message.id, result });
|
||||
} catch (error) {
|
||||
if (message.id !== undefined) send({ jsonrpc: '2.0', id: message.id, error: { code: -32603, message: error.message } });
|
||||
}
|
||||
});
|
||||
@@ -116,8 +116,8 @@ export function selectBundledDependencyPatches(
|
||||
return selectedPatches;
|
||||
}
|
||||
|
||||
export function applyBundledDependencyPatches(destinationDir, bundledDependencies) {
|
||||
const rootPackage = JSON.parse(readFileSync(resolve(repoRoot, "package.json"), "utf8"));
|
||||
export function applyBundledDependencyPatches(destinationDir, bundledDependencies, sourceRoot = repoRoot) {
|
||||
const rootPackage = JSON.parse(readFileSync(resolve(sourceRoot, "package.json"), "utf8"));
|
||||
const patchedDependencies = rootPackage.pnpm?.patchedDependencies ?? {};
|
||||
|
||||
for (const { packageName, patchPath } of selectBundledDependencyPatches(
|
||||
@@ -129,14 +129,14 @@ export function applyBundledDependencyPatches(destinationDir, bundledDependencie
|
||||
"patch",
|
||||
["-p1", "--forward", "-d", resolve(destinationDir, "node_modules", packageName)],
|
||||
{
|
||||
input: readFileSync(resolve(repoRoot, patchPath)),
|
||||
input: readFileSync(resolve(sourceRoot, patchPath)),
|
||||
stdio: ["pipe", "inherit", "inherit"],
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareBundledPackage(sourceDir, destinationDir) {
|
||||
export function prepareBundledPackage(sourceDir, destinationDir, { sourceRoot = repoRoot } = {}) {
|
||||
const sourcePackagePath = resolve(sourceDir, "package.json");
|
||||
const sourcePackage = JSON.parse(readFileSync(sourcePackagePath, "utf8"));
|
||||
const bundledDependencies = sourcePackage.bundleDependencies ?? sourcePackage.bundledDependencies ?? [];
|
||||
@@ -166,7 +166,7 @@ export function prepareBundledPackage(sourceDir, destinationDir) {
|
||||
{ cwd: destinationDir, stdio: "inherit" },
|
||||
);
|
||||
writeFileSync(deployedPackagePath, `${JSON.stringify(publishManifest, null, 2)}\n`);
|
||||
applyBundledDependencyPatches(destinationDir, bundledDependencies);
|
||||
applyBundledDependencyPatches(destinationDir, bundledDependencies, sourceRoot);
|
||||
|
||||
if (bundledDependencies.includes("acpx")) {
|
||||
const acpxPackage = JSON.parse(
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env node
|
||||
// Trusted release tooling. Packaging runs without publish credentials; publishing
|
||||
// accepts only the two fixed package artifacts and never executes their scripts.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync, writeFileSync, mkdirSync, cpSync, renameSync, appendFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import { createHash } from "node:crypto";
|
||||
import { materializePublishManifest, prepareBundledPackage } from "./prepare-bundled-package.mjs";
|
||||
|
||||
export const versionFor = (sha) => {
|
||||
if (!/^[0-9a-f]{40}$/.test(sha ?? "")) throw new Error("Preview builds require a full immutable commit SHA.");
|
||||
return `0.0.0-preview.g${sha}`;
|
||||
};
|
||||
export function validateRequest(sha, requestId) {
|
||||
versionFor(sha);
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(requestId ?? "")) throw new Error("A correlation UUID is required.");
|
||||
}
|
||||
|
||||
export function previewManifest(pkg, sha) {
|
||||
if (!["@paperclipai/shared", "@paperclipai/db"].includes(pkg.name)) throw new Error("Unexpected preview package.");
|
||||
const version = versionFor(sha);
|
||||
const exact = structuredClone(pkg);
|
||||
for (const section of ["dependencies", "optionalDependencies", "peerDependencies"]) {
|
||||
for (const [name, specifier] of Object.entries(exact[section] ?? {})) {
|
||||
if (typeof specifier === "string" && specifier.startsWith("workspace:")) exact[section][name] = version;
|
||||
}
|
||||
}
|
||||
const result = materializePublishManifest({ ...exact, version });
|
||||
result.gitHead = sha;
|
||||
result.paperclipPreviewCommit = sha;
|
||||
if (pkg.name === "@paperclipai/db") result.dependencies = { ...result.dependencies, "@paperclipai/shared": version };
|
||||
return result;
|
||||
}
|
||||
|
||||
export function assertMetadata(pkg, name, sha) {
|
||||
if (pkg?.publishConfig !== undefined || pkg?.name !== name || pkg.version !== versionFor(sha) || pkg.gitHead !== sha || pkg.paperclipPreviewCommit !== sha ||
|
||||
(name === "@paperclipai/db" && pkg.dependencies?.["@paperclipai/shared"] !== versionFor(sha))) {
|
||||
throw new Error("Preview package identity or dependency pin mismatch.");
|
||||
}
|
||||
}
|
||||
|
||||
export function tarManifest(bytes) {
|
||||
const tar = gunzipSync(bytes, { maxOutputLength: 128 * 1024 * 1024 });
|
||||
let manifest;
|
||||
for (let offset = 0; offset + 512 <= tar.length;) {
|
||||
const h = tar.subarray(offset, offset + 512);
|
||||
if (h.every((v) => v === 0)) break;
|
||||
const field = (start, size) => h.subarray(start, start + size).toString("utf8").split("\0")[0].trim();
|
||||
const sizeText = field(124, 12);
|
||||
if (!/^[0-7]+$/.test(sizeText)) throw new Error("Invalid package archive.");
|
||||
const size = Number.parseInt(sizeText, 8);
|
||||
if (offset + 512 + size > tar.length) throw new Error("Truncated package archive.");
|
||||
const name = `${field(345, 155) ? field(345, 155) + "/" : ""}${field(0, 100)}`;
|
||||
if (!name.startsWith("package/") || name.split("/").some((part) => part === "." || part === "..") || ![0, 48, 53].includes(h[156])) throw new Error("Unsupported package archive entry.");
|
||||
if (name === "package/package.json") {
|
||||
if (manifest || ![0, 48].includes(h[156])) throw new Error("Invalid package manifest entry.");
|
||||
manifest = JSON.parse(tar.subarray(offset + 512, offset + 512 + size).toString("utf8"));
|
||||
}
|
||||
offset += 512 + Math.ceil(size / 512) * 512;
|
||||
}
|
||||
if (!manifest) throw new Error("Missing package manifest.");
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export async function packageExists(name, sha, fetchImpl = fetch) {
|
||||
const response = await fetchImpl(`https://registry.npmjs.org/${encodeURIComponent(name)}/${versionFor(sha)}`, { signal: AbortSignal.timeout(30_000) });
|
||||
if (response.status === 404) return false;
|
||||
if (!response.ok) throw new Error(`npm lookup failed: HTTP ${response.status}`);
|
||||
const pkg = await response.json();
|
||||
assertMetadata(pkg, name, sha);
|
||||
if (!pkg.dist?.integrity || !pkg.dist?.tarball) throw new Error("Published preview has no immutable distribution pin.");
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function planArtifacts(sha, { migrator = false, image = true, fetchImpl = fetch } = {}) {
|
||||
versionFor(sha);
|
||||
return {
|
||||
image: image && !await imageExists(sha, fetchImpl),
|
||||
packages: migrator && !(await packageExists("@paperclipai/shared", sha, fetchImpl) && await packageExists("@paperclipai/db", sha, fetchImpl)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function imageExists(sha, fetchImpl = fetch) {
|
||||
versionFor(sha);
|
||||
const tokenRes = await fetchImpl("https://ghcr.io/token?service=ghcr.io&scope=repository:paperclipai/paperclip:pull", { signal: AbortSignal.timeout(30_000) });
|
||||
if (!tokenRes.ok) throw new Error(`GHCR lookup failed: HTTP ${tokenRes.status}`);
|
||||
const { token } = await tokenRes.json();
|
||||
if (typeof token !== "string") throw new Error("GHCR did not return a pull token.");
|
||||
const base = "https://ghcr.io/v2/paperclipai/paperclip";
|
||||
const headers = { Authorization: `Bearer ${token}`, Accept: "application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json" };
|
||||
const get = (url) => fetchImpl(url, { headers, redirect: "error", signal: AbortSignal.timeout(30_000) });
|
||||
let res = await get(`${base}/manifests/sha-${sha}-cloud`);
|
||||
if (res.status === 404) return false;
|
||||
if (!res.ok) throw new Error(`GHCR lookup failed: HTTP ${res.status}`);
|
||||
let manifest = await res.json();
|
||||
const digest = (value) => {
|
||||
if (typeof value !== "string" || !/^sha256:[0-9a-f]{64}$/.test(value)) throw new Error("Invalid image digest.");
|
||||
return value;
|
||||
};
|
||||
if (Array.isArray(manifest.manifests)) {
|
||||
const amd64 = manifest.manifests.find((entry) => entry.platform?.os === "linux" && entry.platform?.architecture === "amd64");
|
||||
if (!amd64) throw new Error("Cloud image has no Linux amd64 manifest.");
|
||||
res = await get(`${base}/manifests/${digest(amd64.digest)}`);
|
||||
if (!res.ok) throw new Error(`GHCR manifest lookup failed: HTTP ${res.status}`);
|
||||
manifest = await res.json();
|
||||
}
|
||||
res = await fetchImpl(`${base}/blobs/${digest(manifest.config?.digest)}`, { headers, redirect: "manual", signal: AbortSignal.timeout(30_000) });
|
||||
// Registry blob storage may redirect to its signed storage URL. Follow only
|
||||
// with no Authorization header, so the GHCR token cannot leave the registry.
|
||||
if ([301, 302, 307, 308].includes(res.status)) {
|
||||
const location = new URL(res.headers.get("location"));
|
||||
if (location.protocol !== "https:" || location.username || location.password) throw new Error("Invalid registry blob redirect.");
|
||||
res = await fetchImpl(location.href, { redirect: "error", signal: AbortSignal.timeout(30_000) });
|
||||
}
|
||||
if (!res.ok) throw new Error(`GHCR config lookup failed: HTTP ${res.status}`);
|
||||
const config = await res.json();
|
||||
if (config.config?.Labels?.["org.opencontainers.image.revision"] !== sha) throw new Error("Existing SHA image tag does not match the requested full commit.");
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Publication loads image data, but never runs a container or source scripts. */
|
||||
export async function publishImage(file, sha, { exec = execFileSync, fetchImpl = fetch } = {}) {
|
||||
versionFor(sha);
|
||||
const image = `ghcr.io/paperclipai/paperclip:sha-${sha}-cloud`;
|
||||
if (await imageExists(sha, fetchImpl)) { console.log("Reusing the verified SHA cloud image."); return; }
|
||||
exec("docker", ["load", "--input", path.resolve(file)], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 });
|
||||
const [metadata] = JSON.parse(exec("docker", ["image", "inspect", image], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }));
|
||||
if (metadata?.Config?.Labels?.["org.opencontainers.image.revision"] !== sha || metadata.Os !== "linux" || metadata.Architecture !== "amd64" ||
|
||||
!/^sha256:[0-9a-f]{64}$/.test(metadata.Id ?? "")) throw new Error("Built image identity or platform does not match the request.");
|
||||
// Push only this verified image ID under the one permitted tag, regardless
|
||||
// of any additional tag names present in the untrusted Docker archive.
|
||||
exec("docker", ["tag", metadata.Id, image], { stdio: "inherit" });
|
||||
exec("docker", ["push", image], { stdio: "inherit" });
|
||||
}
|
||||
|
||||
export function packPreview(source, output, sha, { exec = execFileSync } = {}) {
|
||||
versionFor(sha);
|
||||
source = path.resolve(source); output = path.resolve(output);
|
||||
if (exec("git", ["rev-parse", "HEAD"], { cwd: source, encoding: "utf8" }).trim() !== sha) throw new Error("Source checkout differs from the requested commit.");
|
||||
mkdirSync(output, { recursive: true });
|
||||
for (const short of ["shared", "db"]) {
|
||||
exec("pnpm", ["--filter", `@paperclipai/${short}`, "build"], { cwd: source, stdio: "inherit" });
|
||||
const packageDir = path.join(source, "packages", short);
|
||||
const originalText = readFileSync(path.join(packageDir, "package.json"), "utf8");
|
||||
const original = JSON.parse(originalText);
|
||||
const pkg = previewManifest(original, sha);
|
||||
const staging = path.join(output, `package-${short}`);
|
||||
if ((pkg.bundleDependencies ?? []).length) {
|
||||
// The established helper materializes patched embedded-postgres instead
|
||||
// of publishing pnpm's dependency symlinks.
|
||||
writeFileSync(path.join(packageDir, "package.json"), JSON.stringify(pkg));
|
||||
try { prepareBundledPackage(packageDir, staging, { sourceRoot: source }); }
|
||||
finally { writeFileSync(path.join(packageDir, "package.json"), originalText); }
|
||||
} else {
|
||||
mkdirSync(staging, { recursive: true });
|
||||
cpSync(path.join(packageDir, "dist"), path.join(staging, "dist"), { recursive: true });
|
||||
writeFileSync(path.join(staging, "package.json"), JSON.stringify(pkg));
|
||||
}
|
||||
const packed = JSON.parse(exec("npx", ["--yes", "npm@10.9.7", "pack", "--ignore-scripts", "--json", "--pack-destination", output], { cwd: staging, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }));
|
||||
renameSync(path.join(output, path.basename(packed[0].filename)), path.join(output, `${short}.tgz`));
|
||||
assertMetadata(tarManifest(readFileSync(path.join(output, `${short}.tgz`))), `@paperclipai/${short}`, sha);
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishPreview(dir, sha, { fetchImpl = fetch, exec = execFileSync, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
||||
for (const short of ["shared", "db"]) {
|
||||
const name = `@paperclipai/${short}`;
|
||||
const file = path.resolve(dir, `${short}.tgz`);
|
||||
const bytes = readFileSync(file);
|
||||
assertMetadata(tarManifest(bytes), name, sha);
|
||||
if (await packageExists(name, sha, fetchImpl)) { console.log(`Reusing ${name}@${versionFor(sha)}`); continue; }
|
||||
console.log(`Publishing ${name}@${versionFor(sha)} (${createHash("sha256").update(bytes).digest("hex").slice(0, 12)})`);
|
||||
// No package checkout, lifecycle scripts, npmrc, or branch code runs here.
|
||||
exec("npm", ["publish", file, "--tag", "preview", "--access", "public", "--ignore-scripts", "--provenance", "--registry", "https://registry.npmjs.org"], { stdio: "inherit" });
|
||||
let published = false;
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
if (await packageExists(name, sha, fetchImpl)) { published = true; break; }
|
||||
await sleep(10_000);
|
||||
}
|
||||
if (!published) throw new Error("npm accepted the preview but it is not yet visible. Retry reuses published packages.");
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const [command, ...args] = process.argv.slice(2);
|
||||
try {
|
||||
if (command === "plan" || command === "plan-migrator") {
|
||||
const [sha, requestId, migrator] = args;
|
||||
validateRequest(sha, requestId);
|
||||
if (process.env.GITHUB_REF !== "refs/heads/master") throw new Error("Preview workflow definitions must run from master.");
|
||||
const { image, packages } = await planArtifacts(sha, {
|
||||
image: command === "plan", migrator: command === "plan-migrator" || migrator === "true",
|
||||
});
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `image=${image}\npackages=${packages}\n`);
|
||||
} else if (command === "pack") packPreview(...args);
|
||||
else if (command === "publish") await publishPreview(...args);
|
||||
else if (command === "publish-image") await publishImage(...args);
|
||||
else if (command === "result") {
|
||||
const [sha, requestId] = args;
|
||||
validateRequest(sha, requestId);
|
||||
if (!await imageExists(sha)) throw new Error("Cloud image is still missing.");
|
||||
if (process.env.PREVIEW_MIGRATOR === "true" && !(await packageExists("@paperclipai/shared", sha) && await packageExists("@paperclipai/db", sha))) throw new Error("Preview packages are still missing.");
|
||||
mkdirSync("stack-deploy-result", { recursive: true });
|
||||
writeFileSync("stack-deploy-result/result.json", JSON.stringify({ version: 1, stage: "build", requestId, sha, status: "ready" }) + "\n");
|
||||
} else throw new Error("Expected plan, plan-migrator, pack, publish, publish-image, or result.");
|
||||
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { planArtifacts } from "./preview-artifacts.mjs";
|
||||
import { readFileSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { gzipSync } from "node:zlib";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { previewManifest, assertMetadata, validateRequest, versionFor, tarManifest, packageExists, imageExists, publishPreview, publishImage } from "./preview-artifacts.mjs";
|
||||
|
||||
const sha = "a".repeat(40);
|
||||
const id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const manifest = (name) => previewManifest({ name, version: "0.0.0", dependencies: name.endsWith("/db") ? { "@paperclipai/shared": "workspace:*" } : {}, publishConfig: { exports: { ".": "./dist/index.js" } } }, sha);
|
||||
function pack(pkg) {
|
||||
const b = Buffer.from(JSON.stringify(pkg)); const h = Buffer.alloc(512);
|
||||
h.write("package/package.json"); h.write(b.length.toString(8).padStart(11, "0"), 124, 11); h[156] = 48;
|
||||
const padded = Buffer.alloc(Math.ceil(b.length / 512) * 512); b.copy(padded);
|
||||
return gzipSync(Buffer.concat([h, padded, Buffer.alloc(1024)]));
|
||||
}
|
||||
const json = (body, status = 200) => new Response(JSON.stringify(body), { status });
|
||||
|
||||
test("preview request requires immutable SHA and correlation UUID", () => {
|
||||
validateRequest(sha, id);
|
||||
for (const ref of ["master", "origin/master", "a".repeat(7), "$(unsafe)", "A".repeat(40)]) assert.throws(() => versionFor(ref));
|
||||
assert.throws(() => validateRequest(sha, "not-a-request"));
|
||||
});
|
||||
|
||||
test("migrator-only planning never waits for GHCR and reuses complete exact-source packages", async () => {
|
||||
for (const available of [[], ["@paperclipai/shared"], ["@paperclipai/shared", "@paperclipai/db"]]) {
|
||||
const calls = [];
|
||||
const result = await planArtifacts(sha, { image: false, migrator: true, fetchImpl: async (url) => {
|
||||
assert.equal(new URL(url).hostname, "registry.npmjs.org");
|
||||
const name = decodeURIComponent(new URL(url).pathname.split("/")[1]);
|
||||
calls.push(name);
|
||||
return available.includes(name) ? json({ ...manifest(name), dist: { integrity: "test-integrity", tarball: "https://registry.npmjs.org/package.tgz" } }) : json({}, 404);
|
||||
} });
|
||||
assert.deepEqual(result, { image: false, packages: available.length !== 2 });
|
||||
assert.ok(calls.includes("@paperclipai/shared"));
|
||||
if (available.length) assert.ok(calls.includes("@paperclipai/db"));
|
||||
}
|
||||
});
|
||||
|
||||
test("migrator-only planning rejects registry outages and mismatched source identity", async () => {
|
||||
for (const response of [json({}, 403), json({}, 503), json({ ...manifest("@paperclipai/shared"), gitHead: "b".repeat(40) })]) {
|
||||
await assert.rejects(planArtifacts(sha, { image: false, migrator: true, fetchImpl: async () => response }));
|
||||
}
|
||||
});
|
||||
|
||||
test("ordinary preview planning still requests a missing image without publishing unsolicited packages", async () => {
|
||||
const result = await planArtifacts(sha, { fetchImpl: async (url) => {
|
||||
assert.equal(new URL(url).hostname, "ghcr.io");
|
||||
return url.includes("/token?") ? json({ token: "test-pull-token" }) : json({}, 404);
|
||||
} });
|
||||
assert.deepEqual(result, { image: true, packages: false });
|
||||
});
|
||||
|
||||
test("preview manifests carry exact source, isolated versions and shared dependency", () => {
|
||||
const pkg = manifest("@paperclipai/db");
|
||||
assert.equal(pkg.version, `0.0.0-preview.g${sha}`);
|
||||
assert.equal(pkg.dependencies["@paperclipai/shared"], pkg.version);
|
||||
assert.deepEqual(pkg.exports, { ".": "./dist/index.js" });
|
||||
assertMetadata(pkg, "@paperclipai/db", sha);
|
||||
assert.throws(() => assertMetadata({ ...pkg, gitHead: "b".repeat(40) }, pkg.name, sha));
|
||||
assert.throws(() => assertMetadata({ ...pkg, dependencies: { "@paperclipai/shared": "latest" } }, pkg.name, sha));
|
||||
assert.deepEqual(tarManifest(pack(pkg)), pkg);
|
||||
});
|
||||
|
||||
test("only 404 means an artifact is missing; auth and outages are fatal", async () => {
|
||||
assert.equal(await packageExists("@paperclipai/db", sha, async () => json({}, 404)), false);
|
||||
await assert.rejects(packageExists("@paperclipai/db", sha, async () => json({}, 403)));
|
||||
await assert.rejects(packageExists("@paperclipai/db", sha, async () => json({}, 503)));
|
||||
await assert.rejects(imageExists(sha, async () => json({}, 503)));
|
||||
assert.equal(await imageExists(sha, async (url) => url.includes("/token?") ? json({ token: "test-pull-token" }) : json({}, 404)), false);
|
||||
await assert.rejects(packageExists("@paperclipai/db", sha, async () => json({ ...manifest("@paperclipai/db"), gitHead: "b".repeat(40) })));
|
||||
});
|
||||
|
||||
test("publishing reuses existing previews and never executes package lifecycle hooks", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "preview-publish-test-"));
|
||||
const published = new Set(["@paperclipai/shared"]);
|
||||
const calls = [];
|
||||
try {
|
||||
for (const short of ["shared", "db"]) writeFileSync(path.join(dir, `${short}.tgz`), pack({ ...manifest(`@paperclipai/${short}`), scripts: { prepublishOnly: "do-not-run" } }));
|
||||
await publishPreview(dir, sha, {
|
||||
fetchImpl: async (url) => {
|
||||
const name = decodeURIComponent(new URL(url).pathname.split("/")[1]);
|
||||
return published.has(name) ? json({ ...manifest(name), dist: { integrity: "test-integrity", tarball: "https://registry.npmjs.org/package.tgz" } }) : json({}, 404);
|
||||
},
|
||||
exec: (command, args) => { calls.push({ command, args }); published.add("@paperclipai/db"); },
|
||||
sleep: async () => {},
|
||||
});
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].command, "npm");
|
||||
assert.ok(calls[0].args.includes("--ignore-scripts"));
|
||||
assert.equal(calls[0].args[calls[0].args.indexOf("--tag") + 1], "preview");
|
||||
assert.ok(!calls[0].args.includes("canary"));
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("preview workflow separates branch compilation from trusted publishing", () => {
|
||||
const workflow = readFileSync(new URL("../.github/workflows/release.yml", import.meta.url), "utf8");
|
||||
const builder = workflow.split(" package_preview:")[1].split(" publish_preview:")[0];
|
||||
const publisher = workflow.split(" publish_preview:")[1].split(" image_preview:")[0];
|
||||
const image = workflow.split(" image_preview:")[1].split(" publish_image_preview:")[0];
|
||||
const imagePublisher = workflow.split(" publish_image_preview:")[1].split(" result_preview:")[0];
|
||||
assert.doesNotMatch(builder, /id-token: write|packages: write|secrets\./);
|
||||
assert.doesNotMatch(publisher, /ref: \$\{\{ inputs.source_ref|working-directory: source|pnpm install/);
|
||||
assert.match(publisher, /environment: npm-canary/);
|
||||
assert.match(image, /PAPERCLIP_BUILD_COMMIT=\$\{\{ inputs.source_ref \}\}/);
|
||||
assert.doesNotMatch(image, /cache-(?:to|from):|canary-cloud|latest-cloud|packages: write|secrets\./);
|
||||
assert.doesNotMatch(imagePublisher, /ref: \$\{\{ inputs.source_ref|docker\/build-push-action|pnpm install/);
|
||||
assert.match(imagePublisher, /publish-image/);
|
||||
assert.match(imagePublisher, /environment: npm-canary/);
|
||||
assert.match(imagePublisher, /github.ref == 'refs\/heads\/master'/);
|
||||
assert.match(publisher, /github.ref == 'refs\/heads\/master'/);
|
||||
assert.doesNotMatch(workflow.split(" verify_canary:")[0], /uses: [^\n]+@v\d/);
|
||||
assert.match(workflow, /Stack deploy \{0\} build/);
|
||||
});
|
||||
|
||||
test("merge dispatch uses the existing publisher outside full-release concurrency without claiming image readiness", () => {
|
||||
const dispatcher = readFileSync(new URL("../.github/workflows/cloud-artifacts.yml", import.meta.url), "utf8");
|
||||
const release = readFileSync(new URL("../.github/workflows/release.yml", import.meta.url), "utf8");
|
||||
assert.match(dispatcher, /branches: \[master\]/);
|
||||
assert.match(dispatcher, /github.ref == 'refs\/heads\/master'/);
|
||||
assert.match(dispatcher, /SOURCE_SHA: \$\{\{ github.sha \}\}/);
|
||||
assert.match(dispatcher, /gh workflow run release.yml .*--ref master/);
|
||||
assert.match(dispatcher, /--field channel=cloud-migrator/);
|
||||
assert.doesNotMatch(dispatcher, /actions\/checkout|id-token: write|packages: write|secrets\./);
|
||||
assert.match(release, /\(inputs.channel == 'preview' \|\| inputs.channel == 'cloud-migrator'\) && format\('\{0\}-\{1\}', inputs.channel, inputs.source_ref\)/);
|
||||
const publisher = release.split(" publish_preview:")[1].split(" image_preview:")[0];
|
||||
assert.match(publisher, /group: preview-package-publish-\$\{\{ inputs.source_ref \}\}/);
|
||||
assert.match(publisher, /cancel-in-progress: false/);
|
||||
assert.match(release, /PLAN_COMMAND: \$\{\{ inputs.channel == 'cloud-migrator' && 'plan-migrator' \|\| 'plan' \}\}/);
|
||||
const result = release.split(" result_preview:")[1].split(" verify_canary:")[0];
|
||||
assert.match(result, /always\(\) && inputs.channel == 'preview'/);
|
||||
});
|
||||
|
||||
|
||||
test("existing image reuse verifies the full revision behind the immutable tag", async () => {
|
||||
const digest = "sha256:" + "b".repeat(64);
|
||||
for (const revision of [sha, "c".repeat(40)]) {
|
||||
const fetchImpl = async (url) => url.includes("/token?") ? json({ token: "test-pull-token" }) :
|
||||
url.includes("/blobs/") ? json({ config: { Labels: { "org.opencontainers.image.revision": revision } } }) :
|
||||
url.endsWith(digest) ? json({ config: { digest } }) : json({ manifests: [{ digest, platform: { os: "linux", architecture: "amd64" } }] });
|
||||
if (revision === sha) assert.equal(await imageExists(sha, fetchImpl), true);
|
||||
else await assert.rejects(imageExists(sha, fetchImpl), /full commit/);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("image publisher verifies source and platform before pushing exactly one immutable tag", async () => {
|
||||
for (const revision of [sha, "c".repeat(40)]) {
|
||||
const calls = [];
|
||||
const operation = publishImage("preview-image.tar", sha, {
|
||||
fetchImpl: async (url) => url.includes("/token?") ? json({ token: "test-pull-token" }) : json({}, 404),
|
||||
exec: (command, args) => {
|
||||
calls.push({ command, args });
|
||||
if (args[0] === "image") return JSON.stringify([{ Id: "sha256:" + "b".repeat(64), Os: "linux", Architecture: "amd64", Config: { Labels: { "org.opencontainers.image.revision": revision } } }]);
|
||||
return "";
|
||||
},
|
||||
});
|
||||
if (revision === sha) {
|
||||
await operation;
|
||||
assert.deepEqual(calls.filter((call) => call.args[0] === "push").map((call) => call.args), [["push", `ghcr.io/paperclipai/paperclip:sha-${sha}-cloud`]]);
|
||||
} else { await assert.rejects(operation, /identity/); assert.ok(!calls.some((call) => call.args[0] === "push")); }
|
||||
assert.ok(!calls.some((call) => ["run", "build"].includes(call.args[0])));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("commits sharing a short prefix use separate full-SHA image addresses", async () => {
|
||||
const urls = [];
|
||||
const fetchImpl = async (url) => { urls.push(url); return url.includes("/token?") ? json({ token: "test-pull-token" }) : json({}, 404); };
|
||||
const other = sha.slice(0, 7) + "b".repeat(33);
|
||||
await imageExists(sha, fetchImpl);
|
||||
await imageExists(other, fetchImpl);
|
||||
assert.deepEqual(urls.filter((url) => url.includes("/manifests/")), [sha, other].map((commit) => `https://ghcr.io/v2/paperclipai/paperclip/manifests/sha-${commit}-cloud`));
|
||||
});
|
||||
|
||||
test("cloud builds start per commit and preserve tag promotion dependencies", () => {
|
||||
const docker = readFileSync(new URL("../.github/workflows/docker.yml", import.meta.url), "utf8");
|
||||
const cloud = readFileSync(new URL("../.github/workflows/docker-cloud.yml", import.meta.url), "utf8");
|
||||
const readiness = readFileSync(new URL("../.github/workflows/cloud-readiness.yml", import.meta.url), "utf8");
|
||||
assert.match(readiness, /branches: \[master\]/);
|
||||
assert.match(readiness, /uses: \.\/\.github\/workflows\/docker-cloud.yml/);
|
||||
assert.doesNotMatch(cloud, /^ push:/m);
|
||||
assert.match(cloud, /workflow_call:/);
|
||||
assert.match(cloud, /group: docker-cloud-\$\{\{ github.sha \}\}/);
|
||||
assert.match(cloud, /cancel-in-progress: false/);
|
||||
assert.doesNotMatch(cloud, /uses: .*@v\d\b/);
|
||||
assert.match(cloud, /cache-to: type=registry,ref=ghcr.io\/\$\{\{ github.repository \}\}:buildcache-cloud-\$\{\{ github.sha \}\},mode=max/);
|
||||
const caller = docker.split(" build-and-push-cloud:")[1].split(" promote_canary_channel:")[0];
|
||||
assert.match(caller, /if: github.event_name != 'push' \|\| github.ref != 'refs\/heads\/master'/);
|
||||
assert.match(caller, /uses: .\/.github\/workflows\/docker-cloud.yml/);
|
||||
assert.match(docker.split(" promote_canary_channel:")[1], /needs: \[merge-and-push, build-and-push-cloud\]/);
|
||||
const reaping = cloud.indexOf(" - name: Verify cloud PID 1 reaps orphaned processes");
|
||||
assert.ok(reaping > cloud.indexOf(" - name: Verify the pushed image resolves the declared Sentry version"));
|
||||
assert.ok(reaping < cloud.indexOf(" - name: Publish verified full-SHA cloud tag"));
|
||||
});
|
||||
|
||||
test("cloud builds bake the managed runtime identity and verify it before publication", () => {
|
||||
const workflow = readFileSync(new URL("../.github/workflows/docker-cloud.yml", import.meta.url), "utf8");
|
||||
const build = workflow.split(" - name: Build and push (cloud)")[1].split(" - name:")[0];
|
||||
assert.match(build, /build-args: \|\n\s+USER_UID=1001\n\s+USER_GID=1001\n/);
|
||||
const verify = workflow.indexOf(" - name: Verify cloud runtime user");
|
||||
assert.ok(verify > workflow.indexOf(" - name: Verify the pushed image resolves the declared Sentry version"));
|
||||
assert.ok(verify < workflow.indexOf(" - name: Publish verified full-SHA cloud tag"));
|
||||
const step = workflow.slice(verify).split("\n - name:")[0];
|
||||
assert.match(step, /IMAGE: ghcr.io\/\$\{\{ github.repository \}\}@\$\{\{ steps.build-cloud.outputs.digest \}\}/);
|
||||
assert.doesNotMatch(step, /continue-on-error:|if:/);
|
||||
assert.ok(step.indexOf('--entrypoint sh "$IMAGE"') < step.indexOf('-e USER_UID=1001 -e USER_GID=1001'));
|
||||
for (const flag of ["u", "g"]) {
|
||||
assert.ok(step.includes(`test "$(id -${flag} node)" = 1001`));
|
||||
assert.ok(step.includes(`test "$(id -${flag})" = 1001`));
|
||||
}
|
||||
assert.ok(step.includes('test -w "$PAPERCLIP_HOME"'));
|
||||
});
|
||||
|
||||
test("cloud cache imports are bounded, follow master ancestry, and retain the legacy fallback", () => {
|
||||
const workflow = readFileSync(new URL("../.github/workflows/docker-cloud.yml", import.meta.url), "utf8");
|
||||
const step = workflow.split(" - name: Select cloud cache ancestry")[1].split(" - name: Setup pnpm")[0];
|
||||
const script = step.split(" run: |\n")[1].split("\n").map((line) => line.replace(/^ {10}/, "")).join("\n");
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "cloud-cache-test-"));
|
||||
const output = path.join(dir, "output");
|
||||
const env = { ...process.env, GIT_AUTHOR_NAME: "Test", GIT_AUTHOR_EMAIL: "test@example.test", GIT_COMMITTER_NAME: "Test", GIT_COMMITTER_EMAIL: "test@example.test" };
|
||||
const git = (...args) => execFileSync("git", ["-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false", ...args], { cwd: dir, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
||||
try {
|
||||
git("init", "--initial-branch=master");
|
||||
const commits = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
git("commit", "--allow-empty", "-m", `main ${i}`);
|
||||
commits.unshift(git("rev-parse", "HEAD"));
|
||||
}
|
||||
git("checkout", "-b", "topic", "HEAD~1");
|
||||
git("commit", "--allow-empty", "-m", "topic");
|
||||
git("checkout", "master");
|
||||
git("merge", "--no-ff", "topic", "-m", "merge topic");
|
||||
commits.unshift(git("rev-parse", "HEAD"));
|
||||
const result = spawnSync("bash", ["-c", script], { cwd: dir, encoding: "utf8", env: { ...env, CACHE_IMAGE: "ghcr.io/paperclipai/paperclip", GITHUB_OUTPUT: output } });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(readFileSync(output, "utf8").trim().split("\n"), [
|
||||
"sources<<CACHE_SOURCES",
|
||||
...commits.slice(0, 10).map((commit) => `type=registry,ref=ghcr.io/paperclipai/paperclip:buildcache-cloud-${commit}`),
|
||||
"type=registry,ref=ghcr.io/paperclipai/paperclip:buildcache-cloud",
|
||||
"CACHE_SOURCES",
|
||||
]);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("normal cloud builds publish the checked digest only when source and platform match", () => {
|
||||
const workflow = readFileSync(new URL("../.github/workflows/docker-cloud.yml", import.meta.url), "utf8");
|
||||
const cloud = workflow.split(" build-and-push-cloud:")[1];
|
||||
const verify = cloud.indexOf(" - name: Verify the pushed image resolves the declared Sentry version");
|
||||
const publish = cloud.indexOf(" - name: Publish verified full-SHA cloud tag");
|
||||
assert.ok(verify >= 0 && publish > verify);
|
||||
const verification = cloud.slice(verify, publish);
|
||||
assert.match(verification, /IMAGE: ghcr.io\/\$\{\{ github.repository \}\}@\$\{\{ steps.build-cloud.outputs.digest \}\}/);
|
||||
assert.doesNotMatch(verification, /continue-on-error:|if: always\(/);
|
||||
const step = cloud.slice(publish).split(/\n(?: #| - name:)/)[0];
|
||||
assert.doesNotMatch(step, /continue-on-error:|if:/);
|
||||
assert.match(step, /FULL_SHA_TAG: ghcr.io\/\$\{\{ github.repository \}\}:sha-\$\{\{ github.sha \}\}-cloud/);
|
||||
const script = step.split(" run: |\n")[1].split("\n").map((line) => line.replace(/^ {10}/, "")).join("\n");
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "cloud-tag-test-"));
|
||||
const image = `ghcr.io/paperclipai/paperclip@sha256:${"b".repeat(64)}`;
|
||||
const tag = `ghcr.io/paperclipai/paperclip:sha-${sha}-cloud`;
|
||||
try {
|
||||
writeFileSync(path.join(dir, "docker"), `#!/bin/sh
|
||||
case "$1 $2" in
|
||||
'image inspect')
|
||||
case "$5" in
|
||||
*revision*) printf '%s\\n' "$TEST_REVISION" ;;
|
||||
*) printf '%s\\n' "$TEST_PLATFORM" ;;
|
||||
esac ;;
|
||||
'buildx imagetools') printf '%s\\n' "$@" > "$TEST_CALLS" ;;
|
||||
*) exit 99 ;;
|
||||
esac
|
||||
`, { mode: 0o755 });
|
||||
for (const [revision, platform, succeeds] of [[sha, "linux/amd64", true], ["c".repeat(40), "linux/amd64", false], [sha, "linux/arm64", false]]) {
|
||||
const calls = path.join(dir, "calls");
|
||||
rmSync(calls, { force: true });
|
||||
const result = spawnSync("bash", ["-c", script], { encoding: "utf8", env: {
|
||||
...process.env, PATH: `${dir}${path.delimiter}${process.env.PATH}`, GITHUB_SHA: sha,
|
||||
IMAGE: image, FULL_SHA_TAG: tag, TEST_REVISION: revision, TEST_PLATFORM: platform, TEST_CALLS: calls,
|
||||
} });
|
||||
if (succeeds) {
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(readFileSync(calls, "utf8").trim().split("\n"), ["buildx", "imagetools", "create", "--prefer-index=false", "--tag", tag, image]);
|
||||
} else {
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.throws(() => readFileSync(calls), { code: "ENOENT" });
|
||||
}
|
||||
}
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -692,6 +692,14 @@ function walk(dir) {
|
||||
}
|
||||
|
||||
walk(root);
|
||||
// package.json is the pnpm 9 patch manifest for this repository. Hash the
|
||||
// declared paths, including non-.patch filenames and patches outside patches/.
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
||||
for (const patch of Object.values(manifest.pnpm?.patchedDependencies ?? {})) {
|
||||
if (typeof patch !== "string") throw new Error("Invalid pnpm patch path");
|
||||
const file = path.resolve(root, patch);
|
||||
if (!files.includes(file)) files.push(file);
|
||||
}
|
||||
files.sort((left, right) => path.relative(root, left).localeCompare(path.relative(root, right)));
|
||||
|
||||
const hash = crypto.createHash("sha256");
|
||||
@@ -768,7 +776,7 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t
|
||||
}
|
||||
|
||||
run_pnpm_install() {
|
||||
local stdout_path stderr_path
|
||||
local stdout_path stderr_path exit_code
|
||||
stdout_path="$(mktemp)"
|
||||
stderr_path="$(mktemp)"
|
||||
|
||||
@@ -783,12 +791,13 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t
|
||||
cat "$stderr_path" >&2
|
||||
rm -f "$stdout_path" "$stderr_path"
|
||||
return 0
|
||||
else
|
||||
exit_code=$?
|
||||
fi
|
||||
|
||||
local exit_code=$?
|
||||
cat "$stdout_path"
|
||||
cat "$stderr_path" >&2
|
||||
if grep -q "ERR_PNPM_OUTDATED_LOCKFILE" "$stdout_path" "$stderr_path"; then
|
||||
if grep -Eq "ERR_PNPM_(OUTDATED_LOCKFILE|LOCKFILE_CONFIG_MISMATCH)" "$stdout_path" "$stderr_path"; then
|
||||
rm -f "$stdout_path" "$stderr_path"
|
||||
return 90
|
||||
fi
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readdirSync, realpathSync, statSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadShardDurations, selectGeneralServerShard } from "./general-server-shard.mjs";
|
||||
|
||||
import { assertSelectedTests, partitionTestLines } from "./test-line-shard.mjs";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const scriptsDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const generalServerShardDurations = loadShardDurations(
|
||||
@@ -66,11 +68,15 @@ const serializedModeName = "serialized";
|
||||
const generalModeName = "general";
|
||||
const allModeName = "all";
|
||||
const generalServerGroupName = "general-server";
|
||||
const generalServerWithoutChatGroupName = "general-server-without-chat";
|
||||
const generalChatGroupName = "general-chat";
|
||||
const chatSuite = "server/src/__tests__/chat-channels.integration.test.ts";
|
||||
const generalWorkspacesAGroupName = "general-workspaces-a";
|
||||
const generalWorkspacesBGroupName = "general-workspaces-b";
|
||||
const generalWorkspacesAProjects = ["@paperclipai/ui", "paperclipai"];
|
||||
const generalWorkspacesBProjects = nonServerProjects.filter((project) => !generalWorkspacesAProjects.includes(project));
|
||||
const generalGroupNames = [generalServerGroupName, generalWorkspacesAGroupName, generalWorkspacesBGroupName];
|
||||
const allowedGeneralGroupNames = [...generalGroupNames, generalServerWithoutChatGroupName, generalChatGroupName];
|
||||
const serializedServerVitestArgs = [
|
||||
"--no-file-parallelism",
|
||||
"--maxWorkers=1",
|
||||
@@ -216,10 +222,10 @@ function parseCliOptions(argv) {
|
||||
const shardAllowed =
|
||||
mode === serializedModeName ||
|
||||
(mode === generalModeName &&
|
||||
(group === generalServerGroupName || group === generalWorkspacesAGroupName));
|
||||
([generalServerGroupName, generalServerWithoutChatGroupName, generalChatGroupName, generalWorkspacesAGroupName].includes(group)));
|
||||
if (!shardAllowed && shardIndex !== null) {
|
||||
fail(
|
||||
"--shard-index/--shard-count are only valid with --mode serialized, --mode general --group general-server, or --mode general --group general-workspaces-a.",
|
||||
"--shard-index/--shard-count are only valid with serialized mode or a shardable general server/chat/workspaces-a group.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,8 +233,8 @@ function parseCliOptions(argv) {
|
||||
fail("--group is only valid with --mode general.");
|
||||
}
|
||||
|
||||
if (group !== null && !generalGroupNames.includes(group)) {
|
||||
fail(`Unknown group "${group}". Expected one of: ${generalGroupNames.join(", ")}.`);
|
||||
if (group !== null && !allowedGeneralGroupNames.includes(group)) {
|
||||
fail(`Unknown group "${group}". Expected one of: ${allowedGeneralGroupNames.join(", ")}.`);
|
||||
}
|
||||
|
||||
if (shardIndex !== null) {
|
||||
@@ -271,22 +277,45 @@ function selectSerializedSuites(routeTests, shardIndex, shardCount) {
|
||||
return shardFiles.map((file) => byRepoPath.get(file));
|
||||
}
|
||||
|
||||
function runVitest(args, label) {
|
||||
function runVitest(args, label, testShard = null) {
|
||||
console.log(`\n[test:run] ${label}`);
|
||||
invocationIndex += 1;
|
||||
const tempRootParent = process.platform === "win32" ? os.tmpdir() : "/tmp";
|
||||
// Canonical roots keep security fixtures valid on macOS, where /tmp is a symlink.
|
||||
const testRoot = realpathSync(mkdtempSync(path.join(tempRootParent, `pcvt-${process.pid}-${invocationIndex}-`)));
|
||||
// Production workspace/security checks reject symlink aliases. In particular
|
||||
// /tmp is /private/tmp on macOS, so fixture roots must use the canonical path.
|
||||
const testRoot = realpathSync(mkdtempSync(path.join(tempRootParent, "pv-")));
|
||||
// Keep per-run paths compact so Unix socket fixtures stay under macOS path limits.
|
||||
const env = {
|
||||
...process.env,
|
||||
NODE_ENV: "test",
|
||||
PAPERCLIP_HOME: path.join(testRoot, "h"),
|
||||
// Config discovery otherwise prefers the checkout's .paperclip/config.json
|
||||
// over PAPERCLIP_HOME, importing preview scheduling policy into unit tests.
|
||||
PAPERCLIP_CONFIG: path.join(testRoot, "h", "config.json"),
|
||||
PAPERCLIP_INSTANCE_ID: `vt-${process.pid}-${invocationIndex}`,
|
||||
TMPDIR: path.join(testRoot, "t"),
|
||||
};
|
||||
mkdirSync(env.PAPERCLIP_HOME, { recursive: true });
|
||||
mkdirSync(env.TMPDIR, { recursive: true });
|
||||
if (testShard) {
|
||||
const collect = (filters, name) => {
|
||||
const output = path.join(testRoot, `${name}.json`);
|
||||
const result = spawnSync("pnpm", ["exec", "vitest", "list", ...sourceOnlyVitestArgs,
|
||||
...filters, "--allowOnly=false", "--includeTaskLocation", `--json=${output}`], {
|
||||
cwd: repoRoot, env, stdio: "inherit",
|
||||
});
|
||||
if (result.error || result.status !== 0) fail(`Vitest collection failed: ${result.error?.message ?? result.status}`);
|
||||
return JSON.parse(readFileSync(output, "utf8"));
|
||||
};
|
||||
const collected = collect(args, "all");
|
||||
const file = path.resolve(repoRoot, chatSuite);
|
||||
const selected = partitionTestLines(collected, testShard.count, file)[testShard.index];
|
||||
const filters = selected.lines.map((line) => `${chatSuite}:${line}`);
|
||||
args = [...args.filter((arg) => arg !== chatSuite), ...filters];
|
||||
assertSelectedTests(selected.tests, collect(args, "selected"), file);
|
||||
console.log(`[test:run] chat shard ${testShard.index + 1}/${testShard.count}: ${selected.tests.length}/${collected.length} tests, ${selected.lines.length} source lines; exact filter coverage verified`);
|
||||
args.push("--allowOnly=false");
|
||||
}
|
||||
const result = spawnSync("pnpm", ["exec", "vitest", "run", ...sourceOnlyVitestArgs, ...args], {
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
@@ -321,16 +350,23 @@ function runProjectGroup(projects, groupName, shardIndex = null, shardCount = nu
|
||||
}
|
||||
|
||||
function runGeneralGroup(routeTests, groupName, shardIndex = null, shardCount = null) {
|
||||
if (groupName === generalServerGroupName) {
|
||||
if (groupName === generalChatGroupName) {
|
||||
runVitest(["--project", "@paperclipai/server", ...serializedServerVitestArgs, chatSuite],
|
||||
"chat integration test shard", { index: shardIndex ?? 0, count: shardCount ?? 1 });
|
||||
return;
|
||||
}
|
||||
if (groupName === generalServerGroupName || groupName === generalServerWithoutChatGroupName) {
|
||||
const withoutChat = groupName === generalServerWithoutChatGroupName;
|
||||
const files = withoutChat ? generalServerTestFiles.filter((file) => file !== chatSuite) : generalServerTestFiles;
|
||||
if (shardCount !== null && shardCount > 1) {
|
||||
const shardFiles = selectGeneralServerShard(
|
||||
generalServerTestFiles,
|
||||
files,
|
||||
shardIndex,
|
||||
shardCount,
|
||||
generalServerShardDurations,
|
||||
);
|
||||
console.log(
|
||||
`\n[test:run] general-server shard ${shardIndex + 1}/${shardCount} running ${shardFiles.length} of ${generalServerTestFiles.length} suites`,
|
||||
`\n[test:run] general-server shard ${shardIndex + 1}/${shardCount} running ${shardFiles.length} of ${files.length} suites`,
|
||||
);
|
||||
if (shardFiles.length === 0) {
|
||||
return;
|
||||
@@ -349,6 +385,7 @@ function runGeneralGroup(routeTests, groupName, shardIndex = null, shardCount =
|
||||
}
|
||||
|
||||
const excludeRouteArgs = routeTests.flatMap((file) => ["--exclude", file.serverPath]);
|
||||
if (withoutChat) excludeRouteArgs.push("--exclude", "src/__tests__/chat-channels.integration.test.ts");
|
||||
runVitest(
|
||||
[
|
||||
"--project",
|
||||
@@ -432,16 +469,16 @@ if (options.dryRun) {
|
||||
shardIndex: options.shardIndex,
|
||||
shardCount: options.shardCount,
|
||||
group: options.group,
|
||||
availableGeneralGroups: generalGroupNames,
|
||||
availableGeneralGroups: allowedGeneralGroupNames,
|
||||
serializedSuiteCount: routeTests.length,
|
||||
selectedSerializedSuites: serializedSuites.map((routeTest) => routeTest.repoPath),
|
||||
generalServerSuiteCount: generalServerTestFiles.length,
|
||||
selectedGeneralServerSuites:
|
||||
options.mode === generalModeName &&
|
||||
options.group === generalServerGroupName &&
|
||||
[generalServerGroupName, generalServerWithoutChatGroupName].includes(options.group) &&
|
||||
options.shardCount !== null
|
||||
? selectGeneralServerShard(
|
||||
generalServerTestFiles,
|
||||
options.group === generalServerWithoutChatGroupName ? generalServerTestFiles.filter((file) => file !== chatSuite) : generalServerTestFiles,
|
||||
options.shardIndex,
|
||||
options.shardCount,
|
||||
generalServerShardDurations,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createRunnerdCodexTransport, defaultCapabilityRunnerdBinary } from "../
|
||||
import { createSkilllessCodexThreadConfig } from "../packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.js";
|
||||
import { AttemptJournal } from "../packages/paperclip-runner/src/evals/attempt-journal.js";
|
||||
import { estimateModelCostNanodollars } from "../packages/paperclip-runner/src/evals/model-pricing.js";
|
||||
import { CONNECTION_INTENT_AGENT_GUIDANCE } from "../packages/shared/src/connection-intent-guidance.js";
|
||||
|
||||
if (process.argv.includes("--catalog")) {
|
||||
process.stdout.write(JSON.stringify(runnerApiCatalog()) + "\n");
|
||||
@@ -64,7 +65,8 @@ try {
|
||||
const isOpenRouter = OPENROUTER_MODELS.has(request.model);
|
||||
const provider = isOpenRouter ? "opencode" : request.model === "claude-sonnet-5" ? "acpx" : "codex";
|
||||
let providerVersion: string | null = null;
|
||||
const fixture = await server.fixture({ mode: request.mode, apiToolsEnabled: request.arm !== "baseline", reset: true });
|
||||
const fixture = await server.fixture({ mode: request.mode, apiToolsEnabled: request.arm !== "baseline", reset: true, connectionScenario: request.connectionScenario });
|
||||
const initialState = await fixture.snapshot();
|
||||
const substitutions = Object.fromEntries(Object.entries(fixture).filter(([, value]) => typeof value === "string"));
|
||||
const expand = (value: any): any => typeof value === "string" ? value.replace(/\{\{(\w+)\}\}/g, (_, key) => String(substitutions[key] ?? (() => { throw new Error(`Unknown fixture variable ${key}`); })())) : Array.isArray(value) ? value.map(expand) : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, expand(entry)])) : value;
|
||||
const calls: Record<string, any>[] = [], notifications: unknown[] = [];
|
||||
@@ -91,6 +93,7 @@ try {
|
||||
try {
|
||||
if (request.calls) {
|
||||
for (const [i, call] of request.calls.entries()) await invoke(call.tool, expand(call.arguments), call.callId ?? `direct-${i}`);
|
||||
usage = { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, estimatedCostNanodollars: 0, providerRequests: 0, accountingProvenance: "Provider-free direct authority contract; no provider or runnerd dispatched" };
|
||||
} else {
|
||||
if (request.model === "claude-sonnet-5" && (process.platform !== "linux" || process.arch !== "x64")) throw new Error("Qualified ACPX Claude requires Linux x64; no provider turn was dispatched");
|
||||
if (!request.reservationId || request.maxCostUsd !== 0.5 || !["gpt-5.6-luna", "claude-sonnet-5", ...OPENROUTER_MODELS].includes(request.model)) throw new Error("Paid attempt requires ledger reservation and qualified model");
|
||||
@@ -126,7 +129,7 @@ try {
|
||||
completionContract: { revision: "runner-api-eval-v1", criterionIds: ["objective"] },
|
||||
config: { ...createSkilllessCodexThreadConfig(fixture.workspace), model_reasoning_effort: "low" },
|
||||
permissions: "paperclip-runner-workspace-only", runtimeWorkspaceRoots: [fixture.workspace], approvalPolicy: "never",
|
||||
baseInstructions: "You are operating a disposable real Paperclip company. Use the provided tools to do the user's task. Do not use shell, network, skills, or credentials. Stop when the requested work is verified. " + (request.arm === "baseline" ? "" : "Prefer available dedicated tools. Only use search_api and call_api when no dedicated tool supports the required operation or parameters. Do not search before ordinary dedicated tool use."),
|
||||
baseInstructions: "You are operating a disposable real Paperclip company. Use the provided tools to do the user's task. Do not use shell, network, skills, or credentials. Stop when the requested work is verified. " + (request.arm === "baseline" ? "" : "Prefer available dedicated tools. Only use search_api and call_api when no dedicated tool supports the required operation or parameters. Do not search before ordinary dedicated tool use.") + "\n" + CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
dynamicTools: definitions, experimentalRawEvents: true, persistExtendedHistory: true,
|
||||
});
|
||||
if (request.preflight) {
|
||||
@@ -198,14 +201,15 @@ try {
|
||||
}
|
||||
const artifact = {
|
||||
schema: "paperclip-runner/eval-session-artifact/v1", attemptId: request.attemptId,
|
||||
requestedModel: request.model ?? "provider-free", provider, driver: "real-server-api-tools",
|
||||
requestedModel: request.calls ? "provider-free" : request.model ?? "provider-free", provider: request.calls ? "none" : provider, driver: request.calls ? "direct-authority-contract" : "real-server-api-tools",
|
||||
evidenceMode: request.calls ? "provider-free-contract" : "live-provider",
|
||||
providerSessionId: record(thread.thread).id ?? null, effectiveModel: record(thread.thread).model ?? null,
|
||||
providerVersion: request.model === "gpt-5.6-luna" ? execFileSync("codex", ["--version"], { encoding: "utf8" }).trim() : providerVersion ?? record(evidence).providerVersion ?? null,
|
||||
providerVersion: request.calls ? null : request.model === "gpt-5.6-luna" ? execFileSync("codex", ["--version"], { encoding: "utf8" }).trim() : providerVersion ?? record(evidence).providerVersion ?? null,
|
||||
runtimeVersions: { node: process.versions.node, acpx: record(evidence).providerVersion, agentServer: record(evidence).agentServerVersion, agentRuntime: record(evidence).agentRuntimeVersion },
|
||||
runtimeBuild,
|
||||
timing: { startedAt, finishedAt: new Date().toISOString(), durationMs: performance.now() - started },
|
||||
usage, accountingComplete: Boolean(request.calls) || !providerTurnStarted || terminalSeen, providerTurnStarted, observedProviderToolCalls: observedToolCallIds.size, diagnosticTail, error, calls, notifications, evidence, thread, prompt, arm: request.arm ?? "treatment",
|
||||
fixture: substitutions, state: await fixture.snapshot(),
|
||||
fixture: substitutions, initialState, state: await fixture.snapshot(),
|
||||
toolSchemaBytes: Buffer.byteLength(JSON.stringify(definitions)), definitions,
|
||||
};
|
||||
// The controller supplies a unique retained attempt directory; never overwrite evidence.
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
#!/usr/bin/env node
|
||||
// Opt-in transport diagnostic, NOT a signed Slack event or an agent smoke test.
|
||||
import { Resolver } from "node:dns/promises";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import { BlockList, isIP } from "node:net";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export const TIMEOUT_MS = 8_000;
|
||||
export const INERT_BODY = "{}";
|
||||
// A real v0 signature has 64 hexadecimal hash characters. This cannot match it.
|
||||
export const INVALID_SIGNATURE = "v0=ingress-canary-invalid";
|
||||
const USAGE = `Usage: node scripts/smoke/chat-webhook-ingress.mjs --url HTTPS_SLACK_WEBHOOK
|
||||
[--compare-url HTTP_LOOPBACK_SAME_PATH] [--relay-ip PUBLIC_IP]
|
||||
|
||||
No arguments or --help sends nothing. The public URL must use a DNS hostname
|
||||
and /api/chat-webhooks/<public-id>/slack; no credentials, query or fragment.
|
||||
The optional comparator must use literal 127.0.0.1 or [::1] with the same path.
|
||||
The relay override pins one public IP while retaining the public hostname/SNI.
|
||||
Without an override, DNS answers must all be public. HTTP requests are not retried.
|
||||
One inert, invalid-signature POST per target; 8 seconds maximum per target.
|
||||
No redirects, response bodies, cookies, credentials or environment proxy use.
|
||||
Clear NODE_DEBUG/NODE_DEBUG_NATIVE and TLS tracing before network diagnostics.
|
||||
Only 401 is an expected rejection; other statuses are unexpected (exit 1).
|
||||
A 401 is compatible with reaching the signature verifier, not proof of signed
|
||||
event admission or chat quality. Correlate with local verifier logs if needed.
|
||||
Timings are cumulative milliseconds from each target's start; null means unseen.
|
||||
Output excludes URLs, public IDs, addresses, headers and raw error messages.
|
||||
`;
|
||||
|
||||
class ConfigurationError extends Error {
|
||||
constructor(code) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const privateV4 = new BlockList();
|
||||
for (const [address, prefix] of [
|
||||
["0.0.0.0", 8],
|
||||
["10.0.0.0", 8],
|
||||
["100.64.0.0", 10],
|
||||
["127.0.0.0", 8],
|
||||
["169.254.0.0", 16],
|
||||
["172.16.0.0", 12],
|
||||
["192.0.0.0", 24],
|
||||
["192.0.2.0", 24],
|
||||
["192.88.99.0", 24],
|
||||
["192.168.0.0", 16],
|
||||
["198.18.0.0", 15],
|
||||
["198.51.100.0", 24],
|
||||
["203.0.113.0", 24],
|
||||
["224.0.0.0", 3],
|
||||
])
|
||||
privateV4.addSubnet(address, prefix, "ipv4");
|
||||
const globalV6 = new BlockList();
|
||||
globalV6.addSubnet("2000::", 3, "ipv6");
|
||||
const specialV6 = new BlockList();
|
||||
for (const [address, prefix] of [
|
||||
["2001::", 23],
|
||||
["2001:db8::", 32],
|
||||
["2002::", 16],
|
||||
["3fff::", 20],
|
||||
])
|
||||
specialV6.addSubnet(address, prefix, "ipv6");
|
||||
|
||||
export function isPublicAddress(value) {
|
||||
if (typeof value !== "string" || /[%\s]/u.test(value)) return false;
|
||||
const family = isIP(value);
|
||||
if (family === 4) return !privateV4.check(value, "ipv4");
|
||||
return (
|
||||
family === 6 &&
|
||||
globalV6.check(value, "ipv6") &&
|
||||
!specialV6.check(value, "ipv6")
|
||||
);
|
||||
}
|
||||
|
||||
function parseUrl(raw, comparison = false) {
|
||||
// Reject parser normalization, encoded path/header tricks and even empty ?/#.
|
||||
if (
|
||||
typeof raw !== "string" ||
|
||||
/[\s\u0000-\u001f\u007f-\u009f%\\@?#]/u.test(raw)
|
||||
) {
|
||||
throw new ConfigurationError("invalid_url");
|
||||
}
|
||||
const match = /^(https?):\/\/([^/]+)(\/.*)$/u.exec(raw);
|
||||
let url;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new ConfigurationError("invalid_url");
|
||||
}
|
||||
if (
|
||||
!match ||
|
||||
match[3] !== url.pathname ||
|
||||
url.port === "0" ||
|
||||
!/^\/api\/chat-webhooks\/[A-Za-z0-9_-]{16,128}\/slack$/u.test(url.pathname)
|
||||
) {
|
||||
throw new ConfigurationError("invalid_webhook_path");
|
||||
}
|
||||
if (comparison) {
|
||||
// Exact literals only: no host-file, DNS, alternate integer IPv4 or rebinding.
|
||||
if (!/^http:\/\/(127\.0\.0\.1|\[::1\])(?::[1-9][0-9]{0,4})?\//u.test(raw)) {
|
||||
throw new ConfigurationError("comparison_must_be_loopback_http");
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
url.protocol !== "https:" ||
|
||||
isIP(url.hostname) ||
|
||||
!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/u.test(
|
||||
url.hostname,
|
||||
) ||
|
||||
/\.(?:localhost|local|internal|invalid|test)$/u.test(url.hostname)
|
||||
) {
|
||||
throw new ConfigurationError("public_url_must_be_https_hostname");
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function parseArgs(argv) {
|
||||
if (!Array.isArray(argv) || argv.some((item) => typeof item !== "string")) {
|
||||
throw new ConfigurationError("invalid_arguments");
|
||||
}
|
||||
if (argv.length === 0 || (argv.length === 1 && argv[0] === "--help"))
|
||||
return { help: true };
|
||||
const values = new Map();
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const key = argv[index];
|
||||
if (
|
||||
!["--url", "--compare-url", "--relay-ip"].includes(key) ||
|
||||
values.has(key) ||
|
||||
!argv[index + 1] ||
|
||||
argv[index + 1].startsWith("--")
|
||||
) {
|
||||
throw new ConfigurationError("invalid_arguments");
|
||||
}
|
||||
values.set(key, argv[index + 1]);
|
||||
}
|
||||
if (!values.has("--url"))
|
||||
throw new ConfigurationError("explicit_public_url_required");
|
||||
const publicUrl = parseUrl(values.get("--url"));
|
||||
const relayIp = values.get("--relay-ip") ?? null;
|
||||
if (relayIp !== null && !isPublicAddress(relayIp))
|
||||
throw new ConfigurationError("unsafe_relay_ip");
|
||||
const targets = [
|
||||
{ label: "public", url: publicUrl, relayIp, host: publicUrl.host },
|
||||
];
|
||||
if (values.has("--compare-url")) {
|
||||
const url = parseUrl(values.get("--compare-url"), true);
|
||||
if (url.pathname !== publicUrl.pathname)
|
||||
throw new ConfigurationError("comparison_path_mismatch");
|
||||
targets.push({
|
||||
label: "comparison",
|
||||
url,
|
||||
relayIp: null,
|
||||
host: publicUrl.host,
|
||||
});
|
||||
}
|
||||
return { help: false, targets };
|
||||
}
|
||||
|
||||
const NETWORK_CODES = new Set([
|
||||
"ECONNREFUSED",
|
||||
"ECONNRESET",
|
||||
"ENETUNREACH",
|
||||
"EHOSTUNREACH",
|
||||
"ETIMEDOUT",
|
||||
"ENOTFOUND",
|
||||
"ENODATA",
|
||||
"EAI_AGAIN",
|
||||
"ESERVFAIL",
|
||||
"ECANCELLED",
|
||||
"EPIPE",
|
||||
"ERR_TLS_CERT_ALTNAME_INVALID",
|
||||
"CERT_HAS_EXPIRED",
|
||||
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
||||
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
||||
"HPE_HEADER_OVERFLOW",
|
||||
"HPE_INVALID_HEADER_TOKEN",
|
||||
]);
|
||||
function safeNetworkCode(error) {
|
||||
return NETWORK_CODES.has(error?.code) ? error.code : "NETWORK_ERROR";
|
||||
}
|
||||
|
||||
// Injectable I/O is for local fixture tests only; the CLI always uses these native
|
||||
// implementations. There is no CLI switch for TLS bypass or a private relay.
|
||||
export async function probeTarget(
|
||||
target,
|
||||
{
|
||||
createResolver = () => new Resolver({ timeout: TIMEOUT_MS, tries: 1 }),
|
||||
request = target.label === "public" ? https.request : http.request,
|
||||
timeoutMs = TIMEOUT_MS,
|
||||
} = {},
|
||||
) {
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > TIMEOUT_MS) {
|
||||
throw new ConfigurationError("invalid_timeout");
|
||||
}
|
||||
const start = performance.now();
|
||||
const result = {
|
||||
target: target.label,
|
||||
startedAt: new Date().toISOString(),
|
||||
outcome: null,
|
||||
status: null,
|
||||
errorCode: null,
|
||||
timingsMs: { dns: null, connect: null, tls: null, ttfb: null, total: null },
|
||||
};
|
||||
const elapsed = () => Math.round((performance.now() - start) * 1_000) / 1_000;
|
||||
let resolver;
|
||||
let agent;
|
||||
let req;
|
||||
let response;
|
||||
let done = false;
|
||||
return new Promise((resolve) => {
|
||||
function finish(outcome, code = null) {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(deadline);
|
||||
resolver?.cancel();
|
||||
response?.destroy(); // Never consume, retain or display response content.
|
||||
req?.destroy();
|
||||
agent?.destroy();
|
||||
result.outcome = outcome;
|
||||
result.errorCode = code;
|
||||
result.timingsMs.total = elapsed();
|
||||
resolve(result);
|
||||
}
|
||||
// Covers DNS as well as TCP/TLS/header wait; destroy cancels actual I/O.
|
||||
const deadline = setTimeout(
|
||||
() => finish("timeout", "DEADLINE_EXCEEDED"),
|
||||
timeoutMs,
|
||||
);
|
||||
async function send() {
|
||||
let address = target.relayIp;
|
||||
if (target.label === "public" && !address) {
|
||||
resolver = createResolver();
|
||||
const answers = await Promise.allSettled([
|
||||
resolver.resolve4(target.url.hostname),
|
||||
resolver.resolve6(target.url.hostname),
|
||||
]);
|
||||
if (done) return;
|
||||
result.timingsMs.dns = elapsed();
|
||||
const addresses = answers.flatMap((answer) =>
|
||||
answer.status === "fulfilled" ? answer.value : [],
|
||||
);
|
||||
if (addresses.length === 0) {
|
||||
finish(
|
||||
"network_error",
|
||||
safeNetworkCode(
|
||||
answers.find((answer) => answer.status === "rejected")?.reason,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!addresses.every(isPublicAddress)) {
|
||||
finish("unsafe_resolution", "NON_PUBLIC_DNS_ANSWER");
|
||||
return;
|
||||
}
|
||||
address = addresses[0]; // One pinned address, no fallback/retry.
|
||||
}
|
||||
if (done) return;
|
||||
if (target.label === "comparison")
|
||||
address = target.url.hostname.replace(/^\[|\]$/gu, "");
|
||||
// A fresh explicit agent ignores global/env proxy agents and session caches.
|
||||
const Agent = target.label === "public" ? https.Agent : http.Agent;
|
||||
agent = new Agent({
|
||||
keepAlive: false,
|
||||
maxSockets: 1,
|
||||
proxyEnv: {},
|
||||
maxCachedSessions: 0,
|
||||
});
|
||||
function onResponse(res) {
|
||||
response = res;
|
||||
res.on("error", () => {}); // Raw response errors may contain private data.
|
||||
if (done) {
|
||||
res.destroy();
|
||||
return;
|
||||
}
|
||||
result.status = res.statusCode ?? null;
|
||||
result.timingsMs.ttfb = elapsed();
|
||||
finish(
|
||||
result.status === 401 ? "expected_rejection" : "unexpected_status",
|
||||
);
|
||||
}
|
||||
req = request(
|
||||
{
|
||||
protocol: target.url.protocol,
|
||||
hostname: target.url.hostname.replace(/^\[|\]$/gu, ""),
|
||||
port: target.url.port || (target.label === "public" ? 443 : 80),
|
||||
path: target.url.pathname,
|
||||
method: "POST",
|
||||
agent,
|
||||
servername:
|
||||
target.label === "public" ? target.url.hostname : undefined,
|
||||
rejectUnauthorized: true,
|
||||
autoSelectFamily: false,
|
||||
family: isIP(address),
|
||||
lookup: (_hostname, options, callback) => {
|
||||
const selected = { address, family: isIP(address) };
|
||||
callback(
|
||||
null,
|
||||
options?.all ? [selected] : address,
|
||||
selected.family,
|
||||
);
|
||||
},
|
||||
maxHeaderSize: 16_384,
|
||||
headers: {
|
||||
Host: target.host,
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": Buffer.byteLength(INERT_BODY),
|
||||
"X-Slack-Request-Timestamp": String(Math.floor(Date.now() / 1_000)),
|
||||
"X-Slack-Signature": INVALID_SIGNATURE,
|
||||
Connection: "close",
|
||||
},
|
||||
},
|
||||
onResponse,
|
||||
);
|
||||
req.once("upgrade", (res, socket) => {
|
||||
socket.destroy(); // A 101 is unexpected too; never enter another protocol.
|
||||
onResponse(res);
|
||||
});
|
||||
req.on("socket", (socket) => {
|
||||
socket.once("connect", () => {
|
||||
if (!done) result.timingsMs.connect = elapsed();
|
||||
});
|
||||
socket.once("secureConnect", () => {
|
||||
if (!done) result.timingsMs.tls = elapsed();
|
||||
});
|
||||
});
|
||||
req.on("error", (error) =>
|
||||
finish("network_error", safeNetworkCode(error)),
|
||||
);
|
||||
req.end(INERT_BODY);
|
||||
}
|
||||
void send().catch((error) =>
|
||||
finish("network_error", safeNetworkCode(error)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function main(
|
||||
argv,
|
||||
{ write = (line) => process.stdout.write(line), probe = probeTarget } = {},
|
||||
) {
|
||||
let config;
|
||||
try {
|
||||
config = parseArgs(argv);
|
||||
} catch (error) {
|
||||
write(
|
||||
`${JSON.stringify({ outcome: "invalid_configuration", errorCode: error instanceof ConfigurationError ? error.code : "INVALID_CONFIGURATION" })}\n`,
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
if (config.help) {
|
||||
write(USAGE);
|
||||
return 0;
|
||||
}
|
||||
// Core's debug logging bypasses our redaction and can include request options.
|
||||
// Refuse networking rather than trying to intercept global stdout/stderr.
|
||||
if (
|
||||
process.env.NODE_DEBUG?.trim() ||
|
||||
process.env.NODE_DEBUG_NATIVE?.trim() ||
|
||||
process.execArgv.some((arg) => /^--trace[-_]tls(?:=|$)/u.test(arg)) ||
|
||||
/(?:^|[\s"'])--trace[-_]tls(?:[=\s"']|$)/u.test(
|
||||
process.env.NODE_OPTIONS ?? "",
|
||||
)
|
||||
) {
|
||||
write(
|
||||
`${JSON.stringify({ outcome: "invalid_configuration", errorCode: "UNSAFE_DEBUG_ENVIRONMENT" })}\n`,
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
let exitCode = 0;
|
||||
for (const target of config.targets) {
|
||||
try {
|
||||
const result = await probe(target);
|
||||
write(`${JSON.stringify(result)}\n`);
|
||||
if (result.outcome !== "expected_rejection") exitCode = 1;
|
||||
} catch {
|
||||
// Never surface subprocess/network messages, stacks, paths or caller input.
|
||||
write(
|
||||
`${JSON.stringify({ target: target.label, outcome: "diagnostic_error", errorCode: "DIAGNOSTIC_ERROR" })}\n`,
|
||||
);
|
||||
exitCode = 1;
|
||||
}
|
||||
}
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
) {
|
||||
process.exitCode = await main(process.argv.slice(2));
|
||||
}
|
||||
@@ -0,0 +1,771 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { EventEmitter, once } from "node:events";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import net from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import test from "node:test";
|
||||
import {
|
||||
INERT_BODY,
|
||||
INVALID_SIGNATURE,
|
||||
TIMEOUT_MS,
|
||||
isPublicAddress,
|
||||
main,
|
||||
parseArgs,
|
||||
probeTarget,
|
||||
} from "./chat-webhook-ingress.mjs";
|
||||
|
||||
const script = fileURLToPath(
|
||||
new URL("./chat-webhook-ingress.mjs", import.meta.url),
|
||||
);
|
||||
const webhookPath = "/api/chat-webhooks/fixture-public-id-secret/slack";
|
||||
const publicUrl = `https://canary.example.com:8443${webhookPath}`;
|
||||
const secret = "PRIVATE_RESPONSE_CREDENTIAL_CANARY";
|
||||
const runFile = promisify(execFile);
|
||||
|
||||
async function fixture(t, handler) {
|
||||
const server = http.createServer(handler);
|
||||
const sockets = new Set();
|
||||
server.on("connection", (socket) => {
|
||||
sockets.add(socket);
|
||||
socket.once("close", () => sockets.delete(socket));
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
t.after(async () => {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
const url = `http://127.0.0.1:${server.address().port}${webhookPath}`;
|
||||
const target = parseArgs(["--url", publicUrl, "--compare-url", url])
|
||||
.targets[1];
|
||||
return { server, target, url };
|
||||
}
|
||||
|
||||
function assertRedacted(value) {
|
||||
const output = typeof value === "string" ? value : JSON.stringify(value);
|
||||
for (const denied of [
|
||||
secret,
|
||||
"fixture-public-id-secret",
|
||||
"canary.example.com",
|
||||
"/api/chat-webhooks",
|
||||
"127.0.0.1",
|
||||
"http:",
|
||||
"https:",
|
||||
]) {
|
||||
assert.equal(
|
||||
output.includes(denied),
|
||||
false,
|
||||
`unexpected private output: ${denied}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test("no URL, help, invalid options and missing values never invoke networking", async () => {
|
||||
for (const argv of [
|
||||
[],
|
||||
["--help"],
|
||||
["--url"],
|
||||
["--compare-url", publicUrl],
|
||||
["--relay-ip", "8.8.8.8"],
|
||||
["--url", publicUrl, "--url", publicUrl],
|
||||
[publicUrl],
|
||||
["--help", "--url", publicUrl],
|
||||
["--url", publicUrl, "--headers", secret],
|
||||
["--url", publicUrl, "--retry", "1"],
|
||||
["--url", publicUrl, "--timeout", "999"],
|
||||
["--url", publicUrl, "--"],
|
||||
["--url", 123],
|
||||
]) {
|
||||
let calls = 0;
|
||||
let output = "";
|
||||
const status = await main(argv, {
|
||||
write: (line) => {
|
||||
output += line;
|
||||
},
|
||||
probe: () => {
|
||||
calls++;
|
||||
},
|
||||
});
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(status, argv.length === 0 || argv.join() === "--help" ? 0 : 2);
|
||||
assert.equal(output.includes(secret), false);
|
||||
assert.equal(output.includes("fixture-public-id-secret"), false);
|
||||
}
|
||||
});
|
||||
|
||||
for (const raw of [
|
||||
`http://canary.example.com${webhookPath}`,
|
||||
`https://user:password@canary.example.com${webhookPath}`,
|
||||
`${publicUrl}?token=${secret}`,
|
||||
`${publicUrl}#${secret}`,
|
||||
`${publicUrl}?`,
|
||||
`${publicUrl}#`,
|
||||
`${publicUrl}\r\nAuthorization: ${secret}`,
|
||||
`${publicUrl}\t`,
|
||||
` ${publicUrl}`,
|
||||
`https://canary.example.com/%2e%2e${webhookPath}`,
|
||||
`https://canary.example.com/wrong/..${webhookPath}`,
|
||||
`https://canary.example.com\\evil${webhookPath}`,
|
||||
`https://canary.example.com${webhookPath.replace("/slack", "/telegram")}`,
|
||||
`https://canary.example.com${webhookPath}/`,
|
||||
`https://127.0.0.1${webhookPath}`,
|
||||
`https://[::1]${webhookPath}`,
|
||||
`https://canary.local${webhookPath}`,
|
||||
`https://canary.internal${webhookPath}`,
|
||||
`https://canary.example.com:0${webhookPath}`,
|
||||
`https://canary.example.com:999999${webhookPath}`,
|
||||
]) {
|
||||
test(`rejects unsafe public URL variant ${[...raw].reduce((sum, char) => sum + char.codePointAt(0), 0)}`, async () => {
|
||||
let output = "";
|
||||
assert.equal(
|
||||
await main(["--url", raw], {
|
||||
write: (line) => {
|
||||
output += line;
|
||||
},
|
||||
probe: () => assert.fail("networking"),
|
||||
}),
|
||||
2,
|
||||
);
|
||||
assertRedacted(output);
|
||||
});
|
||||
}
|
||||
|
||||
test("comparator is exact literal loopback HTTP with exactly the same path", () => {
|
||||
for (const authority of ["127.0.0.1:3104", "[::1]:3104"]) {
|
||||
assert.equal(
|
||||
parseArgs([
|
||||
"--url",
|
||||
publicUrl,
|
||||
"--compare-url",
|
||||
`http://${authority}${webhookPath}`,
|
||||
]).targets.length,
|
||||
2,
|
||||
);
|
||||
}
|
||||
for (const value of [
|
||||
`https://127.0.0.1${webhookPath}`,
|
||||
`http://localhost${webhookPath}`,
|
||||
`http://127.1${webhookPath}`,
|
||||
`http://2130706433${webhookPath}`,
|
||||
`http://127.0.0.2${webhookPath}`,
|
||||
`http://10.0.0.1${webhookPath}`,
|
||||
`http://127.0.0.1:3104${webhookPath.replace("fixture-public", "another-public")}`,
|
||||
`http://127.0.0.1:3104${webhookPath}\r\nCookie: ${secret}`,
|
||||
])
|
||||
assert.throws(() =>
|
||||
parseArgs(["--url", publicUrl, "--compare-url", value]),
|
||||
);
|
||||
});
|
||||
|
||||
test("only unambiguous public relay IPs are accepted, including no IPv6 tunneling", () => {
|
||||
for (const address of [
|
||||
"8.8.8.8",
|
||||
"209.177.145.192",
|
||||
"2607:f740:f::b31",
|
||||
"2001:4860:4860::8888",
|
||||
]) {
|
||||
assert.equal(isPublicAddress(address), true);
|
||||
assert.equal(
|
||||
parseArgs(["--url", publicUrl, "--relay-ip", address]).targets[0].relayIp,
|
||||
address,
|
||||
);
|
||||
}
|
||||
for (const address of [
|
||||
"0.0.0.0",
|
||||
"10.0.0.1",
|
||||
"100.64.0.1",
|
||||
"100.127.255.255",
|
||||
"127.0.0.1",
|
||||
"169.254.169.254",
|
||||
"172.16.0.1",
|
||||
"192.0.0.1",
|
||||
"192.0.2.1",
|
||||
"192.88.99.1",
|
||||
"192.168.1.1",
|
||||
"198.18.0.1",
|
||||
"198.51.100.1",
|
||||
"203.0.113.1",
|
||||
"224.0.0.1",
|
||||
"255.255.255.255",
|
||||
"::",
|
||||
"::1",
|
||||
"fc00::1",
|
||||
"fe80::1",
|
||||
"ff02::1",
|
||||
"::ffff:127.0.0.1",
|
||||
"::ffff:8.8.8.8",
|
||||
"64:ff9b::808:808",
|
||||
"2001::1",
|
||||
"2001:db8::1",
|
||||
"2002:7f00:1::1",
|
||||
"3fff::1",
|
||||
"4000::1",
|
||||
"fe80::1%en0",
|
||||
"[2607:f740:f::b31]",
|
||||
"8.8.8.8:443",
|
||||
"008.008.008.008",
|
||||
"0x08080808",
|
||||
"134744072",
|
||||
"example.com",
|
||||
"8.8.8.8\r\nAuthorization: secret",
|
||||
" 8.8.8.8",
|
||||
"8.8.8.8 ",
|
||||
"",
|
||||
]) {
|
||||
assert.equal(isPublicAddress(address), false);
|
||||
assert.throws(() => parseArgs(["--url", publicUrl, "--relay-ip", address]));
|
||||
}
|
||||
});
|
||||
|
||||
test("401 is one inert POST, with no copied auth or response content", async (t) => {
|
||||
let calls = 0;
|
||||
let headers;
|
||||
let method;
|
||||
let path;
|
||||
let body = "";
|
||||
const { target } = await fixture(t, (req, res) => {
|
||||
calls++;
|
||||
headers = req.headers;
|
||||
method = req.method;
|
||||
path = req.url;
|
||||
req.setEncoding("utf8");
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () =>
|
||||
res
|
||||
.writeHead(401, { "Set-Cookie": secret, "X-Request-Id": secret })
|
||||
.end(secret),
|
||||
);
|
||||
});
|
||||
const result = await probeTarget(target);
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(method, "POST");
|
||||
assert.equal(path, webhookPath);
|
||||
assert.equal(body, INERT_BODY);
|
||||
assert.equal(headers.host, "canary.example.com:8443");
|
||||
assert.equal(headers.authorization, undefined);
|
||||
assert.equal(headers.cookie, undefined);
|
||||
assert.equal(headers["proxy-authorization"], undefined);
|
||||
assert.equal(headers["x-slack-signature"], INVALID_SIGNATURE);
|
||||
assert.equal(/^v0=[a-f0-9]{64}$/u.test(headers["x-slack-signature"]), false);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
Number(headers["x-slack-request-timestamp"]) * 1_000 - Date.now(),
|
||||
) < 2_000,
|
||||
);
|
||||
assert.equal(result.outcome, "expected_rejection");
|
||||
assert.equal(result.status, 401);
|
||||
assert.equal(result.errorCode, null);
|
||||
assert.equal(result.timingsMs.dns, null);
|
||||
assert.equal(result.timingsMs.tls, null);
|
||||
assert.ok(result.timingsMs.connect <= result.timingsMs.ttfb);
|
||||
assert.ok(result.timingsMs.ttfb <= result.timingsMs.total);
|
||||
assertRedacted(result);
|
||||
});
|
||||
|
||||
for (const status of [
|
||||
101, 200, 202, 204, 301, 302, 307, 308, 400, 403, 404, 429, 500, 502,
|
||||
]) {
|
||||
test(`HTTP ${status} is unexpected; no redirect, retry or body wait`, async (t) => {
|
||||
let destinationCalls = 0;
|
||||
let calls = 0;
|
||||
const destination = await fixture(t, (_req, res) => {
|
||||
destinationCalls++;
|
||||
res.end();
|
||||
});
|
||||
const { target } = await fixture(t, (_req, res) => {
|
||||
calls++;
|
||||
res.writeHead(status, {
|
||||
Location: `${destination.url}?token=${secret}`,
|
||||
"Set-Cookie": secret,
|
||||
});
|
||||
res.flushHeaders(); // Deliberately never end a body: the probe must discard it.
|
||||
});
|
||||
const result = await probeTarget(target, { timeoutMs: 1_000 });
|
||||
assert.equal(result.status, status);
|
||||
assert.equal(result.outcome, "unexpected_status");
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(destinationCalls, 0);
|
||||
assertRedacted(result);
|
||||
let output = "";
|
||||
assert.equal(
|
||||
await main(["--url", publicUrl], {
|
||||
write: (line) => {
|
||||
output += line;
|
||||
},
|
||||
probe: async () => result,
|
||||
}),
|
||||
1,
|
||||
);
|
||||
assertRedacted(output);
|
||||
});
|
||||
}
|
||||
|
||||
test("hard deadline destroys a real held HTTP connection instead of retrying", async (t) => {
|
||||
let calls = 0;
|
||||
let close;
|
||||
const closed = new Promise((resolve) => {
|
||||
close = resolve;
|
||||
});
|
||||
const { target } = await fixture(t, (req) => {
|
||||
calls++;
|
||||
req.socket.once("close", close);
|
||||
});
|
||||
const result = await probeTarget(target, { timeoutMs: 200 });
|
||||
assert.equal(result.outcome, "timeout");
|
||||
assert.equal(result.errorCode, "DEADLINE_EXCEEDED");
|
||||
assert.equal(result.status, null);
|
||||
assert.equal(result.timingsMs.ttfb, null);
|
||||
await closed;
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(TIMEOUT_MS, 8_000);
|
||||
await assert.rejects(probeTarget(target, { timeoutMs: 8_001 }));
|
||||
assertRedacted(result);
|
||||
});
|
||||
|
||||
test("default eight-second deadline cancels original DNS and forbids late request", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
let release4;
|
||||
let release6;
|
||||
let cancelled = 0;
|
||||
let requests = 0;
|
||||
const target = parseArgs(["--url", publicUrl]).targets[0];
|
||||
const promise = probeTarget(target, {
|
||||
createResolver: () => ({
|
||||
resolve4: () =>
|
||||
new Promise((resolve) => {
|
||||
release4 = resolve;
|
||||
}),
|
||||
resolve6: () =>
|
||||
new Promise((resolve) => {
|
||||
release6 = resolve;
|
||||
}),
|
||||
cancel: () => {
|
||||
cancelled++;
|
||||
release4(["8.8.8.8"]);
|
||||
release6([]);
|
||||
},
|
||||
}),
|
||||
request: () => {
|
||||
requests++;
|
||||
assert.fail("late request");
|
||||
},
|
||||
});
|
||||
t.mock.timers.tick(7_999);
|
||||
assert.equal(cancelled, 0);
|
||||
t.mock.timers.tick(1);
|
||||
const result = await promise;
|
||||
await Promise.resolve();
|
||||
assert.equal(result.outcome, "timeout");
|
||||
assert.equal(cancelled, 1);
|
||||
assert.equal(requests, 0);
|
||||
});
|
||||
|
||||
test("deadline also destroys an actual socket held during TLS handshake", async (t) => {
|
||||
let close;
|
||||
let connections = 0;
|
||||
const closed = new Promise((resolve) => {
|
||||
close = resolve;
|
||||
});
|
||||
const sockets = new Set();
|
||||
const server = net.createServer((socket) => {
|
||||
connections++;
|
||||
sockets.add(socket);
|
||||
socket.on("data", () => {}); // Consume the ClientHello, but never answer it.
|
||||
socket.once("close", () => {
|
||||
sockets.delete(socket);
|
||||
close();
|
||||
});
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
t.after(async () => {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
const target = parseArgs(["--url", publicUrl, "--relay-ip", "8.8.8.8"])
|
||||
.targets[0];
|
||||
const result = await probeTarget(target, {
|
||||
timeoutMs: 200,
|
||||
// Test-only transport routes the real TLS client to this owned TCP fixture.
|
||||
// It does not disable TLS verification or expose a CLI private-relay option.
|
||||
request: (options, callback) =>
|
||||
https.request(
|
||||
{
|
||||
...options,
|
||||
port: server.address().port,
|
||||
lookup: (_hostname, _options, callback) =>
|
||||
callback(null, "127.0.0.1", 4),
|
||||
},
|
||||
callback,
|
||||
),
|
||||
});
|
||||
assert.equal(result.outcome, "timeout");
|
||||
assert.equal(result.status, null);
|
||||
assert.notEqual(result.timingsMs.connect, null);
|
||||
assert.equal(result.timingsMs.tls, null);
|
||||
await closed;
|
||||
assert.equal(connections, 1);
|
||||
assertRedacted(result);
|
||||
});
|
||||
|
||||
test("DNS private or mixed answers fail closed before any connection", async () => {
|
||||
for (const addresses of [
|
||||
["100.64.0.1"],
|
||||
["8.8.8.8", "127.0.0.1"],
|
||||
["::ffff:127.0.0.1"],
|
||||
]) {
|
||||
const result = await probeTarget(
|
||||
parseArgs(["--url", publicUrl]).targets[0],
|
||||
{
|
||||
createResolver: () => ({
|
||||
resolve4: async () => addresses,
|
||||
resolve6: async () => [],
|
||||
cancel() {},
|
||||
}),
|
||||
request: () => assert.fail("private network connection"),
|
||||
},
|
||||
);
|
||||
assert.equal(result.outcome, "unsafe_resolution");
|
||||
assert.equal(result.errorCode, "NON_PUBLIC_DNS_ANSWER");
|
||||
assertRedacted(result);
|
||||
}
|
||||
});
|
||||
|
||||
test("public resolution/override pins one address but preserves Host, TLS SNI and verification", async () => {
|
||||
for (const override of [null, "2607:f740:f::b31"]) {
|
||||
const argv = [
|
||||
"--url",
|
||||
publicUrl,
|
||||
...(override ? ["--relay-ip", override] : []),
|
||||
];
|
||||
let requests = 0;
|
||||
let dnsCalls = 0;
|
||||
let destroyed = 0;
|
||||
const result = await probeTarget(parseArgs(argv).targets[0], {
|
||||
createResolver: () => ({
|
||||
resolve4: async () => {
|
||||
dnsCalls++;
|
||||
return ["8.8.8.8", "8.8.4.4"];
|
||||
},
|
||||
resolve6: async () => {
|
||||
dnsCalls++;
|
||||
return [];
|
||||
},
|
||||
cancel() {},
|
||||
}),
|
||||
request: (options, callback) => {
|
||||
requests++;
|
||||
assert.equal(options.hostname, "canary.example.com");
|
||||
assert.equal(options.servername, "canary.example.com");
|
||||
assert.equal(options.headers.Host, "canary.example.com:8443");
|
||||
assert.equal(options.path, webhookPath);
|
||||
assert.equal(options.rejectUnauthorized, true);
|
||||
assert.equal(options.auth, undefined);
|
||||
assert.equal(options.autoSelectFamily, false);
|
||||
assert.ok(options.agent instanceof https.Agent);
|
||||
assert.deepEqual(options.agent.options.proxyEnv, {});
|
||||
options.lookup(options.hostname, {}, (error, address) => {
|
||||
assert.equal(error, null);
|
||||
assert.equal(address, override ?? "8.8.8.8");
|
||||
});
|
||||
const req = new EventEmitter();
|
||||
req.destroy = () => {
|
||||
destroyed++;
|
||||
};
|
||||
req.end = (body) => {
|
||||
assert.equal(body, "{}");
|
||||
const socket = new EventEmitter();
|
||||
req.emit("socket", socket);
|
||||
socket.emit("connect");
|
||||
socket.emit("secureConnect");
|
||||
const response = new EventEmitter();
|
||||
response.statusCode = 401;
|
||||
response.on("newListener", (event) => assert.notEqual(event, "data"));
|
||||
response.read = () => assert.fail("response body must never be read");
|
||||
response.destroy = () => {};
|
||||
callback(response);
|
||||
};
|
||||
return req;
|
||||
},
|
||||
});
|
||||
assert.equal(requests, 1);
|
||||
assert.equal(dnsCalls, override ? 0 : 2);
|
||||
assert.equal(destroyed, 1);
|
||||
assert.equal(result.outcome, "expected_rejection");
|
||||
assert.ok(result.timingsMs.connect <= result.timingsMs.tls);
|
||||
assert.ok(result.timingsMs.tls <= result.timingsMs.ttfb);
|
||||
assertRedacted(result);
|
||||
}
|
||||
});
|
||||
|
||||
test("failed pinned family is not retried with another DNS answer; asynchronous errors are redacted", async () => {
|
||||
let calls = 0;
|
||||
let destroys = 0;
|
||||
let cancelled = 0;
|
||||
const result = await probeTarget(parseArgs(["--url", publicUrl]).targets[0], {
|
||||
createResolver: () => ({
|
||||
resolve4: async () => ["8.8.8.8", "8.8.4.4"],
|
||||
resolve6: async () => ["2001:4860:4860::8888"],
|
||||
cancel: () => {
|
||||
cancelled++;
|
||||
},
|
||||
}),
|
||||
request: (options) => {
|
||||
calls++;
|
||||
options.lookup(options.hostname, { all: true }, (error, addresses) => {
|
||||
assert.equal(error, null);
|
||||
assert.deepEqual(addresses, [{ address: "8.8.8.8", family: 4 }]);
|
||||
});
|
||||
const request = new EventEmitter();
|
||||
request.destroy = () => {
|
||||
destroys++;
|
||||
};
|
||||
request.end = () =>
|
||||
queueMicrotask(() =>
|
||||
request.emit(
|
||||
"error",
|
||||
Object.assign(new Error(`${publicUrl} ${secret}`), {
|
||||
code: "ENETUNREACH",
|
||||
}),
|
||||
),
|
||||
);
|
||||
return request;
|
||||
},
|
||||
});
|
||||
assert.equal(result.outcome, "network_error");
|
||||
assert.equal(result.errorCode, "ENETUNREACH");
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(destroys, 1);
|
||||
assert.equal(cancelled, 1);
|
||||
assertRedacted(result);
|
||||
});
|
||||
|
||||
test("network/DNS exceptions expose only closed error codes, never messages or stdout/stderr", async () => {
|
||||
for (const code of ["ECONNRESET", "ENOTFOUND", secret, undefined]) {
|
||||
const error = Object.assign(new Error(`${publicUrl} ${secret}`), {
|
||||
code,
|
||||
stdout: secret,
|
||||
stderr: secret,
|
||||
});
|
||||
const target = parseArgs(["--url", publicUrl, "--relay-ip", "8.8.8.8"])
|
||||
.targets[0];
|
||||
const result = await probeTarget(target, {
|
||||
request: () => {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
assert.equal(result.outcome, "network_error");
|
||||
assert.equal(
|
||||
result.errorCode,
|
||||
["ECONNRESET", "ENOTFOUND"].includes(code) ? code : "NETWORK_ERROR",
|
||||
);
|
||||
assertRedacted(result);
|
||||
const dnsResult = await probeTarget(
|
||||
parseArgs(["--url", publicUrl]).targets[0],
|
||||
{
|
||||
createResolver: () => ({
|
||||
resolve4: async () => {
|
||||
throw error;
|
||||
},
|
||||
resolve6: async () => {
|
||||
throw error;
|
||||
},
|
||||
cancel() {},
|
||||
}),
|
||||
request: () => assert.fail("failed DNS must not connect"),
|
||||
},
|
||||
);
|
||||
assert.equal(dnsResult.outcome, "network_error");
|
||||
assertRedacted(dnsResult);
|
||||
let output = "";
|
||||
assert.equal(
|
||||
await main(["--url", publicUrl], {
|
||||
write: (line) => {
|
||||
output += line;
|
||||
},
|
||||
probe: async () => {
|
||||
throw error;
|
||||
},
|
||||
}),
|
||||
1,
|
||||
);
|
||||
assertRedacted(output);
|
||||
}
|
||||
});
|
||||
|
||||
test("actual socket failure is redacted and not retried", async (t) => {
|
||||
let calls = 0;
|
||||
const { target } = await fixture(t, (req) => {
|
||||
calls++;
|
||||
req.socket.destroy();
|
||||
});
|
||||
const result = await probeTarget(target);
|
||||
assert.equal(result.outcome, "network_error");
|
||||
assert.equal(result.errorCode, "ECONNRESET");
|
||||
assert.equal(calls, 1);
|
||||
assertRedacted(result);
|
||||
});
|
||||
|
||||
test("CLI child and real comparator ignore proxy/auth environments and redact bad argv", async (t) => {
|
||||
let proxyCalls = 0;
|
||||
const proxy = await fixture(t, (_req, res) => {
|
||||
proxyCalls++;
|
||||
res.end(secret);
|
||||
});
|
||||
const proxyUrl = proxy.url
|
||||
.replace(webhookPath, "")
|
||||
.replace("http://", `http://user:${secret}@`);
|
||||
const env = {
|
||||
...process.env,
|
||||
NODE_USE_ENV_PROXY: "1",
|
||||
HTTP_PROXY: proxyUrl,
|
||||
HTTPS_PROXY: proxyUrl,
|
||||
ALL_PROXY: proxyUrl,
|
||||
http_proxy: proxyUrl,
|
||||
https_proxy: proxyUrl,
|
||||
NO_PROXY: "",
|
||||
no_proxy: "",
|
||||
PAPERCLIP_AUTH_HEADER: `Bearer ${secret}`,
|
||||
PAPERCLIP_COOKIE: secret,
|
||||
SLACK_BOT_TOKEN: secret,
|
||||
};
|
||||
for (const argv of [
|
||||
[],
|
||||
["--help"],
|
||||
["--url", `${publicUrl}\r\nCookie: ${secret}`],
|
||||
]) {
|
||||
let child;
|
||||
try {
|
||||
child = await runFile(process.execPath, [script, ...argv], {
|
||||
env,
|
||||
timeout: 5_000,
|
||||
});
|
||||
} catch (error) {
|
||||
assert.equal(error.code, 2);
|
||||
child = error;
|
||||
}
|
||||
assert.equal(child.stderr, "");
|
||||
assert.equal(child.stdout.includes(secret), false);
|
||||
assert.equal(child.stdout.includes("fixture-public-id-secret"), false);
|
||||
}
|
||||
// Explicitly poison Node's GLOBAL agent as --use-env-proxy does. The canary's
|
||||
// fresh agent must still connect straight to the one local fixture.
|
||||
const originalAgent = http.globalAgent;
|
||||
http.globalAgent = new http.Agent({ proxyEnv: env });
|
||||
t.after(() => {
|
||||
http.globalAgent.destroy();
|
||||
http.globalAgent = originalAgent;
|
||||
});
|
||||
const direct = await fixture(t, (req, res) => {
|
||||
assert.equal(req.headers.authorization, undefined);
|
||||
assert.equal(req.headers.cookie, undefined);
|
||||
res.writeHead(401).end();
|
||||
});
|
||||
assert.equal(
|
||||
(await probeTarget(direct.target)).outcome,
|
||||
"expected_rejection",
|
||||
);
|
||||
assert.equal(proxyCalls, 0);
|
||||
});
|
||||
|
||||
test("CLI refuses native debug output before starting a request", async () => {
|
||||
for (const debug of [
|
||||
{ NODE_DEBUG: "http,net,tls" },
|
||||
{ NODE_DEBUG_NATIVE: "TLS" },
|
||||
{ NODE_OPTIONS: "--trace-tls" },
|
||||
]) {
|
||||
const env = {
|
||||
...process.env,
|
||||
NODE_DEBUG: "",
|
||||
NODE_DEBUG_NATIVE: "",
|
||||
NODE_OPTIONS: "",
|
||||
...debug,
|
||||
};
|
||||
const child = await runFile(
|
||||
process.execPath,
|
||||
[script, "--url", publicUrl, "--relay-ip", "8.8.8.8"],
|
||||
{ env, timeout: 5_000 },
|
||||
).catch((error) => error);
|
||||
assert.equal(child.code, 2);
|
||||
assert.equal(
|
||||
JSON.parse(child.stdout).errorCode,
|
||||
"UNSAFE_DEBUG_ENVIRONMENT",
|
||||
);
|
||||
assertRedacted(child.stdout);
|
||||
assertRedacted(child.stderr);
|
||||
}
|
||||
});
|
||||
|
||||
for (const flag of ["--trace_tls", "--trace-tls=true", "--trace_tls=true"]) {
|
||||
for (const source of ["argv", "environment"]) {
|
||||
test(`refuses ${source} TLS tracing alias ${flag} without networking`, async (t) => {
|
||||
const previousArgv = process.execArgv;
|
||||
const previousOptions = process.env.NODE_OPTIONS;
|
||||
t.after(() => {
|
||||
process.execArgv = previousArgv;
|
||||
if (previousOptions === undefined) delete process.env.NODE_OPTIONS;
|
||||
else process.env.NODE_OPTIONS = previousOptions;
|
||||
});
|
||||
if (source === "argv") process.execArgv = [...previousArgv, flag];
|
||||
else process.env.NODE_OPTIONS = flag;
|
||||
let calls = 0;
|
||||
let output = "";
|
||||
const status = await main(["--url", publicUrl], {
|
||||
write: (line) => {
|
||||
output += line;
|
||||
},
|
||||
probe: async () => {
|
||||
calls++;
|
||||
return { outcome: "expected_rejection" };
|
||||
},
|
||||
});
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(status, 2);
|
||||
assert.equal(JSON.parse(output).errorCode, "UNSAFE_DEBUG_ENVIRONMENT");
|
||||
assertRedacted(output);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("two explicit targets run once in order and any unexpected result fails the command", async () => {
|
||||
for (const statuses of [
|
||||
[401, 401],
|
||||
[502, 401],
|
||||
[401, 202],
|
||||
]) {
|
||||
const calls = [];
|
||||
let output = "";
|
||||
const exit = await main(
|
||||
[
|
||||
"--url",
|
||||
publicUrl,
|
||||
"--compare-url",
|
||||
`http://127.0.0.1:3104${webhookPath}`,
|
||||
],
|
||||
{
|
||||
write: (line) => {
|
||||
output += line;
|
||||
},
|
||||
probe: async (target) => {
|
||||
const status = statuses[calls.length];
|
||||
calls.push(target.label);
|
||||
return {
|
||||
target: target.label,
|
||||
status,
|
||||
outcome:
|
||||
status === 401 ? "expected_rejection" : "unexpected_status",
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual(calls, ["public", "comparison"]);
|
||||
assert.equal(exit, statuses.every((status) => status === 401) ? 0 : 1);
|
||||
assert.equal(output.trim().split("\n").length, 2);
|
||||
assertRedacted(output);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
|
||||
function validateTests(tests, file) {
|
||||
assert.ok(Array.isArray(tests) && tests.length > 0, "Vitest must collect at least one test");
|
||||
for (const test of tests) {
|
||||
assert.equal(test.projectName, "@paperclipai/server", "unexpected test project");
|
||||
assert.equal(path.resolve(test.file), path.resolve(file), "unexpected test file");
|
||||
assert.ok(typeof test.name === "string" && test.name.length > 0, "missing test name");
|
||||
assert.ok(Number.isSafeInteger(test.location?.line) && test.location.line > 0, "missing test source line");
|
||||
}
|
||||
}
|
||||
|
||||
// Keep all cases registered on one source line together, including it.each
|
||||
// and loop-generated cases. Balance by collected case count, not line count.
|
||||
export function partitionTestLines(tests, count, file) {
|
||||
validateTests(tests, file);
|
||||
assert.ok(Number.isSafeInteger(count) && count > 0, "invalid shard count");
|
||||
const byLine = new Map();
|
||||
for (const test of tests) {
|
||||
const line = test.location.line;
|
||||
if (!byLine.has(line)) byLine.set(line, []);
|
||||
byLine.get(line).push(test);
|
||||
}
|
||||
assert.ok(byLine.size >= count, "each shard must contain a source line");
|
||||
const groups = [...byLine].sort((a, b) => b[1].length - a[1].length || a[0] - b[0]);
|
||||
const shards = Array.from({ length: count }, () => ({ lines: [], tests: [] }));
|
||||
for (const [line, cases] of groups) {
|
||||
const shard = shards.reduce((best, next) => next.tests.length < best.tests.length ? next : best);
|
||||
shard.lines.push(line);
|
||||
shard.tests.push(...cases);
|
||||
}
|
||||
for (const shard of shards) shard.lines.sort((a, b) => a - b);
|
||||
return shards;
|
||||
}
|
||||
|
||||
// Re-collect using the exact filters passed to the subsequent test run. A
|
||||
// Vitest filtering change must fail here instead of silently dropping cases.
|
||||
export function assertSelectedTests(expected, actual, file) {
|
||||
validateTests(actual, file);
|
||||
const identities = (tests) => tests.map((test) => JSON.stringify([
|
||||
test.projectName, path.resolve(test.file), test.location.line, test.name,
|
||||
])).sort();
|
||||
assert.deepEqual(identities(actual), identities(expected), "Vitest filters must select exactly the assigned tests");
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter, once } from "node:events";
|
||||
import { readFileSync } from "node:fs";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import test from "node:test";
|
||||
import { observeChatWebhookProxyRejection } from "../lib/chat-webhook-proxy-diagnostics.mjs";
|
||||
|
||||
const allowedHost = "dottas-macbook-pro.tail29c1aa.ts.net:8443";
|
||||
const allowedPath = "/api/chat-webhooks/PRIVATE_ENDPOINT_SENTINEL/slack";
|
||||
const closedKeys = [
|
||||
"bytes",
|
||||
"connectionId",
|
||||
"durationMs",
|
||||
"event",
|
||||
"reason",
|
||||
"statusCode",
|
||||
];
|
||||
|
||||
function assertPrivate(rows) {
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(rows),
|
||||
/private|sentinel|authorization|forwarded|cookie|https?:\/\//i,
|
||||
);
|
||||
}
|
||||
|
||||
function fixtureHandler(rows, connections, upstreamPort) {
|
||||
return (req, res) => {
|
||||
const observation = observeChatWebhookProxyRejection(req, res, {
|
||||
connectionId: connections.get(req.socket),
|
||||
emit: (row) => rows.push(row),
|
||||
});
|
||||
const reject = (reason, status) => {
|
||||
observation.reject(reason);
|
||||
req.on("data", (chunk) => observation.countBytes(chunk.length));
|
||||
res.writeHead(status).end();
|
||||
req.resume();
|
||||
};
|
||||
let url;
|
||||
try {
|
||||
url = new URL(req.url ?? "/", "http://localhost");
|
||||
} catch {
|
||||
reject("malformed_target", 400);
|
||||
return;
|
||||
}
|
||||
if (req.method !== "POST") return reject("method", 404);
|
||||
if (req.headers.host?.toLowerCase() !== allowedHost)
|
||||
return reject("host", 404);
|
||||
if (
|
||||
!/^\/api\/chat-webhooks\/[A-Za-z0-9_-]+\/(slack|github|telegram|microsoft-teams)\/?$/.test(
|
||||
url.pathname,
|
||||
)
|
||||
)
|
||||
return reject("path", 404);
|
||||
const upstream = http.request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port: upstreamPort,
|
||||
method: req.method,
|
||||
path: req.url,
|
||||
headers: req.headers,
|
||||
},
|
||||
(reply) => {
|
||||
res.writeHead(reply.statusCode, reply.headers);
|
||||
reply.pipe(res);
|
||||
},
|
||||
);
|
||||
upstream.on("error", () => {
|
||||
if (!res.headersSent) res.writeHead(502);
|
||||
res.end();
|
||||
});
|
||||
req.pipe(upstream);
|
||||
};
|
||||
}
|
||||
|
||||
async function harness(t, options = {}) {
|
||||
const rows = [];
|
||||
const connections = new WeakMap();
|
||||
const upstreamRequests = [];
|
||||
const upstream = http.createServer((req, res) => {
|
||||
upstreamRequests.push({ path: req.url, host: req.headers.host });
|
||||
req.on("error", () => {});
|
||||
req.on("end", () => res.writeHead(202).end());
|
||||
req.resume();
|
||||
});
|
||||
upstream.listen(0, "127.0.0.1");
|
||||
await once(upstream, "listening");
|
||||
let server;
|
||||
if (process.env.PAPERCLIP_WEBHOOK_PROXY_UNDER_TEST) {
|
||||
// Optional integration against the ignored local proxy; the portable suite
|
||||
// needs no live deployment/configuration or ignored workspace artifacts.
|
||||
const source = readFileSync(
|
||||
process.env.PAPERCLIP_WEBHOOK_PROXY_UNDER_TEST,
|
||||
"utf8",
|
||||
).replace(/^import .*;\r?\n/gm, "");
|
||||
const wrappedHttp = {
|
||||
createServer(handler) {
|
||||
server = http.createServer(handler);
|
||||
return { on: (...args) => server.on(...args), listen() {} };
|
||||
},
|
||||
request(options, callback) {
|
||||
assert.equal(options.hostname, "127.0.0.1");
|
||||
assert.equal(options.port, 3137);
|
||||
assert.equal(options.timeout, 30_000);
|
||||
return http.request(
|
||||
{ ...options, port: upstream.address().port },
|
||||
callback,
|
||||
);
|
||||
},
|
||||
};
|
||||
new Function(
|
||||
"http",
|
||||
"console",
|
||||
"observeChatWebhookProxyRejection",
|
||||
"process",
|
||||
source,
|
||||
)(
|
||||
wrappedHttp,
|
||||
{
|
||||
log: (line) => {
|
||||
try {
|
||||
rows.push(JSON.parse(line));
|
||||
} catch {}
|
||||
},
|
||||
},
|
||||
observeChatWebhookProxyRejection,
|
||||
{ env: { PAPERCLIP_QA_FAIL_ONCE_GITHUB_PATH: options.faultPath } },
|
||||
);
|
||||
} else {
|
||||
server = http.createServer(
|
||||
fixtureHandler(rows, connections, upstream.address().port),
|
||||
);
|
||||
let nextId = 1;
|
||||
server.on("connection", (socket) => connections.set(socket, nextId++));
|
||||
}
|
||||
const baseline = http.createServer();
|
||||
assert.equal(server.listenerCount("clientError"), 0);
|
||||
for (const key of [
|
||||
"keepAliveTimeout",
|
||||
"keepAliveTimeoutBuffer",
|
||||
"headersTimeout",
|
||||
"requestTimeout",
|
||||
"timeout",
|
||||
])
|
||||
assert.equal(server[key], baseline[key]);
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const agent = new http.Agent({ keepAlive: true, maxSockets: 1 });
|
||||
t.after(async () => {
|
||||
agent.destroy();
|
||||
server.closeAllConnections();
|
||||
upstream.closeAllConnections();
|
||||
await Promise.all([
|
||||
new Promise((resolve) => server.close(resolve)),
|
||||
new Promise((resolve) => upstream.close(resolve)),
|
||||
]);
|
||||
});
|
||||
const request = ({
|
||||
method = "POST",
|
||||
path = allowedPath,
|
||||
host = allowedHost,
|
||||
body = "PRIVATE_BODY_SENTINEL",
|
||||
headers = {},
|
||||
} = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
agent,
|
||||
host: "127.0.0.1",
|
||||
port: server.address().port,
|
||||
method,
|
||||
path,
|
||||
headers: {
|
||||
host,
|
||||
authorization: "PRIVATE_AUTH_SENTINEL",
|
||||
cookie: "PRIVATE_COOKIE_SENTINEL",
|
||||
"content-length": Buffer.byteLength(body),
|
||||
...headers,
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
res.resume();
|
||||
res.on("end", () =>
|
||||
resolve({ status: res.statusCode, socket: req.socket }),
|
||||
);
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end(body);
|
||||
});
|
||||
return { rows, server, upstreamRequests, request };
|
||||
}
|
||||
|
||||
test(
|
||||
"real keep-alive HTTP observes each rejection exactly once alongside accepted traffic",
|
||||
{ timeout: 5_000 },
|
||||
async (t) => {
|
||||
const { rows, request, upstreamRequests } = await harness(t);
|
||||
const first = await request();
|
||||
assert.equal(first.status, 202);
|
||||
for (const [input, reason, status] of [
|
||||
[{ method: "PUT" }, "method", 404],
|
||||
[{ host: "PRIVATE_HOST_SENTINEL.invalid" }, "host", 404],
|
||||
[
|
||||
{ path: "/PRIVATE_PATH_SENTINEL?secret=PRIVATE_QUERY_SENTINEL" },
|
||||
"path",
|
||||
404,
|
||||
],
|
||||
[{ path: "http://[PRIVATE_TARGET_SENTINEL" }, "malformed_target", 400],
|
||||
]) {
|
||||
const prior = rows.filter(
|
||||
(row) => row.event === "chat_webhook_proxy_rejection",
|
||||
).length;
|
||||
const response = await request(input);
|
||||
assert.equal(response.status, status);
|
||||
assert.equal(response.socket, first.socket);
|
||||
const observations = rows.filter(
|
||||
(row) => row.event === "chat_webhook_proxy_rejection",
|
||||
);
|
||||
assert.equal(observations.length, prior + 1);
|
||||
assert.equal(observations.at(-1).reason, reason);
|
||||
assert.equal(observations.at(-1).statusCode, status);
|
||||
assert.equal(observations.at(-1).connectionId, 1);
|
||||
assert.deepEqual(Object.keys(observations.at(-1)).sort(), closedKeys);
|
||||
}
|
||||
const last = await request({
|
||||
path: `${allowedPath}?token=PRIVATE_QUERY_SENTINEL`,
|
||||
});
|
||||
assert.equal(last.status, 202);
|
||||
assert.equal(last.socket, first.socket);
|
||||
assert.equal(upstreamRequests.length, 2);
|
||||
assertPrivate(rows);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"real Node parser retains native 400/431 and never reaches rejection observer",
|
||||
{ timeout: 5_000 },
|
||||
async (t) => {
|
||||
const { server, rows, upstreamRequests } = await harness(t);
|
||||
const exchange = (raw) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection({
|
||||
host: "127.0.0.1",
|
||||
port: server.address().port,
|
||||
});
|
||||
t.after(() => socket.destroy());
|
||||
let response = "";
|
||||
socket.setTimeout(2_000, () =>
|
||||
socket.destroy(new Error("fixture timeout")),
|
||||
);
|
||||
socket.on("connect", () => socket.write(raw));
|
||||
socket.on("data", (chunk) => {
|
||||
response += chunk.toString();
|
||||
});
|
||||
socket.on("error", reject);
|
||||
socket.on("close", () => resolve(response));
|
||||
});
|
||||
assert.match(
|
||||
await exchange(
|
||||
"INVALID_PRIVATE_METHOD_SENTINEL / HTTP/1.1\r\nHost: private.invalid\r\n\r\n",
|
||||
),
|
||||
/^HTTP\/1\.1 400 /,
|
||||
);
|
||||
assert.match(
|
||||
await exchange(
|
||||
`GET / HTTP/1.1\r\nHost: private.invalid\r\nX-Private: ${"x".repeat(http.maxHeaderSize + 1024)}\r\n\r\n`,
|
||||
),
|
||||
/^HTTP\/1\.1 431 /,
|
||||
);
|
||||
assert.equal(server.listenerCount("clientError"), 0);
|
||||
assert.equal(upstreamRequests.length, 0);
|
||||
assert.equal(
|
||||
rows.filter((row) => row.event === "chat_webhook_proxy_rejection").length,
|
||||
0,
|
||||
);
|
||||
assertPrivate(rows);
|
||||
},
|
||||
);
|
||||
|
||||
test("observer does not read/resume bodies and emits one closed event for abort then close", () => {
|
||||
const req = new EventEmitter();
|
||||
req.resume = () => assert.fail("observer must not resume request");
|
||||
const res = Object.assign(new EventEmitter(), { headersSent: false });
|
||||
const rows = [];
|
||||
const observation = observeChatWebhookProxyRejection(req, res, {
|
||||
connectionId: 4,
|
||||
emit: (row) => rows.push(row),
|
||||
});
|
||||
assert.equal(req.listenerCount("data"), 0);
|
||||
observation.reject("path");
|
||||
observation.countBytes(17);
|
||||
req.emit("aborted");
|
||||
res.emit("close");
|
||||
res.emit("finish");
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].bytes, 17);
|
||||
assert.equal(rows[0].statusCode, null);
|
||||
assert.equal(req.listenerCount("aborted"), 0);
|
||||
assertPrivate(rows);
|
||||
});
|
||||
|
||||
for (const failure of ["client_abort", "response_error"]) {
|
||||
test(
|
||||
`real HTTP ${failure} records one rejection without private error prose`,
|
||||
{ timeout: 5_000 },
|
||||
async (t) => {
|
||||
const rows = [];
|
||||
let signalBytes;
|
||||
const bytesRead = new Promise((resolve) => {
|
||||
signalBytes = resolve;
|
||||
});
|
||||
let response;
|
||||
let signalObservation;
|
||||
const observed = new Promise((resolve) => {
|
||||
signalObservation = resolve;
|
||||
});
|
||||
const server = http.createServer((req, res) => {
|
||||
response = res;
|
||||
const observation = observeChatWebhookProxyRejection(req, res, {
|
||||
connectionId: 1,
|
||||
emit: (row) => {
|
||||
rows.push(row);
|
||||
signalObservation();
|
||||
},
|
||||
});
|
||||
assert.equal(req.listenerCount("data"), 0);
|
||||
observation.reject("path");
|
||||
req.on("data", (chunk) => {
|
||||
observation.countBytes(chunk.length);
|
||||
signalBytes();
|
||||
});
|
||||
// The fixture deliberately holds its response so it can exercise the
|
||||
// observer's abort/close path, not change the deployed rejection policy.
|
||||
req.on("error", () => {});
|
||||
res.on("error", () => {});
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const socket = net.createConnection({
|
||||
host: "127.0.0.1",
|
||||
port: server.address().port,
|
||||
});
|
||||
socket.on("error", () => {});
|
||||
const closed = once(socket, "close");
|
||||
t.after(async () => {
|
||||
socket.destroy();
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
await once(socket, "connect");
|
||||
socket.write(
|
||||
"POST /PRIVATE_PATH_SENTINEL HTTP/1.1\r\nHost: PRIVATE_HOST_SENTINEL.invalid\r\nContent-Length: 100\r\n\r\nabc",
|
||||
);
|
||||
await bytesRead;
|
||||
if (failure === "client_abort") socket.destroy();
|
||||
else response.destroy(new Error("PRIVATE_ERROR_SENTINEL"));
|
||||
await Promise.all([observed, closed]);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(server.listenerCount("clientError"), 0);
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].bytes, 3);
|
||||
assert.equal(rows[0].statusCode, null);
|
||||
assert.deepEqual(Object.keys(rows[0]).sort(), closedKeys);
|
||||
assertPrivate(rows);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env.PAPERCLIP_WEBHOOK_PROXY_UNDER_TEST) {
|
||||
test(
|
||||
"wired proxy preserves its 1 MiB streaming limit and accepted timing",
|
||||
{ timeout: 5_000 },
|
||||
async (t) => {
|
||||
const { rows, request } = await harness(t);
|
||||
assert.equal(
|
||||
(await request({ body: Buffer.alloc(1_048_576) })).status,
|
||||
202,
|
||||
);
|
||||
assert.equal(
|
||||
(await request({ body: Buffer.alloc(1_048_577) })).status,
|
||||
413,
|
||||
);
|
||||
const timings = rows.filter(
|
||||
(row) => row.event === "chat_webhook_proxy_timing",
|
||||
);
|
||||
assert.deepEqual(
|
||||
timings.map((row) => [row.statusCode, row.outcome, row.bytes]),
|
||||
[
|
||||
[202, "completed", 1_048_576],
|
||||
[413, "body_too_large", 1_048_577],
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
rows.filter((row) => row.event === "chat_webhook_proxy_rejection")
|
||||
.length,
|
||||
0,
|
||||
);
|
||||
assertPrivate(rows);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"wired proxy preserves exact one-shot GitHub fault without rejected-route consumption",
|
||||
{ timeout: 5_000 },
|
||||
async (t) => {
|
||||
const faultPath = "/api/chat-webhooks/PRIVATE_ENDPOINT_SENTINEL/github";
|
||||
const { rows, request, upstreamRequests } = await harness(t, {
|
||||
faultPath,
|
||||
});
|
||||
assert.equal(
|
||||
(
|
||||
await request({
|
||||
method: "PUT",
|
||||
path: faultPath,
|
||||
headers: { "x-github-event": "issue_comment" },
|
||||
})
|
||||
).status,
|
||||
404,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request({
|
||||
path: faultPath,
|
||||
headers: { "x-github-event": "ping" },
|
||||
})
|
||||
).status,
|
||||
202,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request({
|
||||
path: faultPath,
|
||||
headers: { "x-github-event": "issue_comment" },
|
||||
})
|
||||
).status,
|
||||
503,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request({
|
||||
path: faultPath,
|
||||
headers: { "x-github-event": "issue_comment" },
|
||||
})
|
||||
).status,
|
||||
202,
|
||||
);
|
||||
assert.equal(upstreamRequests.length, 2);
|
||||
assert.equal(
|
||||
rows.filter((row) => row.outcome === "qualification_injected_503")
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
rows.filter((row) => row.event === "chat_webhook_proxy_rejection")
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
assertPrivate(rows);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test("closed labels, bounds and failing diagnostic sinks cannot change response handling", async () => {
|
||||
for (const value of ["PRIVATE_REASON_SENTINEL", "method"]) {
|
||||
const req = new EventEmitter();
|
||||
const res = Object.assign(new EventEmitter(), {
|
||||
headersSent: true,
|
||||
statusCode: 404,
|
||||
});
|
||||
const rows = [];
|
||||
let clock = 20;
|
||||
const observation = observeChatWebhookProxyRejection(req, res, {
|
||||
connectionId: "PRIVATE_ID_SENTINEL",
|
||||
now: () => clock,
|
||||
emit: (row) => rows.push(row),
|
||||
});
|
||||
observation.reject(value);
|
||||
observation.countBytes(Infinity);
|
||||
observation.countBytes(-1);
|
||||
observation.countBytes("PRIVATE_BYTES_SENTINEL");
|
||||
observation.countBytes(Number.MAX_SAFE_INTEGER);
|
||||
observation.countBytes(1);
|
||||
clock = -100;
|
||||
res.emit("finish");
|
||||
assert.equal(rows.length, value === "method" ? 1 : 0);
|
||||
if (rows.length)
|
||||
assert.deepEqual(rows[0], {
|
||||
event: "chat_webhook_proxy_rejection",
|
||||
reason: "method",
|
||||
connectionId: null,
|
||||
statusCode: 404,
|
||||
durationMs: 0,
|
||||
bytes: Number.MAX_SAFE_INTEGER,
|
||||
});
|
||||
assertPrivate(rows);
|
||||
}
|
||||
for (const emit of [
|
||||
() => {
|
||||
throw new Error("PRIVATE_ERROR_SENTINEL");
|
||||
},
|
||||
async () => {
|
||||
throw new Error("PRIVATE_ERROR_SENTINEL");
|
||||
},
|
||||
]) {
|
||||
const req = new EventEmitter();
|
||||
const res = Object.assign(new EventEmitter(), {
|
||||
headersSent: true,
|
||||
statusCode: 404,
|
||||
});
|
||||
observeChatWebhookProxyRejection(req, res, { emit }).reject("method");
|
||||
assert.doesNotThrow(() => res.emit("finish"));
|
||||
}
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
// Opt-in real Codex qualification; no model turn or live account data.
|
||||
// Run from the repository root:
|
||||
// node --import ./server/node_modules/tsx/dist/loader.mjs scripts/tests/native-cleanup-paginated-codex.mjs
|
||||
// PAPERCLIP_TEST_CODEX_BINARY may select the exact installed Codex 0.153.4 binary.
|
||||
// Fresh synthetic fixture directories are retained for inspection; never reuse live homes.
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import {
|
||||
mkdtemp,
|
||||
readdir,
|
||||
mkdir,
|
||||
writeFile,
|
||||
rename,
|
||||
cp,
|
||||
readFile,
|
||||
readlink,
|
||||
realpath,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, dirname, relative, isAbsolute } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { createHash } from "node:crypto";
|
||||
import { rebaseRetainedNativeCleanupProviderHome } from "../../server/src/services/native-runtime/native-session-executor.ts";
|
||||
|
||||
const codexBinary = process.env.PAPERCLIP_TEST_CODEX_BINARY ?? "codex";
|
||||
if (
|
||||
execFileSync(codexBinary, ["--version"], {
|
||||
encoding: "utf8",
|
||||
timeout: 10000,
|
||||
}).trim() !== "codex-cli 0.153.4"
|
||||
) {
|
||||
throw new Error(
|
||||
"This opt-in qualification requires Codex CLI 0.153.4. Requalify deliberately before changing the pin.",
|
||||
);
|
||||
}
|
||||
const home = await mkdtemp(join(tmpdir(), "paperclip-paginated-probe-"));
|
||||
const methods = [];
|
||||
async function withServer(home, callback) {
|
||||
const child = spawn(codexBinary, ["app-server"], {
|
||||
cwd: home,
|
||||
env: {
|
||||
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
||||
HOME: home,
|
||||
CODEX_HOME: home,
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
const finished = new Promise((resolve) => {
|
||||
child.once("exit", (code, signal) => resolve({ code, signal }));
|
||||
child.once("error", (error) => resolve({ error }));
|
||||
});
|
||||
child.stderr.on("data", () => {});
|
||||
const pending = new Map();
|
||||
let next = 0;
|
||||
createInterface({ input: child.stdout }).on("line", (line) => {
|
||||
const frame = JSON.parse(line);
|
||||
if (frame.id != null && pending.has(frame.id)) {
|
||||
const { resolve, reject, timer } = pending.get(frame.id);
|
||||
pending.delete(frame.id);
|
||||
clearTimeout(timer);
|
||||
frame.error
|
||||
? reject(new Error(JSON.stringify(frame.error)))
|
||||
: resolve(frame.result);
|
||||
}
|
||||
});
|
||||
const rpc = (method, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
methods.push(method);
|
||||
const id = ++next;
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error("local_rpc_timeout")),
|
||||
10000,
|
||||
);
|
||||
pending.set(id, { resolve, reject, timer });
|
||||
child.stdin.write(JSON.stringify({ id, method, params }) + "\n");
|
||||
});
|
||||
try {
|
||||
await rpc("initialize", {
|
||||
clientInfo: { name: "paperclip-fixture", version: "1" },
|
||||
capabilities: { experimentalApi: true },
|
||||
});
|
||||
child.stdin.write(JSON.stringify({ method: "initialized" }) + "\n");
|
||||
return await callback(rpc);
|
||||
} finally {
|
||||
let killTimer;
|
||||
let deadline;
|
||||
try {
|
||||
child.stdin.end();
|
||||
child.kill("SIGTERM");
|
||||
killTimer = setTimeout(() => child.kill("SIGKILL"), 2000);
|
||||
const exit = await Promise.race([
|
||||
finished,
|
||||
new Promise((_, reject) => {
|
||||
deadline = setTimeout(
|
||||
() => reject(new Error("fixture_child_exit_unproven")),
|
||||
5000,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
if (exit.error) throw exit.error;
|
||||
} finally {
|
||||
clearTimeout(killTimer);
|
||||
clearTimeout(deadline);
|
||||
for (const { timer } of pending.values()) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = await withServer(home, (rpc) =>
|
||||
rpc("thread/start", { cwd: home, model: "gpt-5.6-luna", ephemeral: false }),
|
||||
);
|
||||
const threadId = result.thread.id;
|
||||
const canonicalHome = await realpath(home);
|
||||
const rolloutRelative = relative(canonicalHome, result.thread.path);
|
||||
if (
|
||||
!/^[a-f0-9-]{36}$/.test(threadId) ||
|
||||
isAbsolute(rolloutRelative) ||
|
||||
!rolloutRelative.startsWith("sessions/") ||
|
||||
rolloutRelative.split("/").includes("..") ||
|
||||
!rolloutRelative.endsWith(`-${threadId}.jsonl`)
|
||||
) {
|
||||
throw new Error("Provider fixture path escaped the exact temporary home");
|
||||
}
|
||||
const timestamp = new Date().toISOString();
|
||||
// Same minimal paginated fixture shape as the pinned provider's own
|
||||
// app-server/tests/common/rollout.rs. No model call or turn/start is sent.
|
||||
const lines =
|
||||
[
|
||||
{
|
||||
type: "session_meta",
|
||||
payload: {
|
||||
id: threadId,
|
||||
session_id: threadId,
|
||||
timestamp,
|
||||
cwd: home,
|
||||
originator: "codex",
|
||||
cli_version: "0.153.4",
|
||||
source: "cli",
|
||||
model_provider: "openai",
|
||||
selected_capability_roots: [],
|
||||
history_mode: "paginated",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: "Synthetic retained fixture. No action requested.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "event_msg",
|
||||
payload: {
|
||||
type: "user_message",
|
||||
message: "Synthetic retained fixture. No action requested.",
|
||||
kind: "plain",
|
||||
},
|
||||
},
|
||||
]
|
||||
.map((v, ordinal) => JSON.stringify({ timestamp, ordinal, ...v }))
|
||||
.join("\n") + "\n";
|
||||
await mkdir(dirname(result.thread.path), { recursive: true });
|
||||
await writeFile(result.thread.path, lines, { flag: "wx" });
|
||||
// Materialize the synthetic persisted thread through the real provider so
|
||||
// SQLite, paginated history and rollout selection use the producer schema.
|
||||
await withServer(canonicalHome, (rpc) =>
|
||||
rpc("thread/resume", {
|
||||
threadId,
|
||||
cwd: canonicalHome,
|
||||
model: "gpt-5.6-luna",
|
||||
excludeTurns: true,
|
||||
}),
|
||||
);
|
||||
const original = `${canonicalHome}-archive`;
|
||||
await rename(canonicalHome, original);
|
||||
const fingerprint = async (root) => {
|
||||
const files = [];
|
||||
async function visit(relative) {
|
||||
for (const entry of (
|
||||
await readdir(join(root, relative), { withFileTypes: true })
|
||||
).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const p = join(relative, entry.name);
|
||||
if (entry.isDirectory()) await visit(p);
|
||||
else if (entry.isFile())
|
||||
files.push([
|
||||
p,
|
||||
createHash("sha256")
|
||||
.update(await readFile(join(root, p)))
|
||||
.digest("hex"),
|
||||
]);
|
||||
else if (entry.isSymbolicLink())
|
||||
files.push([p, "symlink", await readlink(join(root, p))]);
|
||||
else throw new Error("unsupported_synthetic_fixture_entry");
|
||||
}
|
||||
}
|
||||
await visit("");
|
||||
return createHash("sha256").update(JSON.stringify(files)).digest("hex");
|
||||
};
|
||||
const originalFingerprint = await fingerprint(original);
|
||||
const copied = `${canonicalHome}-copy`;
|
||||
await cp(original, copied, {
|
||||
recursive: true,
|
||||
filter: (source) => !["tmp", ".tmp"].includes(source.split("/").at(-1)),
|
||||
});
|
||||
const resume = (rpc) =>
|
||||
rpc("thread/resume", {
|
||||
threadId,
|
||||
cwd: copied,
|
||||
model: "gpt-5.6-luna",
|
||||
excludeTurns: true,
|
||||
});
|
||||
let red;
|
||||
try {
|
||||
await withServer(copied, resume);
|
||||
} catch (error) {
|
||||
red = error.message;
|
||||
}
|
||||
if (!red?.includes(`no rollout found for thread id ${threadId}`)) {
|
||||
console.log(JSON.stringify({ red, canonicalHome, copied, threadId }));
|
||||
throw new Error("expected_exact_missing_paginated_path");
|
||||
}
|
||||
rebaseRetainedNativeCleanupProviderHome(
|
||||
copied,
|
||||
canonicalHome,
|
||||
threadId,
|
||||
"staging",
|
||||
);
|
||||
const green = await withServer(copied, resume);
|
||||
if (green.thread.id !== threadId || green.thread.historyMode !== "paginated")
|
||||
throw new Error("wrong_resumed_identity");
|
||||
rebaseRetainedNativeCleanupProviderHome(
|
||||
copied,
|
||||
canonicalHome,
|
||||
threadId,
|
||||
"canonical",
|
||||
);
|
||||
await rename(copied, canonicalHome);
|
||||
const activated = await withServer(canonicalHome, (rpc) =>
|
||||
rpc("thread/resume", {
|
||||
threadId,
|
||||
cwd: canonicalHome,
|
||||
model: "gpt-5.6-luna",
|
||||
excludeTurns: true,
|
||||
}),
|
||||
);
|
||||
if (
|
||||
activated.thread.id !== threadId ||
|
||||
activated.thread.historyMode !== "paginated"
|
||||
)
|
||||
throw new Error("wrong_activated_identity");
|
||||
if (methods.some((method) => method === "turn/start"))
|
||||
throw new Error("unexpected_provider_turn");
|
||||
if (originalFingerprint !== (await fingerprint(original)))
|
||||
throw new Error("original_fixture_changed");
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
red: "exact_paginated_path_missing",
|
||||
green: true,
|
||||
activatedResume: true,
|
||||
threadId,
|
||||
historyMode: green.thread.historyMode,
|
||||
methods,
|
||||
original,
|
||||
canonicalHome,
|
||||
originalFingerprint,
|
||||
originalSnapshotPreserved: true,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,584 @@
|
||||
// Opt-in real Codex qualification. No model turn, credentials or live history.
|
||||
// node --import ./server/node_modules/tsx/dist/loader.mjs \
|
||||
// scripts/tests/native-provider-startup-codex.mjs --run \
|
||||
// --codex-binary /absolute/path/to/codex --expected-runner-sha256 <staged-hash>
|
||||
// Fresh 0700 fixture directories and their synthetic evidence are retained.
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
access,
|
||||
copyFile,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
realpath,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import {
|
||||
DurablePrpControlPlane,
|
||||
spawnRunner,
|
||||
} from "../../packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts";
|
||||
import {
|
||||
createRunnerdCodexAppServerArgs,
|
||||
defaultCapabilityRunnerdBinary,
|
||||
} from "../../packages/paperclip-runner/src/live/runnerd-codex-transport.ts";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0 || (args.length === 1 && args[0] === "--help")) {
|
||||
console.log(
|
||||
"Opt-in only: --run --codex-binary ABSOLUTE_CODEX --expected-runner-sha256 STAGED_SHA256. Creates retained synthetic fixtures; never sends turn/start.",
|
||||
);
|
||||
} else {
|
||||
let fixture;
|
||||
try {
|
||||
assert.equal(args.length, 5, "invalid_arguments");
|
||||
assert.equal(args[0], "--run", "explicit_run_required");
|
||||
assert.equal(args[1], "--codex-binary", "explicit_codex_binary_required");
|
||||
assert.ok(isAbsolute(args[2]), "absolute_codex_binary_required");
|
||||
assert.equal(
|
||||
args[3],
|
||||
"--expected-runner-sha256",
|
||||
"expected_staged_digest_required",
|
||||
);
|
||||
assert.match(args[4], /^[a-f0-9]{64}$/u, "invalid_expected_digest");
|
||||
const codexBinary = await realpath(args[2]);
|
||||
const runnerBinary = await realpath(defaultCapabilityRunnerdBinary());
|
||||
// Never silently qualify a debug fallback or a caller-selected alternate runner.
|
||||
assert.match(
|
||||
runnerBinary,
|
||||
/\/dist\/bin\/paperclip-runnerd$/u,
|
||||
"normal_staged_runner_required",
|
||||
);
|
||||
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
const runnerSha256 = digest(await readFile(runnerBinary));
|
||||
assert.equal(runnerSha256, args[4], "staged_runner_digest_changed");
|
||||
const codexSha256 = digest(await readFile(codexBinary));
|
||||
fixture = await realpath(
|
||||
await mkdtemp(join(tmpdir(), "paperclip-real-startup-")),
|
||||
);
|
||||
const home = join(fixture, "empty-codex-home");
|
||||
const workspace = join(fixture, "workspace");
|
||||
const controlDirectory = join(fixture, "control-plane");
|
||||
const runnerDirectory = join(fixture, "runner");
|
||||
const snapshots = join(fixture, "before-reopen");
|
||||
for (const directory of [home, workspace, snapshots])
|
||||
await mkdir(directory, { mode: 0o700 });
|
||||
// Pin file-only auth storage: this fixture must not consult a shared keychain.
|
||||
await writeFile(
|
||||
join(home, "config.toml"),
|
||||
'cli_auth_credentials_store = "file"\n',
|
||||
{ flag: "wx", mode: 0o600 },
|
||||
);
|
||||
const environment = {
|
||||
PATH: `${dirname(codexBinary)}:/usr/bin:/bin`,
|
||||
HOME: home,
|
||||
CODEX_HOME: home,
|
||||
LC_ALL: "C",
|
||||
};
|
||||
const version = execFileSync(codexBinary, ["--version"], {
|
||||
cwd: workspace,
|
||||
env: environment,
|
||||
encoding: "utf8",
|
||||
timeout: 10_000,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
assert.equal(
|
||||
version,
|
||||
"codex-cli 0.153.4",
|
||||
"qualified_codex_version_required",
|
||||
);
|
||||
const ledger = join(fixture, "provider-process-starts.txt");
|
||||
const identityLedger = join(fixture, "provider-process-identities.txt");
|
||||
const trace = join(fixture, "provider-trace.jsonl");
|
||||
const shim = join(fixture, "record-and-exec-codex");
|
||||
const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||
await writeFile(
|
||||
shim,
|
||||
`#!/bin/sh\nset -eu\nprintf '%s\\n' "$$" >> ${quote(ledger)}\nLC_ALL=C /bin/ps -p "$$" -o pid= -o pgid= -o lstart= >> ${quote(identityLedger)}\nexec ${quote(codexBinary)} "$@"\n`,
|
||||
{ flag: "wx", mode: 0o700 },
|
||||
);
|
||||
environment.PAPERCLIP_PROVIDER_TRACE_PATH = trace;
|
||||
environment.PAPERCLIP_PROVIDER_TRACE_MAX_BYTES = "1048576";
|
||||
const identity = {
|
||||
runnerInstanceId: `runner-${randomUUID()}`,
|
||||
environmentLeaseId: `lease-${randomUUID()}`,
|
||||
runId: randomUUID(),
|
||||
normalizedSessionId: randomUUID(),
|
||||
turnId: randomUUID(),
|
||||
itemId: randomUUID(),
|
||||
};
|
||||
const missingThread = randomUUID();
|
||||
const events = [];
|
||||
const startupCommandStatuses = [];
|
||||
const startupEvents = () =>
|
||||
events.filter(
|
||||
(event) =>
|
||||
event.eventType === "harness.diagnostic" &&
|
||||
event.payload.code === "provider_startup_ownership",
|
||||
);
|
||||
const makeCore = () =>
|
||||
new DurablePrpControlPlane({
|
||||
stateDirectory: controlDirectory,
|
||||
identity,
|
||||
expectedRunnerVersion: "0.3.0",
|
||||
expectedRunnerDigest: `sha256:${runnerSha256}`,
|
||||
onCommittedEvent: async (event) => {
|
||||
events.push(structuredClone(event));
|
||||
if (
|
||||
event.eventType === "harness.diagnostic" &&
|
||||
event.payload.code === "provider_startup_ownership"
|
||||
) {
|
||||
startupCommandStatuses.push(
|
||||
core.store.state.commands.find(
|
||||
(command) => command.commandId === "startup-open",
|
||||
)?.status,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
const readProvider = async () =>
|
||||
JSON.parse(
|
||||
await readFile(
|
||||
join(runnerDirectory, "codex-provider-state.json"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const readStarts = async () =>
|
||||
(await readFile(ledger, "utf8")).trim().split("\n").map(Number);
|
||||
const waitUntil = async (predicate, milliseconds = 20_000) => {
|
||||
const deadline = performance.now() + milliseconds;
|
||||
while (!(await predicate())) {
|
||||
if (performance.now() >= deadline)
|
||||
throw new Error("fixture_condition_timeout");
|
||||
await delay(10);
|
||||
}
|
||||
};
|
||||
const launch = (core, iteration) =>
|
||||
spawnRunner({
|
||||
connectUrl: core.connectUrl,
|
||||
stateDirectory: runnerDirectory,
|
||||
identity,
|
||||
ticket: core.issueBootstrapTicket(60_000),
|
||||
maxOutboxBytes: 16 * 1024 * 1024,
|
||||
p0ReserveBytes: 1024 * 1024,
|
||||
maxRuntimeMs: 10_000,
|
||||
reconnectGraceMs: 1_000,
|
||||
runnerBinaryPath: runnerBinary,
|
||||
runnerVersion: "0.3.0",
|
||||
runnerDigest: `sha256:${runnerSha256}`,
|
||||
environment,
|
||||
diagnosticsDirectory: join(fixture, `runner-diagnostics-${iteration}`),
|
||||
});
|
||||
// Join the exact ChildProcess on all paths. Signalling alone is never proof.
|
||||
const stopAndJoin = async (handle) => {
|
||||
if (!handle) return null;
|
||||
let terminateTimer;
|
||||
let killTimer;
|
||||
let finalTimer;
|
||||
try {
|
||||
terminateTimer = setTimeout(() => handle.child.kill("SIGTERM"), 12_000);
|
||||
killTimer = setTimeout(() => handle.child.kill("SIGKILL"), 14_000);
|
||||
const finished = handle.completion;
|
||||
const deadline = new Promise((_, reject) => {
|
||||
finalTimer = setTimeout(() => {
|
||||
reject(new Error("fixture_runner_exit_not_joined"));
|
||||
}, 17_000);
|
||||
});
|
||||
return await Promise.race([finished, deadline]);
|
||||
} finally {
|
||||
clearTimeout(terminateTimer);
|
||||
clearTimeout(killTimer);
|
||||
clearTimeout(finalTimer);
|
||||
}
|
||||
};
|
||||
// This is emergency hygiene for this fixture's recorded direct child only.
|
||||
// A PID absence check is not wait(2), and never proves whole-tree retirement.
|
||||
const retireRecordedProvider = async () => {
|
||||
const unproven = () => new Error("fixture_provider_cleanup_unproven");
|
||||
let recordedPids;
|
||||
let recordedIdentities;
|
||||
try {
|
||||
recordedPids = await readStarts();
|
||||
} catch {
|
||||
throw unproven();
|
||||
}
|
||||
try {
|
||||
recordedIdentities = (await readFile(identityLedger, "utf8"))
|
||||
.trim()
|
||||
.split("\n");
|
||||
} catch {
|
||||
throw unproven();
|
||||
}
|
||||
if (
|
||||
recordedPids.length !== recordedIdentities.length ||
|
||||
new Set(recordedPids).size !== recordedPids.length
|
||||
)
|
||||
throw unproven();
|
||||
const normalize = (value) => value.trim().replaceAll(/\s+/gu, " ");
|
||||
for (const [index, pid] of recordedPids.entries()) {
|
||||
if (!Number.isSafeInteger(pid) || pid <= 1) throw unproven();
|
||||
const expected = normalize(recordedIdentities[index]);
|
||||
const [recordedPid, pgid, ...birth] = expected.split(" ");
|
||||
if (
|
||||
recordedPid !== String(pid) ||
|
||||
pgid !== String(pid) ||
|
||||
!/^[A-Z][a-z]{2} [A-Z][a-z]{2} \d{1,2} \d{2}:\d{2}:\d{2} \d{4}$/u.test(
|
||||
birth.join(" "),
|
||||
)
|
||||
)
|
||||
throw unproven();
|
||||
const stillOwned = () => {
|
||||
let output;
|
||||
try {
|
||||
output = execFileSync(
|
||||
"/bin/ps",
|
||||
[
|
||||
"-ww",
|
||||
"-p",
|
||||
String(pid),
|
||||
"-o",
|
||||
"pid=",
|
||||
"-o",
|
||||
"pgid=",
|
||||
"-o",
|
||||
"lstart=",
|
||||
"-o",
|
||||
"command=",
|
||||
],
|
||||
{
|
||||
env: { PATH: "/usr/bin:/bin", LC_ALL: "C" },
|
||||
encoding: "utf8",
|
||||
timeout: 1_000,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (error?.status === 1 && String(error.stdout ?? "").trim() === "")
|
||||
return false;
|
||||
throw unproven();
|
||||
}
|
||||
const line = normalize(output);
|
||||
if (!line.startsWith(`${expected} `)) throw unproven();
|
||||
const command = line.slice(expected.length + 1);
|
||||
if (
|
||||
!(
|
||||
command.startsWith(`${normalize(codexBinary)} `) ||
|
||||
command.startsWith(`/bin/sh ${normalize(shim)} `)
|
||||
) ||
|
||||
!command.includes(`--cd ${normalize(workspace)} `)
|
||||
)
|
||||
throw unproven();
|
||||
return true;
|
||||
};
|
||||
if (!stillOwned()) continue;
|
||||
for (const signal of ["SIGTERM", "SIGKILL"]) {
|
||||
if (!stillOwned()) break;
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
} catch (error) {
|
||||
if (error?.code !== "ESRCH") throw unproven();
|
||||
}
|
||||
const deadline = performance.now() + 2_000;
|
||||
while (stillOwned() && performance.now() < deadline) await delay(25);
|
||||
}
|
||||
if (stillOwned()) throw unproven();
|
||||
}
|
||||
};
|
||||
const finishIteration = async (activeCore, activeHandle) => {
|
||||
try {
|
||||
return await stopAndJoin(activeHandle);
|
||||
} finally {
|
||||
try {
|
||||
await activeCore.stop();
|
||||
} finally {
|
||||
if (activeHandle) await retireRecordedProvider();
|
||||
}
|
||||
}
|
||||
};
|
||||
let core = makeCore();
|
||||
let handle;
|
||||
let firstExit;
|
||||
const prepare = core.queueCommand(
|
||||
"run.prepare",
|
||||
{
|
||||
provider: {
|
||||
kind: "codex",
|
||||
provider: "codex",
|
||||
driver: "codex_app_server",
|
||||
providerVersion: "0.153.4",
|
||||
command: shim,
|
||||
args: [
|
||||
"--cd",
|
||||
workspace,
|
||||
...createRunnerdCodexAppServerArgs({
|
||||
environment,
|
||||
codexHome: home,
|
||||
}),
|
||||
],
|
||||
cwd: workspace,
|
||||
model: null,
|
||||
providerSessionId: missingThread,
|
||||
instructions:
|
||||
"Synthetic startup qualification only. No model turn is authorized.",
|
||||
approvalPolicy: "never",
|
||||
externallySandboxed: false,
|
||||
},
|
||||
},
|
||||
"startup-prepare",
|
||||
);
|
||||
const open = core.queueCommand("session.open", {}, "startup-open");
|
||||
try {
|
||||
await core.start();
|
||||
handle = launch(core, 1);
|
||||
// Atomic controller commits publish new snapshots. Observe commands by
|
||||
// stable ID instead of waiting on the original queueCommand object.
|
||||
await waitUntil(
|
||||
() =>
|
||||
(core.getCommand(open.commandId)?.status ?? "pending") !== "pending",
|
||||
);
|
||||
assert.equal(
|
||||
core.getCommand(prepare.commandId)?.status,
|
||||
"completed",
|
||||
"prepare_must_succeed",
|
||||
);
|
||||
assert.equal(
|
||||
core.getCommand(open.commandId)?.status,
|
||||
"failed",
|
||||
"missing_thread_must_fail",
|
||||
);
|
||||
assert.ok(
|
||||
core
|
||||
.getCommand(open.commandId)
|
||||
?.result?.result?.message?.includes(
|
||||
`no rollout found for thread id ${missingThread}`,
|
||||
),
|
||||
"exact_missing_rollout_required",
|
||||
);
|
||||
// The callback completed each durable event commit before failed-command receipt.
|
||||
assert.deepEqual(
|
||||
startupEvents().map((event) => event.payload.startup.phase),
|
||||
["intent", "spawned", "initialization_failed"],
|
||||
);
|
||||
assert.deepEqual(startupCommandStatuses, [
|
||||
"pending",
|
||||
"pending",
|
||||
"pending",
|
||||
]);
|
||||
} finally {
|
||||
firstExit = await finishIteration(core, handle);
|
||||
}
|
||||
const providerBefore = await readProvider();
|
||||
const facts = startupEvents().map((event) => event.payload.startup);
|
||||
const [intent, spawned, failed] = facts;
|
||||
const starts = await readStarts();
|
||||
assert.equal(starts.length, 1, "exactly_one_real_provider_process");
|
||||
assert.ok(
|
||||
Number.isInteger(starts[0]) && starts[0] > 0,
|
||||
"valid_recorded_pid_required",
|
||||
);
|
||||
for (const fact of facts) {
|
||||
assert.equal(fact.schema, "paperclip.provider_startup.v1");
|
||||
assert.equal(fact.launchId, intent.launchId);
|
||||
assert.equal(fact.requestedThreadId, missingThread);
|
||||
assert.equal(fact.authenticatedThreadId, null);
|
||||
assert.equal(fact.processTreeRetired, false);
|
||||
assert.equal(fact.origin.runId, identity.runId);
|
||||
assert.equal(fact.origin.runnerInstanceId, identity.runnerInstanceId);
|
||||
assert.equal(fact.command.commandId, open.commandId);
|
||||
assert.equal(fact.command.controllerSeq, open.controllerSeq);
|
||||
assert.equal(
|
||||
fact.configurationFingerprint,
|
||||
intent.configurationFingerprint,
|
||||
);
|
||||
}
|
||||
assert.equal(spawned.processId, starts[0]);
|
||||
assert.equal(spawned.processGroupId, starts[0]);
|
||||
assert.equal(spawned.directChildExitObserved, false);
|
||||
assert.equal(failed.processId, starts[0]);
|
||||
assert.equal(failed.failedStage, "thread_open");
|
||||
assert.equal(failed.directChildExitObserved, true);
|
||||
assert.notEqual(failed.exitCode === null, failed.signal === null);
|
||||
assert.deepEqual(providerBefore.startupAttempt, failed);
|
||||
assert.equal(providerBefore.providerProcessGeneration, 0);
|
||||
const traceBefore = await readFile(trace);
|
||||
const traceRows = traceBefore
|
||||
.toString("utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line));
|
||||
assert.equal(
|
||||
traceRows.at(-1)?.kind,
|
||||
"trace_status",
|
||||
"trace_terminal_status_required",
|
||||
);
|
||||
assert.equal(
|
||||
traceRows.at(-1)?.status,
|
||||
"complete",
|
||||
"complete_provider_trace_required",
|
||||
);
|
||||
assert.deepEqual(
|
||||
traceRows.map((row) => row.debugSequence),
|
||||
traceRows.map((_, index) => index + 1),
|
||||
"complete_trace_sequence_required",
|
||||
);
|
||||
const sent = traceRows
|
||||
.filter(
|
||||
(row) => row.kind === "frame" && row.direction === "client_to_provider",
|
||||
)
|
||||
.map((row) =>
|
||||
JSON.parse(Buffer.from(row.rawBase64, "base64").toString("utf8")),
|
||||
);
|
||||
assert.deepEqual(
|
||||
sent.map((frame) => frame.method),
|
||||
["initialize", "initialized", "thread/resume"],
|
||||
);
|
||||
assert.equal(sent[2].params.threadId, missingThread);
|
||||
await assert.rejects(access(join(home, "auth.json")), { code: "ENOENT" });
|
||||
for (const [source, name] of [
|
||||
[
|
||||
join(controlDirectory, "control-plane-state.json"),
|
||||
"control-plane-state.json",
|
||||
],
|
||||
[join(runnerDirectory, "runner-state.json"), "runner-state.json"],
|
||||
[
|
||||
join(runnerDirectory, "codex-provider-state.json"),
|
||||
"codex-provider-state.json",
|
||||
],
|
||||
])
|
||||
await copyFile(source, join(snapshots, name));
|
||||
const firstFailure = structuredClone(
|
||||
core.getCommand(open.commandId)?.result,
|
||||
);
|
||||
const ledgerBefore = await readFile(ledger);
|
||||
const identityLedgerBefore = await readFile(identityLedger);
|
||||
core = makeCore();
|
||||
const snapshot = core.queueCommand(
|
||||
"session.snapshot",
|
||||
{},
|
||||
"reopened-snapshot",
|
||||
);
|
||||
const reopen = core.queueCommand("session.open", {}, "reopened-open");
|
||||
handle = null;
|
||||
let secondExit;
|
||||
try {
|
||||
await core.start();
|
||||
handle = launch(core, 2);
|
||||
await waitUntil(
|
||||
() =>
|
||||
(core.getCommand(snapshot.commandId)?.status ?? "pending") !==
|
||||
"pending" &&
|
||||
(core.getCommand(reopen.commandId)?.status ?? "pending") !==
|
||||
"pending",
|
||||
);
|
||||
for (const queued of [snapshot, reopen]) {
|
||||
const command = core.getCommand(queued.commandId);
|
||||
assert.ok(command, "reopened_command_must_exist");
|
||||
assert.equal(command.status, "failed");
|
||||
assert.ok(
|
||||
command.result?.result?.message?.includes(
|
||||
"provider startup ownership remains unadmitted",
|
||||
),
|
||||
"persisted_startup_fence_required",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
secondExit = await finishIteration(core, handle);
|
||||
}
|
||||
assert.deepEqual(
|
||||
await readFile(ledger),
|
||||
ledgerBefore,
|
||||
"reopen_started_another_provider_process",
|
||||
);
|
||||
assert.deepEqual(
|
||||
await readFile(identityLedger),
|
||||
identityLedgerBefore,
|
||||
"reopen_changed_process_identity_ledger",
|
||||
);
|
||||
assert.deepEqual(
|
||||
await readFile(trace),
|
||||
traceBefore,
|
||||
"reopen_sent_provider_rpc",
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await readProvider()).startupAttempt,
|
||||
failed,
|
||||
"original_startup_receipt_changed",
|
||||
);
|
||||
assert.deepEqual(
|
||||
core.store.state.commands.find(
|
||||
(command) => command.commandId === open.commandId,
|
||||
).result,
|
||||
firstFailure,
|
||||
);
|
||||
assert.ok(
|
||||
core.store.state.commands.every(
|
||||
(command) => !command.type.startsWith("turn."),
|
||||
),
|
||||
"no_turn_command_authorized",
|
||||
);
|
||||
assert.equal(startupEvents().length, 3);
|
||||
assert.equal(
|
||||
digest(await readFile(codexBinary)),
|
||||
codexSha256,
|
||||
"codex_binary_changed",
|
||||
);
|
||||
assert.equal(
|
||||
digest(await readFile(runnerBinary)),
|
||||
runnerSha256,
|
||||
"runner_binary_changed",
|
||||
);
|
||||
const summary = {
|
||||
outcome: "passed",
|
||||
fixture,
|
||||
codexVersion: version,
|
||||
codexSha256,
|
||||
runnerSha256,
|
||||
realCodexBehindRecordingExecShim: true,
|
||||
providerProcessId: starts[0],
|
||||
phases: facts.map((fact) => fact.phase),
|
||||
requestedThreadId: missingThread,
|
||||
authenticatedThreadId: null,
|
||||
directChildExitObserved: true,
|
||||
processTreeRetired: false,
|
||||
providerMethods: sent.map((frame) => frame.method),
|
||||
providerProcesses: starts.length,
|
||||
reopenDeniedBeforeProviderLaunch: true,
|
||||
originalReceiptPreserved: true,
|
||||
startupFactsCommittedBeforeFailure: true,
|
||||
runnerExits: [firstExit, secondExit].map((exit) => ({
|
||||
code: exit.code,
|
||||
signal: exit.signal,
|
||||
})),
|
||||
noHistoricalRecoveryAuthority: true,
|
||||
};
|
||||
await writeFile(
|
||||
join(fixture, "qualification-summary.json"),
|
||||
`${JSON.stringify(summary, null, 2)}\n`,
|
||||
{ flag: "wx", mode: 0o600 },
|
||||
);
|
||||
console.log(JSON.stringify(summary));
|
||||
} catch (error) {
|
||||
// Never print raw Codex/runner frames, command output or provider error prose.
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
outcome: "failed",
|
||||
fixture: fixture ?? null,
|
||||
code:
|
||||
error instanceof Error &&
|
||||
[
|
||||
"fixture_runner_exit_not_joined",
|
||||
"fixture_provider_cleanup_unproven",
|
||||
].includes(error.message)
|
||||
? error.message
|
||||
: error instanceof assert.AssertionError
|
||||
? "qualification_assertion_failed"
|
||||
: "qualification_failed",
|
||||
}),
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user