diff --git a/packages/db/scripts/create-auth-bootstrap-invite.ts b/packages/db/scripts/create-auth-bootstrap-invite.ts index 7af8a7d34f..36f7f28186 100644 --- a/packages/db/scripts/create-auth-bootstrap-invite.ts +++ b/packages/db/scripts/create-auth-bootstrap-invite.ts @@ -1,5 +1,5 @@ import { createHash, randomBytes } from "node:crypto"; -import { readFileSync } from "node:fs"; +import { readFileSync, realpathSync } from "node:fs"; import path from "node:path"; import { and, eq, gt, isNull } from "drizzle-orm"; import { createDb } from "../src/client.js"; @@ -31,13 +31,28 @@ async function main() { database?: { mode?: string; embeddedPostgresPort?: number; + embeddedPostgresDataDir?: string; connectionString?: string; }; }; + // The server can select another port when the configured one is occupied. + // Bind bootstrap to this data directory's running process, never another instance. + let embeddedPort: number | undefined; + if (config.database?.mode !== "postgres") { + const dataDir = config.database?.embeddedPostgresDataDir; + if (!dataDir) throw new Error("Embedded bootstrap requires its configured data directory"); + const pidLines = readFileSync(path.join(dataDir, "postmaster.pid"), "utf8").split(/\r?\n/); + if (realpathSync(pidLines[1] ?? "") !== realpathSync(dataDir)) throw new Error("Embedded bootstrap data directory does not match the running postmaster"); + const postmasterPid = Number(pidLines[0]); + if (!Number.isInteger(postmasterPid) || postmasterPid <= 1) throw new Error("Invalid embedded postmaster PID"); + process.kill(postmasterPid, 0); + embeddedPort = Number(pidLines[3]); + if (!Number.isInteger(embeddedPort) || embeddedPort < 1 || embeddedPort > 65535) throw new Error("Invalid running embedded database port"); + } const dbUrl = config.database?.mode === "postgres" ? config.database.connectionString - : `postgres://paperclip:paperclip@127.0.0.1:${config.database?.embeddedPostgresPort ?? 54329}/paperclip`; + : `postgres://paperclip:paperclip@127.0.0.1:${embeddedPort}/paperclip`; if (!dbUrl) { throw new Error(`Could not resolve database connection from ${configPath}`); } diff --git a/packages/paperclip-runner/scripts/generate-semantic-contracts.mjs b/packages/paperclip-runner/scripts/generate-semantic-contracts.mjs index 684e5a2bef..8721a1423c 100644 --- a/packages/paperclip-runner/scripts/generate-semantic-contracts.mjs +++ b/packages/paperclip-runner/scripts/generate-semantic-contracts.mjs @@ -3,10 +3,12 @@ import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import { serializeCapabilityGeneratedSemanticContracts } from "../dist/semantic-tools/provider-neutral.js"; import { PAPERCLIP_RUNNER_BUILD_METADATA } from "../dist/evals/build-metadata.js"; +import { buildProtocolManifest } from "./generate-protocol-manifest.mjs"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const outputPath = resolve(packageRoot, "generated/capability/semantic-tool-contracts.json"); const generated = serializeCapabilityGeneratedSemanticContracts(); +const manifestPath = resolve(packageRoot, "protocol/manifest.json"); // This is an explicitly seeded schema fixture, not retained live evidence. // Keep its advertised catalog identity synchronized with the shipped contracts. const fixturePath = resolve(packageRoot, "protocol/fixtures/evals/native-execution-seeded.json"); @@ -20,7 +22,12 @@ if (process.argv.includes("--check")) { process.exitCode = 1; } if (!fixtureCurrent) { - process.stderr.write("native-execution-seeded.json catalog is stale; run generate:semantic-contracts and generate:protocol-manifest\n"); + process.stderr.write("native-execution-seeded.json catalog is stale; run generate:semantic-contracts\n"); + process.exitCode = 1; + } + const manifest = `${JSON.stringify(await buildProtocolManifest(), null, 2)}\n`; + if (await readFile(manifestPath, "utf8").catch(() => "") !== manifest) { + process.stderr.write("protocol/manifest.json is stale; run generate:semantic-contracts\n"); process.exitCode = 1; } } else { @@ -29,5 +36,7 @@ if (process.argv.includes("--check")) { fixture.runner.catalogSha256 = PAPERCLIP_RUNNER_BUILD_METADATA.semanticCatalog.sha256; await writeFile(fixturePath, `${JSON.stringify(fixture, null, 2)}\n`); } - process.stdout.write(`wrote ${outputPath}\n`); + // The manifest hashes fixture bytes, so refresh it after the seeded catalog. + await writeFile(manifestPath, `${JSON.stringify(await buildProtocolManifest(), null, 2)}\n`); + process.stdout.write(`wrote ${outputPath} and ${manifestPath}\n`); } diff --git a/packages/paperclip-runner/src/evals/native-execution.test.ts b/packages/paperclip-runner/src/evals/native-execution.test.ts index e78760f63b..9700ae81b0 100644 --- a/packages/paperclip-runner/src/evals/native-execution.test.ts +++ b/packages/paperclip-runner/src/evals/native-execution.test.ts @@ -1,4 +1,9 @@ -import { readFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { cp, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import Ajv2020 from "ajv/dist/2020.js"; import { describe, expect, it } from "vitest"; @@ -10,8 +15,47 @@ import { parsePaperclipNativeExecution, } from "./native-execution.js"; import { PAPERCLIP_RUNNER_BUILD_METADATA } from "./build-metadata.js"; +import { serializeCapabilityGeneratedSemanticContracts } from "../semantic-tools/provider-neutral.js"; describe("paperclip-runner/native-execution/v1", () => { + it("refreshes a stale seeded catalog and its manifest with one semantic generator invocation", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-semantic-generator-")); + const packageRoot = fileURLToPath(new URL("../../", import.meta.url)); + const run = promisify(execFile); + try { + await cp(join(packageRoot, "protocol"), join(root, "protocol"), { recursive: true }); + await mkdir(join(root, "scripts")); + for (const script of ["generate-semantic-contracts.mjs", "generate-protocol-manifest.mjs", "protocol-contract.mjs"]) { + await cp(join(packageRoot, "scripts", script), join(root, "scripts", script)); + } + await symlink(join(packageRoot, "node_modules"), join(root, "node_modules"), "dir"); + await writeFile(join(root, "package.json"), JSON.stringify({ type: "module" })); + await mkdir(join(root, "dist/semantic-tools"), { recursive: true }); + await mkdir(join(root, "dist/evals"), { recursive: true }); + await mkdir(join(root, "generated/capability"), { recursive: true }); + // Materialize current source exports without requiring a previous package build. + await writeFile(join(root, "dist/semantic-tools/provider-neutral.js"), + `export const serializeCapabilityGeneratedSemanticContracts = () => ${JSON.stringify(serializeCapabilityGeneratedSemanticContracts())};`); + await writeFile(join(root, "dist/evals/build-metadata.js"), + `export const PAPERCLIP_RUNNER_BUILD_METADATA = ${JSON.stringify(PAPERCLIP_RUNNER_BUILD_METADATA)};`); + const fixturePath = join(root, "protocol/fixtures/evals/native-execution-seeded.json"); + const fixture = JSON.parse(await readFile(fixturePath, "utf8")); + fixture.runner.catalogSha256 = `sha256:${"0".repeat(64)}`; + await writeFile(fixturePath, `${JSON.stringify(fixture, null, 2)}\n`); + // Leave a stale manifest even if the restored fixture bytes happen to match the original. + await writeFile(join(root, "protocol/manifest.json"), "{}\n"); + const generator = join(root, "scripts/generate-semantic-contracts.mjs"); + await expect(run(process.execPath, [generator, "--check"])).rejects.toThrow(); + await run(process.execPath, [generator]); + expect(JSON.parse(await readFile(fixturePath, "utf8")).runner.catalogSha256) + .toBe(PAPERCLIP_RUNNER_BUILD_METADATA.semanticCatalog.sha256); + await run(process.execPath, [generator, "--check"]); + await run(process.execPath, [join(root, "scripts/generate-protocol-manifest.mjs"), "--check"]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("keeps the shipped seeded fixture valid against the published JSON Schema", async () => { const ajv = new Ajv2020({ allErrors: true, strict: false }); for (const schema of Object.values(prpSchemaBundle)) ajv.addSchema(schema); diff --git a/server/src/__tests__/adapter-session-codecs.test.ts b/server/src/__tests__/adapter-session-codecs.test.ts index 622ffb5773..613180674c 100644 --- a/server/src/__tests__/adapter-session-codecs.test.ts +++ b/server/src/__tests__/adapter-session-codecs.test.ts @@ -37,19 +37,6 @@ describe("adapter session codecs", () => { expect(claudeSessionCodec.getDisplayId?.(serialized ?? null)).toBe("claude-session-1"); }); - it("preserves Claude MCP identity across persistence so resumed turns keep their context", () => { - const params = { - sessionId: "11111111-1111-4111-8111-111111111111", - cwd: "/tmp/workspace", - mcpServerIdentity: JSON.stringify([{ - name: "Paperclip projects", - url: "http://localhost:3100/api/mcp/project-tools", - connectionId: "paperclip-project-tools", - }]), - }; - expect(claudeSessionCodec.deserialize(claudeSessionCodec.serialize(params))).toEqual(params); - }); - it("preserves claude ACP session params for ACP lane resumes", () => { const parsed = claudeSessionCodec.deserialize({ sessionKey: "paperclip:company:agent:task:fingerprint",