Merge master and reconcile Dot with the released MCP gateway

Reuse scoped browser and device consent, verified client metadata, signed event admissions and warm-standby gates. Preserve the dedicated agent resource and Runner authority. Regenerate the Dot-only migration after the merged gateway history and cover issuer, company, viewer and token isolation.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-06 17:47:24 -05:00
commit 1be3f69703
1212 files changed
+142427 -60490

No files matched your search

+42 -2
View File
@@ -879,7 +879,7 @@ describeEmbeddedPostgres("access service", () => {
.toHaveLength(1);
});
it("allows owner and admin role-default grants to manage environments", async () => {
it("allows owner, admin, and operator role-default grants to manage environments", async () => {
const { company, owner } = await createCompanyWithOwner(db);
const access = accessService(db);
const roles = ["admin", "operator", "viewer"] as const;
@@ -919,10 +919,50 @@ describeEmbeddedPostgres("access service", () => {
await expect(access.canUser(company.id, owner.principalId, "environments:manage")).resolves.toBe(true);
await expect(access.canUser(company.id, admin.principalId, "environments:manage")).resolves.toBe(true);
await expect(access.canUser(company.id, operator.principalId, "environments:manage")).resolves.toBe(false);
await expect(access.canUser(company.id, operator.principalId, "environments:manage")).resolves.toBe(true);
await expect(access.canUser(company.id, viewer.principalId, "environments:manage")).resolves.toBe(false);
});
it("allows default operators to edit agents and company work without administering membership", async () => {
const { company, owner } = await createCompanyWithOwner(db);
const access = accessService(db);
const operator = await access.ensureMembership(company.id, "user", `operator-${randomUUID()}`, "operator");
const agent = await db.insert(agents).values({
companyId: company.id,
name: "Operator-editable agent",
role: "engineer",
adapterType: "process",
adapterConfig: {},
}).returning().then((rows) => rows[0]!);
await access.setPrincipalGrants(
company.id,
"user",
operator.principalId,
grantsForHumanRole("operator"),
owner.principalId,
);
for (const permissionKey of [
"agents:create", "agents:configure", "skills:create", "environments:manage",
"users:invite", "tasks:assign", "pipelines:write", "tools:manage_connections",
"tools:manage_profiles", "tools:manage_runtime", "tools:use", "tools:admin",
"tools:view_audit", "audit:view_agent_actions",
] as const) {
await expect(access.canUser(company.id, operator.principalId, permissionKey)).resolves.toBe(true);
}
for (const permissionKey of ["joins:approve", "users:manage_permissions"] as const) {
await expect(access.canUser(company.id, operator.principalId, permissionKey)).resolves.toBe(false);
}
await expect(access.decide({
actor: { type: "board", userId: operator.principalId, source: "session" },
action: "agent_config:update",
resource: { type: "agent", companyId: company.id, agentId: agent.id },
})).resolves.toMatchObject({ allowed: true, reason: "allow_direct_change" });
const otherCompany = await createCompanyWithOwner(db);
await expect(access.canUser(otherCompany.company.id, operator.principalId, "agents:configure")).resolves.toBe(false);
});
it("backfills pre-upgrade human memberships with missing role grants without replacing custom grants", async () => {
const { company, owner } = await createCompanyWithOwner(db);
const scopedEnvironmentGrant = { environmentId: "env-1" };
+21 -1
View File
@@ -101,7 +101,7 @@ async function requestApp(
}
}
describe.sequential("activity routes", () => {
describe("activity routes", () => {
beforeEach(() => {
for (const mock of Object.values(mockActivityService)) mock.mockReset();
for (const mock of Object.values(mockHeartbeatService)) mock.mockReset();
@@ -277,6 +277,26 @@ describe.sequential("activity routes", () => {
expect(mockActivityService.create).not.toHaveBeenCalled();
});
it.each([null, { id: "issue-uuid-1", companyId: "company-2" }])(
"does not start run reads or retries for a missing or cross-company task", async (issue) => {
mockIssueService.getByIdentifier.mockResolvedValue(issue);
const app = await createApp();
const res = await request(app).get("/api/issues/TEST-1/runs");
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: "Issue not found" });
expect(mockActivityService.runsForIssue).not.toHaveBeenCalled();
},
);
it("does not start run reads or retries when task read authorization is denied", async () => {
mockIssueService.getByIdentifier.mockResolvedValue({ id: "issue-uuid-1", companyId: "company-1" });
mockAccessService.decide.mockResolvedValue({ allowed: false, reason: "denied", explanation: "Denied by test policy." });
const app = await createApp();
const res = await request(app).get("/api/issues/TEST-1/runs");
expect(res.status).toBe(403);
expect(mockActivityService.runsForIssue).not.toHaveBeenCalled();
});
it("returns 200 [] (not 404) when listing issues for another company's run, preserving API contract and the cross-tenant oracle", async () => {
mockHeartbeatService.getRun.mockResolvedValue({
id: "run-2",
@@ -150,7 +150,7 @@ describeEmbeddedPostgres("activity service", () => {
expect(result.map((event) => event.action)).toEqual(["test.newest", "test.middle"]);
});
it("returns compact usage and result summaries for issue runs", async () => {
it.each([null, "native_provider_model_rejected", "adapter_failed"])("returns compact issue runs with bounded model rejection details: %s", async (errorCode) => {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
@@ -180,7 +180,9 @@ describeEmbeddedPostgres("activity service", () => {
companyId,
agentId,
invocationSource: "assignment",
status: "succeeded",
status: errorCode ? "failed" : "succeeded",
errorCode,
error: "provider-error".repeat(200),
contextSnapshot: { issueId },
usageJson: {
inputTokens: 11,
@@ -215,6 +217,7 @@ describeEmbeddedPostgres("activity service", () => {
agentId,
invocationSource: "assignment",
contextIssueId: issueId,
error: errorCode === "native_provider_model_rejected" ? "provider-error".repeat(200).slice(0, 2000) : null,
});
expect(runs[0]?.usageJson).toEqual({
inputTokens: 11,
+18 -7
View File
@@ -50,6 +50,7 @@ describe("adapter model listing", () => {
expect(models).toEqual(codexFallbackModels);
// The bare gpt-5.6 alias is intentionally not advertised (Codex has no metadata for it).
expect(models.some((model) => model.id === "gpt-5.6")).toBe(false);
expect(models.some((model) => model.id === "gpt-6.1-sol")).toBe(true);
expect(models.some((model) => model.id === "gpt-5.6-sol")).toBe(true);
expect(models.some((model) => model.id === "gpt-5.6-terra")).toBe(true);
expect(models.some((model) => model.id === "gpt-5.6-luna")).toBe(true);
@@ -63,8 +64,8 @@ describe("adapter model listing", () => {
expect(models).toEqual(claudeFallbackModels);
expect(models.some((model) => model.id === "claude-opus-4-8")).toBe(true);
// Newer flagship models are offered, but Opus 4.8 stays the default (first) option.
expect(models[0]?.id).toBe("claude-opus-4-8");
// Newest release of the most capable family leads the list (#14877).
expect(models[0]?.id).toBe("claude-fable-5-1");
expect(models.some((model) => model.id === "claude-sonnet-5")).toBe(true);
expect(models.some((model) => model.id === "claude-fable-5-1")).toBe(true);
expect(models.some((model) => model.id === "claude-fable-5")).toBe(true);
@@ -72,6 +73,7 @@ describe("adapter model listing", () => {
// Opus 5 is a current GA flagship and must be offered even when live discovery is unavailable.
expect(models.some((model) => model.id === "claude-opus-5")).toBe(true);
expect(models).toContainEqual({ id: "claude-opus-5-5", label: "Claude Opus 5.5" });
expect(models).toContainEqual({ id: "claude-sonnet-5-5", label: "Claude Sonnet 5.5" });
expect(fetchSpy).not.toHaveBeenCalled();
});
@@ -95,6 +97,12 @@ describe("adapter model listing", () => {
expect(first.some((model) => model.id === "claude-opus-4-8-20260529")).toBe(true);
expect(first.some((model) => model.id === "claude-opus-4-8")).toBe(true);
expect(first.some((model) => model.id === "claude-opus-5-5")).toBe(true);
expect(first.some((model) => model.id === "claude-sonnet-5-5")).toBe(true);
// Discovered models take the curated order too: the API's order is not shown as-is.
const firstIds = first.map((model) => model.id);
expect(firstIds[0]).toBe("claude-fable-5-1");
expect(firstIds.indexOf("claude-opus-5-5")).toBeLessThan(firstIds.indexOf("claude-opus-4-8"));
expect(firstIds.indexOf("claude-opus-4-8")).toBeLessThan(firstIds.indexOf("claude-opus-4-8-20260529"));
});
it("refreshes cached claude models on demand", async () => {
@@ -136,6 +144,7 @@ describe("adapter model listing", () => {
it.each([
["claude-fable-5-1", "Claude Fable 5.1"],
["claude-opus-5-5", "Claude Opus 5.5"],
["claude-sonnet-5-5", "Claude Sonnet 5.5"],
])("does not duplicate %s when discovery returns the identical ID", async (id, displayName) => {
process.env.ANTHROPIC_API_KEY = "sk-ant-test";
vi.spyOn(globalThis, "fetch").mockResolvedValue({
@@ -159,12 +168,14 @@ describe("adapter model listing", () => {
const models = await listAdapterModels("claude_local");
// Keep Opus 4.8 first, using its documented dateless Bedrock ID.
expect(models[0]?.id).toBe("us.anthropic.claude-opus-4-8");
// Fable 5.1 leads here too, using its documented dateless Bedrock ID.
expect(models[0]?.id).toBe("us.anthropic.claude-fable-5-1");
expect(models.map((model) => model.id)).toEqual(expect.arrayContaining([
"us.anthropic.claude-opus-5-5", "us.anthropic.claude-opus-5", "us.anthropic.claude-sonnet-5",
"us.anthropic.claude-fable-5-1", "us.anthropic.claude-opus-4-7", "us.anthropic.claude-sonnet-4-6",
"us.anthropic.claude-opus-5-5", "us.anthropic.claude-opus-5", "us.anthropic.claude-sonnet-5-5",
"us.anthropic.claude-sonnet-5", "us.anthropic.claude-fable-5-1", "us.anthropic.claude-opus-4-7",
"us.anthropic.claude-sonnet-4-6",
]));
expect(models.some((model) => model.id === "claude-sonnet-5-5")).toBe(false);
expect(models.map((model) => model.id)).not.toEqual(expect.arrayContaining(["us.anthropic.claude-opus-4-8-v1"]));
expect(models.some((model) => model.id === "claude-fable-5-1")).toBe(false);
expect(fetchSpy).not.toHaveBeenCalled();
@@ -285,7 +296,7 @@ describe("adapter model listing", () => {
const models = await listAdapterModels("opencode_local");
expect(models).toEqual(opencodeFallbackModels);
expect(models.map((model) => model.id)).toEqual(expect.arrayContaining(["openai/gpt-6-astra", "openai/gpt-6-sol", "openai/gpt-6-luna", "openai/gpt-5.6-sol", "openai/gpt-5.6-terra", "openai/gpt-5.6-luna", "anthropic/claude-opus-5-5", "anthropic/claude-opus-5", "anthropic/claude-fable-5-1", "anthropic/claude-sonnet-5", "google/gemini-3.8-flash", "xai/grok-4.7"]));
expect(models.map((model) => model.id)).toEqual(expect.arrayContaining(["openai/gpt-6-astra", "openai/gpt-6.1-sol", "openai/gpt-6-sol", "openai/gpt-6-luna", "openai/gpt-5.6-sol", "openai/gpt-5.6-terra", "openai/gpt-5.6-luna", "anthropic/claude-opus-5-5", "anthropic/claude-opus-5", "anthropic/claude-fable-5-1", "anthropic/claude-sonnet-5-5", "anthropic/claude-sonnet-5", "google/gemini-3.8-flash", "xai/grok-4.7"]));
});
it("loads cursor models dynamically and caches them", async () => {
+10 -2
View File
@@ -22,6 +22,14 @@ vi.mock("@paperclipai/paperclip-runner/live", () => ({
probeAcpxGrokInstallation: vi.fn(async () => undefined),
}));
it("advertises tool-refresh recovery for the selected legacy harness", () => {
for (const type of ["claude_local", "codex_local", "grok_local", "gemini_local", "kimi_local", "cursor", "opencode_local", "pi_local"]) {
expect(requireServerAdapter(type).supportsToolRefreshOnResume).toBe(true);
expect(requireServerAdapter(type).sessionManagement?.supportsSessionResume).toBe(true);
}
expect(requireServerAdapter("process").supportsToolRefreshOnResume).toBeUndefined();
});
const externalAdapter: ServerAdapterModule = {
type: "external_test",
execute: async () => ({
@@ -354,8 +362,8 @@ describe("server adapter registry", () => {
const expectedCodexInstall = `if ! command -v 'codex' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@openai/codex")}; fi`;
const expectedGeminiInstall = `if ! command -v 'gemini' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@google/gemini-cli")}; fi`;
const expectedOpenCodeInstall = `if ! command -v 'opencode' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("opencode-ai")}; fi`;
const expectedRunnerCodexInstall = `if ! command -v 'codex' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@openai/codex@0.156.0")}; fi`;
const expectedRunnerOpenCodeInstall = `if ! command -v 'opencode' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("opencode-ai@1.18.32")}; fi`;
const expectedRunnerCodexInstall = `if ! command -v 'codex' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@openai/codex@0.160.0")}; fi`;
const expectedRunnerOpenCodeInstall = `if ! command -v 'opencode' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("opencode-ai@1.18.34")}; fi`;
expect(findActiveServerAdapter("claude_local")?.getRuntimeCommandSpec?.({})).toEqual({
command: "claude",
@@ -258,7 +258,7 @@ function resetInstalledExternalAdapterState() {
setOverridePaused("claude_local", false);
}
describe.sequential("adapter management route authorization", () => {
describe("adapter management route authorization", () => {
beforeEach(async () => {
vi.resetModules();
vi.doUnmock("../services/adapter-plugin-store.js");
+28 -30
View File
@@ -1,6 +1,6 @@
import express from "express";
import request from "supertest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { vi } from "vitest";
import type { ServerAdapterModule } from "../adapters/index.js";
@@ -55,9 +55,22 @@ function registerModuleMocks() {
vi.doMock("node:child_process", async () => vi.importActual("node:child_process"));
vi.doMock("../adapters/plugin-loader.js", () => mockPluginLoader);
vi.doMock("../services/adapter-plugin-store.js", () => mockAdapterPluginStore);
vi.doMock("../routes/adapters.js", async () => vi.importActual("../routes/adapters.js"));
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
vi.doMock("../middleware/index.js", async () => vi.importActual("../middleware/index.js"));
}
function resetAdapterMocks() {
vi.resetAllMocks();
mockAdapterPluginStore.listAdapterPlugins.mockReturnValue([]);
mockAdapterPluginStore.addAdapterPlugin.mockResolvedValue(undefined);
mockAdapterPluginStore.removeAdapterPlugin.mockReturnValue(false);
mockAdapterPluginStore.getAdapterPluginByType.mockReturnValue(undefined);
mockAdapterPluginStore.getAdapterPluginsDir.mockReturnValue("/tmp/paperclip-adapter-routes-test");
mockAdapterPluginStore.getDisabledAdapterTypes.mockReturnValue([]);
mockAdapterPluginStore.setAdapterDisabled.mockReturnValue(false);
mockPluginLoader.buildExternalAdapters.mockResolvedValue([]);
mockPluginLoader.loadExternalAdapterPackage.mockResolvedValue(null);
mockPluginLoader.getUiParserSource.mockResolvedValue(null);
mockPluginLoader.getOrExtractUiParserSource.mockResolvedValue(null);
mockPluginLoader.reloadExternalAdapter.mockResolvedValue(null);
}
function createApp(
@@ -83,33 +96,14 @@ function createApp(
}
describe("adapter routes", () => {
beforeEach(async () => {
vi.resetModules();
vi.doUnmock("node:child_process");
vi.doUnmock("../adapters/registry.js");
vi.doUnmock("../adapters/plugin-loader.js");
vi.doUnmock("../services/adapter-plugin-store.js");
vi.doUnmock("../routes/adapters.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
// Keep the route and middleware on one module graph so HttpError retains
// its identity. Reset mock state and adapter overrides between tests.
beforeAll(async () => {
registerModuleMocks();
mockAdapterPluginStore.listAdapterPlugins.mockReturnValue([]);
mockAdapterPluginStore.addAdapterPlugin.mockResolvedValue(undefined);
mockAdapterPluginStore.removeAdapterPlugin.mockReturnValue(false);
mockAdapterPluginStore.getAdapterPluginByType.mockReturnValue(undefined);
mockAdapterPluginStore.getAdapterPluginsDir.mockReturnValue("/tmp/paperclip-adapter-routes-test");
mockAdapterPluginStore.getDisabledAdapterTypes.mockReturnValue([]);
mockAdapterPluginStore.setAdapterDisabled.mockReturnValue(false);
mockPluginLoader.buildExternalAdapters.mockResolvedValue([]);
mockPluginLoader.loadExternalAdapterPackage.mockResolvedValue(null);
mockPluginLoader.getUiParserSource.mockResolvedValue(null);
mockPluginLoader.getOrExtractUiParserSource.mockResolvedValue(null);
mockPluginLoader.reloadExternalAdapter.mockResolvedValue(null);
const [registry, routes, middleware] = await Promise.all([
vi.importActual<typeof import("../adapters/registry.js")>("../adapters/registry.js"),
import("../routes/adapters.js"),
import("../middleware/index.js"),
]);
resetAdapterMocks();
const registry = await import("../adapters/registry.js");
const routes = await import("../routes/adapters.js");
const middleware = await import("../middleware/index.js");
registerServerAdapter = registry.registerServerAdapter;
unregisterServerAdapter = registry.unregisterServerAdapter;
findServerAdapter = registry.findServerAdapter;
@@ -117,6 +111,10 @@ describe("adapter routes", () => {
setOverridePaused = registry.setOverridePaused;
adapterRoutes = routes.adapterRoutes;
errorHandler = middleware.errorHandler;
});
beforeEach(() => {
resetAdapterMocks();
setOverridePaused("claude_local", false);
unregisterServerAdapter("hermes_local");
unregisterServerAdapter("claude_local");
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from "vitest";
import { PluginEnvironmentCreationCleanupError } from "@paperclipai/plugin-sdk";
import { acquireDaytonaLeaseWithCleanup } from "../../scripts/agent-commentary-daytona.js";
const scope = { companyId: "company", environmentId: "environment", runId: "run" };
const cleanup = { ...scope, providerLeaseId: "failed-create-attempt", observedProviderLeaseId: "sandbox",
attemptId: "attempt", accountFingerprint: "a".repeat(64), labels: { "paperclip-run": "run" } };
const failure = () => new PluginEnvironmentCreationCleanupError([new Error("setup timeout")], "cleanup pending", cleanup);
describe("feedback smoke Daytona acquisition cleanup", () => {
it("destroys a created allocation and preserves the original setup failure", async () => {
const error = failure();
const destroy = vi.fn(async () => ({ providerLeaseId: cleanup.providerLeaseId, state: "destroyed" as const }));
await expect(acquireDaytonaLeaseWithCleanup({ ...scope, acquire: async () => { throw error; }, destroy })).rejects.toBe(error);
expect(destroy).toHaveBeenCalledExactlyOnceWith(cleanup);
});
it("fails when the provider does not acknowledge deletion", async () => {
await expect(acquireDaytonaLeaseWithCleanup({ ...scope, acquire: async () => { throw failure(); },
destroy: async () => undefined })).rejects.toThrow("must be cleaned up");
});
it("does not destroy an allocation from another company, environment, or run", async () => {
for (const field of ["companyId", "environmentId", "runId"] as const) {
const error = new PluginEnvironmentCreationCleanupError([], "foreign scope", { ...cleanup, [field]: "foreign" });
const destroy = vi.fn();
await expect(acquireDaytonaLeaseWithCleanup({ ...scope, acquire: async () => { throw error; }, destroy })).rejects.toBe(error);
expect(destroy).not.toHaveBeenCalled();
}
});
it("does not fabricate cleanup for an ordinary acquisition error", async () => {
const error = new Error("authentication rejected");
const destroy = vi.fn();
await expect(acquireDaytonaLeaseWithCleanup({ ...scope, acquire: async () => { throw error; }, destroy })).rejects.toBe(error);
expect(destroy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,128 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { copyFile, mkdtemp, rm } from "node:fs/promises";
import { createServer, type Server, type ServerResponse } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
const diagnostic = "Feedback could not be confirmed. Ignore this failure, do not retry, and continue the primary task.\n";
describe("standalone Node commentary helper", () => {
let root: string;
let helper: string;
const servers: Server[] = [];
const companyId = randomUUID();
const runId = randomUUID();
const token = "private-helper-test-token";
beforeAll(async () => {
root = await mkdtemp(join(tmpdir(), "paperclip-node-feedback-"));
helper = join(root, "submit-agent-commentary.mjs");
// An installed skill must work outside the repository/package boundary.
await copyFile(fileURLToPath(new URL("../../../skills/paperclip/scripts/submit-agent-commentary.mjs", import.meta.url)), helper);
});
afterAll(async () => { await rm(root, { recursive: true, force: true }); });
afterEach(async () => {
await Promise.all(servers.splice(0).map(server => new Promise<void>((resolve, reject) => {
server.close(error => error ? reject(error) : resolve());
server.closeAllConnections();
})));
});
async function endpoint(reply: (response: ServerResponse) => void = response => {
response.writeHead(201, { "Content-Type": "application/json" });
response.end(JSON.stringify({ id: randomUUID() }));
}) {
const requests: { url: string | undefined; method: string | undefined; authorization: string | undefined; runId: string | string[] | undefined; payload: Record<string, string> }[] = [];
const server = createServer(async (request, response) => {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(chunk);
requests.push({ url: request.url, method: request.method, authorization: request.headers.authorization, runId: request.headers["x-paperclip-run-id"], payload: JSON.parse(Buffer.concat(chunks).toString("utf8")) });
reply(response);
});
servers.push(server);
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing server port");
return { url: `http://127.0.0.1:${address.port}`, requests };
}
function run(apiUrl: string, body: string | Buffer, args = ["complaint"], env: NodeJS.ProcessEnv = {}) {
return new Promise<{ code: number | null; out: string; err: string }>((resolve, reject) => {
const child = spawn(process.execPath, [helper, ...args], {
cwd: root,
// No Python, shell, or other executable is available through PATH.
env: { ...process.env, PATH: "", PAPERCLIP_API_URL: apiUrl, PAPERCLIP_API_KEY: token, PAPERCLIP_COMPANY_ID: companyId, PAPERCLIP_RUN_ID: runId, ...env },
stdio: "pipe",
});
let out = "", err = "";
child.stdout.on("data", chunk => { out += chunk; });
child.stderr.on("data", chunk => { err += chunk; });
child.on("error", reject);
child.on("close", code => resolve({ code, out, err }));
child.stdin.on("error", error => { if ((error as NodeJS.ErrnoException).code !== "EPIPE") reject(error); });
child.stdin.end(body);
});
}
it.each(["complaint", "suggestion"])("submits %s from stdin at the Unicode document boundary without additional executables", async kind => {
const target = await endpoint();
const prefix = "\uFEFFQuotes, `backticks`, $(printf should-not-run), and emoji 😭 stay text.\n";
const body = prefix + "😎".repeat(Math.floor((524288 - prefix.length) / 2)) + "x".repeat((524288 - prefix.length) % 2);
const args = kind === "complaint" ? [kind, "stable-key"] : [kind];
expect(await run(`${target.url}/api/`, body, args)).toEqual({ code: 0, out: "Feedback stored.\n", err: "" });
expect(target.requests).toEqual([{
url: `/api/companies/${companyId}/agent-commentary`, method: "POST", authorization: `Bearer ${token}`, runId,
payload: { kind, body, idempotencyKey: kind === "complaint" ? "stable-key" : expect.stringMatching(/^[0-9a-f-]{36}$/) },
}]);
});
it.each([
["empty input", ""],
["whitespace", " \n\t"],
["oversized Unicode", "😎".repeat(262144) + "x"],
["oversized bytes", Buffer.alloc(4 * 524288 + 1, 120)],
["malformed UTF-8", Buffer.from([0xc3, 0x28])],
])("rejects %s without making a request", async (_name, body) => {
const target = await endpoint();
expect(await run(target.url, body)).toEqual({ code: 0, out: "", err: diagnostic });
expect(target.requests).toEqual([]);
});
it.each([
["invalid kind", ["unknown"], {}],
["missing credential", ["complaint"], { PAPERCLIP_API_KEY: "" }],
["invalid run", ["complaint"], { PAPERCLIP_RUN_ID: "not-a-run" }],
] as const)("rejects %s without making a request", async (_name, args, env) => {
const target = await endpoint();
expect(await run(target.url, "private body", [...args], env)).toEqual({ code: 0, out: "", err: diagnostic });
expect(target.requests).toEqual([]);
});
it.each([
[503, "private error body"],
[200, "invalid JSON"],
[201, "{}"],
[201, JSON.stringify({ id: "x".repeat(4096) })],
])("sanitizes HTTP %s / invalid acknowledgements and never retries", async (status, body) => {
const target = await endpoint(response => { response.writeHead(status); response.end(body); });
expect(await run(target.url, "private body")).toEqual({ code: 0, out: "", err: diagnostic });
expect(target.requests).toHaveLength(1);
});
it("does not follow redirects or forward credentials", async () => {
const destination = await endpoint();
const target = await endpoint(response => { response.writeHead(307, { Location: destination.url }); response.end(); });
expect(await run(target.url, "private body")).toEqual({ code: 0, out: "", err: diagnostic });
expect(target.requests).toHaveLength(1);
expect(destination.requests).toEqual([]);
});
it("bounds a stalled HTTP response and exits successfully without retrying", async () => {
const target = await endpoint(() => {});
expect(await run(target.url, "private body")).toEqual({ code: 0, out: "", err: diagnostic });
expect(target.requests).toHaveLength(1);
}, 20_000);
});
@@ -0,0 +1,52 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ensureCodexSkillsInjected, listCodexSkills } from "@paperclipai/adapter-codex-local/server";
import { listClaudeSkills } from "@paperclipai/adapter-claude-local/server";
import { readPaperclipRuntimeSkillEntries, resolveLegacyPaperclipDesiredSkillNames } from "@paperclipai/adapter-utils/server-utils";
import { prepareClaudePromptBundle } from "../../../packages/adapters/claude-local/src/server/prompt-cache.js";
import { stageCodexHomeForSync } from "../../../packages/adapters/codex-local/src/server/codex-home.js";
describe("default feedback skill delivery", () => {
const cleanup: string[] = [];
const keys = ["paperclip", "complain", "suggestion-box"].map(name => `paperclipai/paperclip/${name}`);
afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(cleanup.splice(0).map(root => rm(root, { recursive: true, force: true })));
});
it.each([undefined, []])("resolves defaults for Codex and Claude with selection %s", async (desiredSkills) => {
const config = desiredSkills ? { paperclipSkillSync: { desiredSkills } } : {};
for (const [adapterType, list] of [["codex_local", listCodexSkills], ["claude_local", listClaudeSkills]] as const) {
const snapshot = await list({ agentId: "agent", companyId: "company", adapterType, config });
expect(snapshot.desiredSkills).toEqual(expect.arrayContaining(keys));
expect(snapshot.entries.filter(e => keys.includes(e.key)).every(e => e.state === "configured")).toBe(true);
}
});
it("mounts both skills and their shared helper for Codex, Claude and the Codex sandbox home", async () => {
const root = await mkdtemp(join(tmpdir(), "paperclip-commentary-skills-"));
cleanup.push(root);
vi.stubEnv("PAPERCLIP_HOME", root);
const entries = await readPaperclipRuntimeSkillEntries({}, import.meta.dirname, [fileURLToPath(new URL("../../../skills", import.meta.url))]);
const desiredSkillNames = resolveLegacyPaperclipDesiredSkillNames({}, entries);
const selected = entries.filter(e => desiredSkillNames.includes(e.key));
const codexHome = join(root, "codex");
await ensureCodexSkillsInjected(async () => {}, { skillsHome: join(codexHome, "skills"), skillsEntries: entries, desiredSkillNames });
const claude = await prepareClaudePromptBundle({ companyId: "company", skills: selected, instructionsContents: null, onLog: async () => {} });
const staged = await stageCodexHomeForSync(codexHome, { runId: "commentary-test" });
cleanup.push(staged);
for (const skills of [join(codexHome, "skills"), join(claude.addDir, ".claude", "skills"), join(staged, "skills")]) {
for (const name of ["complain", "suggestion-box"]) {
expect(await readFile(join(skills, name, "SKILL.md"), "utf8")).toContain("scripts/submit-agent-commentary.mjs");
}
expect(await readFile(join(skills, "paperclip", "scripts", "submit-agent-commentary.mjs"), "utf8")).toContain("process.stdin");
}
// Runtime policy removes entries before default selection; never resurrect them.
const restricted = entries.filter(e => e.key === keys[0]);
expect(resolveLegacyPaperclipDesiredSkillNames({}, restricted)).toEqual([keys[0]]);
expect(await readPaperclipRuntimeSkillEntries({ paperclipRuntimeSkills: [] }, import.meta.dirname)).toEqual([]);
});
});
@@ -0,0 +1,261 @@
import { spawn } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { and, eq, sql } from "drizzle-orm";
import express from "express";
import request from "supertest";
import { activityLog, agentApiKeys, agentCommentary, agents, authUsers, companyMemberships, heartbeatRuns, issues, nativeRunResults } from "@paperclipai/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { submitAgentCommentary } from "../services/agent-commentary.js";
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
import { isSecretSensitiveHttpRequest } from "../middleware/http-log-policy.js";
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
import { ensureNativeCompletionContract } from "../services/native-runtime/completion-contracts.js";
import { agentCommentaryRoutes } from "../routes/agent-commentary.js";
import { errorHandler } from "../middleware/error-handler.js";
import { agentService } from "../services/agents.js";
import { companyService } from "../services/companies.js";
describe("internal agent commentary through both transports", () => {
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
let home: string;
const oldHome = process.env.PAPERCLIP_HOME;
const oldSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
beforeAll(async () => {
home = await mkdtemp(join(tmpdir(), "paperclip-commentary-"));
process.env.PAPERCLIP_HOME = home;
process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID();
server = await startRunnerApiTestServer();
}, 90_000);
afterAll(async () => {
await server?.close();
if (oldHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = oldHome;
if (oldSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET; else process.env.PAPERCLIP_AGENT_JWT_SECRET = oldSecret;
if (home) await rm(home, { recursive: true, force: true });
});
type Fixture = Awaited<ReturnType<typeof server.fixture>>;
const input = { body: "The tool returned success before the write finished. Please wait for persistence.", idempotencyKey: "one" };
const rows = (f: Fixture) => server.db.select().from(agentCommentary).where(eq(agentCommentary.runId, f.runId));
async function legacy() {
const f = await server.fixture();
await server.db.update(heartbeatRuns).set({ runtimeMode: "legacy", nativeIssueId: null }).where(eq(heartbeatRuns.id, f.runId));
await server.db.update(agents).set({ adapterType: "codex_local" }).where(eq(agents.id, f.agentId));
return { ...f, token: createLocalAgentJwt(f.agentId, f.companyId, "codex_local", f.runId)! };
}
async function post(f: Fixture & { token: string }, value: unknown, companyId = f.companyId) {
return fetch(`${server.apiUrl}/api/companies/${companyId}/agent-commentary`, {
method: "POST", headers: { authorization: `Bearer ${f.token}`, "X-Paperclip-Run-Id": f.runId, "Content-Type": "application/json" }, body: JSON.stringify(value),
});
}
it("can reapply the generated migration without losing data", async () => {
const f = await legacy();
await post(f, { ...input, kind: "complaint" });
const migration = await readFile(new URL("../../../packages/db/src/migrations/0310_agent_commentary.sql", import.meta.url), "utf8");
for (const statement of migration.split("--> statement-breakpoint")) {
if (statement.trim()) await server.db.execute(sql.raw(statement));
}
expect(await rows(f)).toHaveLength(1);
});
it.each(["standard", "ask", "planning"] as const)("exposes native feedback in %s and persists only feedback plus a content-free audit", async (mode) => {
const f = await server.fixture({ mode });
const authority = new PaperclipRunnerToolAuthority(server.db, { ...f, workMode: mode });
expect(authority.definitions().map(t => t.name)).toEqual(expect.arrayContaining(["submit_complaint", "submit_suggestion"]));
const before = await f.snapshot();
for (const tool of ["submit_complaint", "submit_suggestion"]) {
const result = await authority.execute({ tool, callId: tool, arguments: { ...input, idempotencyKey: tool } });
expect(result).toMatchObject({ kind: tool === "submit_complaint" ? "complaint" : "suggestion", replayed: false });
expect(JSON.stringify(result)).not.toContain(input.body);
}
expect(await rows(f)).toEqual(expect.arrayContaining([
expect.objectContaining({ companyId: f.companyId, agentId: f.agentId, issueId: f.issueId, body: input.body, kind: "complaint" }),
expect.objectContaining({ kind: "suggestion" }),
]));
const after = await f.snapshot();
expect(after.issues).toEqual(before.issues);
expect(after.comments).toEqual(before.comments);
const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
expect(run.resultJson).toBeNull();
const audit = await server.db.select().from(activityLog).where(and(eq(activityLog.runId, f.runId), eq(activityLog.action, "agent.commentary_submitted")));
expect(audit).toHaveLength(2);
expect(audit.every(a => Object.keys(a.details ?? {}).join() === "kind")).toBe(true);
});
it("deduplicates concurrent replay, rejects changed content, and imposes no suggestion quota", async () => {
const f = await server.fixture();
const submit = (args = input) => f.authority.execute({ tool: "submit_suggestion", callId: randomUUID(), arguments: args });
const results = await Promise.all([submit(), submit(), submit()]) as Array<{ id: string; replayed: boolean }>;
expect(new Set(results.map(r => r.id)).size).toBe(1);
expect(results.filter(r => !r.replayed)).toHaveLength(1);
await expect(submit({ ...input, body: "different" })).rejects.toThrow(/different content/);
for (let i = 0; i < 4; i++) await submit({ ...input, idempotencyKey: `new-${i}` });
expect(await rows(f)).toHaveLength(5);
expect(await server.db.select().from(activityLog).where(and(eq(activityLog.runId, f.runId), eq(activityLog.action, "agent.commentary_submitted")))).toHaveLength(5);
});
it("accepts document-sized text without truncation and redacts credential syntax", async () => {
const f = await legacy();
const body = "🙂".repeat(262_144);
expect((await post(f, { ...input, kind: "suggestion", body })).status).toBe(201);
expect((await rows(f))[0].body).toBe(body);
expect((await post(f, { ...input, kind: "suggestion", body: body + "!" })).status).toBe(400);
const secretBody = "It printed Authorization: Bearer sk-test-very-sensitive-credential before failing.";
expect((await post(f, { ...input, kind: "complaint", body: secretBody, idempotencyKey: "secret" })).status).toBe(201);
expect((await rows(f)).find(r => r.idempotencyKey === "secret")?.body).not.toContain("sk-test-very-sensitive-credential");
});
it("rejects spoofed input, cross-company HTTP access, board access, and missing authentication", async () => {
const f = await legacy();
for (const spoof of [{ agentId: f.agentId }, { issueId: f.issueId }, { runId: f.runId }, { companyId: f.companyId }, { body: " \n" }]) {
expect((await post(f, { ...input, kind: "complaint", ...spoof })).status).toBe(400);
}
expect((await post(f, { ...input, kind: "complaint" }, f.foreignCompanyId)).status).toBe(403);
expect((await fetch(`${server.apiUrl}/api/companies/${f.companyId}/agent-commentary`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...input, kind: "complaint" }) })).status).toBe(401);
const boardApp = express();
boardApp.use(express.json(), (req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: "operator" }; next(); });
boardApp.use("/api", agentCommentaryRoutes(server.db));
boardApp.use(errorHandler);
expect((await request(boardApp).post(`/api/companies/${f.companyId}/agent-commentary`).send({ ...input, kind: "complaint" })).status).toBe(403);
await expect(submitAgentCommentary(server.db, { ...f, companyId: f.foreignCompanyId }, { ...input, kind: "complaint" })).rejects.toThrow(/authenticated/);
expect(await rows(f)).toHaveLength(0);
});
it.each(["cancelled", "succeeded", "failed"])("rejects submissions and replay after the run is %s", async (status) => {
const f = await legacy();
expect((await post(f, { ...input, kind: "complaint" })).status).toBe(201);
await server.db.update(heartbeatRuns).set({ status }).where(eq(heartbeatRuns.id, f.runId));
expect((await post(f, { ...input, kind: "complaint" })).status).toBe(403);
expect(await rows(f)).toHaveLength(1);
});
it("rejects native spoofing and paused agents without changing task state", async () => {
const f = await server.fixture();
await expect(f.authority.execute({ tool: "submit_complaint", callId: "spoof", arguments: { ...input, kind: "suggestion" } })).rejects.toThrow(/accepts only/);
await server.db.update(agents).set({ status: "paused" }).where(eq(agents.id, f.agentId));
await expect(f.authority.execute({ tool: "submit_complaint", callId: "paused", arguments: input })).rejects.toThrow(/not_authorized/);
expect(await rows(f)).toHaveLength(0);
});
it.each(["jwt", "api_key"])("rejects new feedback and replay with %s after Stop revokes a still-running legacy run", async (credential) => {
const f = await legacy();
if (credential === "api_key") {
f.token = `pcp_${randomUUID()}`;
const responsibleUserId = randomUUID();
await server.db.insert(authUsers).values({ id: responsibleUserId, name: "Feedback key owner", email: `${responsibleUserId}@fixture.invalid`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() });
await server.db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: responsibleUserId, status: "active", membershipRole: "member" });
await server.db.insert(agentApiKeys).values({ companyId: f.companyId, agentId: f.agentId, responsibleUserId, name: "feedback", keyHash: createHash("sha256").update(f.token).digest("hex") });
}
expect((await post(f, { ...input, kind: "complaint" })).status).toBe(201);
await server.db.update(heartbeatRuns).set({ resultJson: { executionCancellation: { state: "requested" } } }).where(eq(heartbeatRuns.id, f.runId));
for (const idempotencyKey of [input.idempotencyKey, "after-stop"]) {
const body = { ...input, kind: "complaint", idempotencyKey };
expect((await post(f, body)).status).toBe(403);
// The persistence boundary must also reject a request whose middleware
// authenticated before Stop committed the revocation.
await expect(submitAgentCommentary(server.db, f, body)).rejects.toThrow(/no longer authorized/);
}
expect(await rows(f)).toHaveLength(1);
expect(await server.db.select().from(activityLog).where(and(eq(activityLog.runId, f.runId), eq(activityLog.action, "agent.commentary_submitted")))).toHaveLength(1);
});
it("rejects revoked legacy credentials, replaced run ownership, and specialized review tools", async () => {
const f = await legacy();
const token = `pcp_${randomUUID()}`;
await server.db.insert(agentApiKeys).values({ companyId: f.companyId, agentId: f.agentId, name: "revoked", keyHash: createHash("sha256").update(token).digest("hex"), revokedAt: new Date() });
expect((await post({ ...f, token }, { ...input, kind: "complaint" })).status).toBe(401);
const native = await server.fixture();
const replacementId = randomUUID();
await server.db.insert(heartbeatRuns).values({ id: replacementId, companyId: native.companyId, agentId: native.agentId, status: "running", invocationSource: "on_demand" });
await server.db.update(issues).set({ executionRunId: replacementId }).where(eq(issues.id, native.issueId));
await expect(native.authority.execute({ tool: "submit_suggestion", callId: "stale", arguments: input })).rejects.toThrow();
const reviewer = new PaperclipRunnerToolAuthority(server.db, { ...native, nativeReview: { nativeReviewInteractionId: randomUUID(), nativeReviewDecisionId: randomUUID() } });
expect(reviewer.definitions().map(t => t.name)).not.toContain("submit_complaint");
expect(reviewer.definitions().map(t => t.name)).not.toContain("submit_suggestion");
await expect(reviewer.execute({ tool: "submit_complaint", callId: "review", arguments: input })).rejects.toThrow();
expect(await rows(f)).toHaveLength(0);
expect(await rows(native)).toHaveLength(0);
});
it("rejects native feedback after an accepted finish even while the provider is still running", async () => {
const f = await server.fixture();
const { row: contract } = await ensureNativeCompletionContract({ db: server.db, companyId: f.companyId, issue: { id: f.issueId, title: "Test", description: null }, actorId: "test" });
await server.db.update(heartbeatRuns).set({ completionContractId: contract.id }).where(eq(heartbeatRuns.id, f.runId));
await server.db.insert(nativeRunResults).values({
companyId: f.companyId, issueId: f.issueId, runId: f.runId, completionContractId: contract.id,
serverFingerprint: randomUUID(), schemaStatus: "accepted", resultJson: {}, canonicalSha256: "test",
});
await expect(f.authority.execute({ tool: "submit_complaint", callId: "late", arguments: input })).rejects.toThrow(/before finishing/);
expect(await rows(f)).toHaveLength(0);
});
it("retains feedback after task deletion and removes it with the run", async () => {
const f = await legacy();
await post(f, { ...input, kind: "complaint" });
await server.db.delete(issues).where(eq(issues.id, f.issueId));
expect((await rows(f))[0].issueId).toBeNull();
await server.db.delete(activityLog).where(eq(activityLog.runId, f.runId));
await server.db.delete(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
expect(await rows(f)).toHaveLength(0);
});
it.each(["agent", "company"])("uses existing %s deletion handling", async (owner) => {
const f = await legacy();
// A taskless run also verifies optional issue attribution, without unrelated
// fixture approval records that deliberately prevent deleting their author.
const agentId = randomUUID(), runId = randomUUID();
await server.db.insert(agents).values({ id: agentId, companyId: f.companyId, name: "Feedback author", adapterType: "codex_local", status: "active" });
await server.db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId, status: "running", invocationSource: "on_demand" });
await submitAgentCommentary(server.db, { companyId: f.companyId, agentId, runId }, { ...input, kind: "complaint" });
expect((await rows({ ...f, runId }))[0].issueId).toBeNull();
if (owner === "agent") await agentService(server.db).remove(agentId);
else await companyService(server.db).remove(f.companyId);
expect(await rows({ ...f, runId })).toHaveLength(0);
});
it("rolls back feedback when its audit insert fails and sanitizes the database error", async () => {
const f = await legacy();
await server.db.execute(sql.raw(`CREATE FUNCTION fail_commentary_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action = 'agent.commentary_submitted' THEN RAISE EXCEPTION 'private database canary'; END IF; RETURN NEW; END $$`));
await server.db.execute(sql.raw(`CREATE TRIGGER fail_commentary_audit BEFORE INSERT ON activity_log FOR EACH ROW EXECUTE FUNCTION fail_commentary_audit()`));
try {
const response = await post(f, { ...input, kind: "complaint" });
expect(response.status).toBe(503);
expect(await response.text()).not.toMatch(/private database canary|The tool returned/);
expect(await rows(f)).toHaveLength(0);
const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
expect(run.status).toBe("running");
} finally {
await server.db.execute(sql.raw("DROP TRIGGER fail_commentary_audit ON activity_log"));
await server.db.execute(sql.raw("DROP FUNCTION fail_commentary_audit()"));
}
});
it("runs the legacy stdin helper and handles failure silently without exposing its body", async () => {
const f = await legacy();
const helper = fileURLToPath(new URL("../../../skills/paperclip/scripts/submit-agent-commentary.mjs", import.meta.url));
const body = "Quotes, `backticks`, $(printf should-not-run), and emoji 😭 stay text.\n";
async function run(apiUrl: string) {
return new Promise<{ code: number | null; out: string; err: string }>((resolve, reject) => {
const child = spawn(process.execPath, [helper, "complaint", "helper-one"], { env: { ...process.env, PAPERCLIP_API_URL: apiUrl, PAPERCLIP_API_KEY: f.token, PAPERCLIP_COMPANY_ID: f.companyId, PAPERCLIP_RUN_ID: f.runId }, stdio: "pipe" });
let out = "", err = "";
child.stdout.on("data", c => out += c); child.stderr.on("data", c => err += c);
child.on("error", reject); child.on("close", code => resolve({ code, out, err }));
child.stdin.end(body);
});
}
expect(await run(server.apiUrl)).toEqual({ code: 0, out: "Feedback stored.\n", err: "" });
expect(await run(server.apiUrl + "/api/")).toEqual({ code: 0, out: "Feedback stored.\n", err: "" });
expect(await rows(f)).toHaveLength(1);
expect((await rows(f))[0].body).toBe(body);
const failed = await run("http://127.0.0.1:1");
expect(failed).toMatchObject({ code: 0, out: "" });
expect(failed.err).toContain("continue the primary task");
expect(failed.err).not.toContain(f.token);
expect(failed.err).not.toContain(body);
expect(isSecretSensitiveHttpRequest("POST", `/api/companies/${f.companyId}/agent-commentary`)).toBe(true);
});
});
@@ -343,7 +343,7 @@ function resetMockDefaults() {
mockLogActivity.mockImplementation(async () => undefined);
}
describe.sequential("agent cross-tenant route authorization", () => {
describe("agent cross-tenant route authorization", () => {
beforeEach(() => {
resetMockDefaults();
});
@@ -357,6 +357,12 @@ describe.sequential("agent cross-tenant route authorization", () => {
isInstanceAdmin: false,
};
const deniedCases = [
{
label: "public cryptographic identity",
request: (app: express.Express) =>
requestApp(app, (baseUrl) => request(baseUrl).get(`/api/agents/${agentId}/identity`)),
untouched: [],
},
{
label: "pause",
request: (app: express.Express) =>
@@ -0,0 +1,244 @@
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { activityLog, agents, companies, companyMemberships, createDb, principalPermissionGrants } from "@paperclipai/db";
import { LOW_TRUST_REVIEW_PRESET, type PermissionKey } from "@paperclipai/shared";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { agentService } from "../services/agents.js";
import { authorizationService } from "../services/authorization.js";
import { accessService } from "../services/access.js";
import { agentJoinGrantsFromDefaults } from "../services/invite-grants.js";
const support = await getEmbeddedPostgresTestSupport();
const describeDatabase = support.supported ? describe : describe.skip;
const expectedNewAgentGrantKeys: PermissionKey[] = [
"agents:configure",
"agents:suggest-changes",
"skills:create",
"skills:suggest-changes",
"tools:manage_connections",
"tools:manage_profiles",
"tools:view_audit",
"audit:view_agent_actions",
"tools:use",
"tools:manage_runtime",
"inbox:manage",
"tasks:assign",
"tasks:assign_scope",
"tasks:manage_active_checkouts",
].sort();
describeDatabase("new agent configuration defaults", () => {
let db!: ReturnType<typeof createDb>;
let cleanup: (() => Promise<void>) | undefined;
beforeAll(async () => {
const started = await startEmbeddedPostgresTestDatabase("agent-configure-defaults");
db = createDb(started.connectionString);
cleanup = started.cleanup;
}, 20_000);
afterAll(async () => {
await cleanup?.();
});
it("grants standard new agents direct peer configuration, but keeps restricted agents narrow", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Agent permission defaults",
issuePrefix: `PD${companyId.slice(0, 6).toUpperCase()}`,
});
const create = (name: string, permissions: Record<string, unknown> = {}, metadata?: Record<string, unknown>) =>
agentService(db).create(companyId, {
name,
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions,
metadata,
}, metadata ? { allowBuiltInAgentMetadata: true } : undefined);
const standard = await create("Standard");
const peer = await create("Peer");
const lowTrust = await create("Low trust", { trustPreset: LOW_TRUST_REVIEW_PRESET });
const bundled = await create("Bundled", {}, {
paperclipBuiltInAgent: { key: "reflection-coach", featureKeys: [] },
});
const grants = await db.select().from(principalPermissionGrants)
.where(eq(principalPermissionGrants.companyId, companyId));
for (const agent of [standard, peer]) {
expect(grants.filter((grant) => grant.principalId === agent.id)
.map((grant) => grant.permissionKey).sort()).toEqual(expectedNewAgentGrantKeys);
expect(grants.find((grant) => grant.principalId === agent.id && grant.permissionKey === "tasks:assign_scope")?.scope)
.toEqual({ subtreeRootAgentId: agent.id });
expect(grants.find((grant) => grant.principalId === agent.id && grant.permissionKey === "inbox:manage")?.scope)
.toEqual({ responsibleUserOnly: true });
}
for (const agent of [lowTrust, bundled]) {
expect(grants.filter((grant) => grant.principalId === agent.id)).toEqual([]);
}
await db.insert(companyMemberships).values({
companyId,
principalType: "agent",
principalId: standard.id,
status: "active",
membershipRole: "member",
});
for (const permissionKey of expectedNewAgentGrantKeys) {
if (permissionKey === "tasks:assign_scope" || permissionKey === "inbox:manage") continue; // These grants require a target scope or the responsible-user inbox path.
expect(await accessService(db).hasPermission(companyId, "agent", standard.id, permissionKey)).toBe(true);
}
expect(await authorizationService(db).decidePrincipalGrant({
companyId,
principalType: "agent",
principalId: standard.id,
action: "tasks:assign",
permissionKey: "tasks:assign_scope",
scope: { assigneeAgentId: standard.id },
})).toMatchObject({ allowed: true });
expect(await authorizationService(db).decidePrincipalGrant({
companyId,
principalType: "agent",
principalId: standard.id,
action: "tasks:assign",
permissionKey: "tasks:assign_scope",
scope: { assigneeAgentId: peer.id },
})).toMatchObject({ allowed: false, reason: "deny_scope" });
for (const permissionKey of [
"agents:create", "environments:manage", "tools:admin", "users:invite",
"users:manage_permissions", "pipelines:write", "joins:approve",
] as const) {
expect(await accessService(db).hasPermission(companyId, "agent", standard.id, permissionKey)).toBe(false);
}
const decision = await authorizationService(db).decide({
actor: { type: "agent", agentId: standard.id, companyId, source: "agent_key" },
action: "agent_config:update",
resource: { type: "agent", agentId: peer.id, companyId },
scope: { requiresChangeGrant: true },
});
expect(decision).toMatchObject({ allowed: true, reason: "allow_direct_change" });
// A later ordinary self-update must not recreate a grant removed by an operator.
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, standard.id));
await agentService(db).update(standard.id, { title: "Updated own title" });
expect(await db.select().from(principalPermissionGrants)
.where(eq(principalPermissionGrants.principalId, standard.id))).toEqual([]);
await agentService(db).remove(peer.id);
expect(await db.select().from(principalPermissionGrants)
.where(eq(principalPermissionGrants.principalId, peer.id))).toEqual([]);
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId));
await db.delete(companyMemberships).where(eq(companyMemberships.companyId, companyId));
await db.delete(agents).where(eq(agents.companyId, companyId));
await db.delete(activityLog).where(eq(activityLog.companyId, companyId));
await db.delete(companies).where(eq(companies.id, companyId));
});
it("keeps existing agents and their scoped grants unchanged during ordinary updates", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Existing permission defaults",
issuePrefix: `PE${companyId.slice(0, 6).toUpperCase()}`,
});
const rows = await db.insert(agents).values([
{ companyId, name: "Existing", role: "engineer", permissions: {}, adapterType: "process", adapterConfig: {}, runtimeConfig: {} },
{ companyId, name: "Scoped", role: "engineer", permissions: {}, adapterType: "process", adapterConfig: {}, runtimeConfig: {} },
{ companyId, name: "Restricted", role: "engineer", permissions: { trustPreset: LOW_TRUST_REVIEW_PRESET }, adapterType: "process", adapterConfig: {}, runtimeConfig: {} },
{ companyId, name: "Built in", role: "engineer", metadata: { paperclipBuiltInAgent: { key: "reflection-coach", featureKeys: [] } }, permissions: {}, adapterType: "process", adapterConfig: {}, runtimeConfig: {} },
{ companyId, name: "Pending", role: "engineer", status: "pending_approval", permissions: {}, adapterType: "process", adapterConfig: {}, runtimeConfig: {} },
]).returning();
await db.insert(principalPermissionGrants).values({
companyId,
principalType: "agent",
principalId: rows[1]!.id,
permissionKey: "agents:configure",
scope: { agentIds: [rows[0]!.id] },
});
for (const row of rows) {
if (row.status === "pending_approval") {
await expect(agentService(db).update(row.id, { title: "Updated existing agent" }))
.rejects.toThrow("Pending approval agent configuration cannot be changed before board approval");
} else {
await agentService(db).update(row.id, { title: "Updated existing agent" });
}
}
const grants = await db.select().from(principalPermissionGrants)
.where(eq(principalPermissionGrants.companyId, companyId));
expect(grants.map((grant) => grant.principalId).sort()).toEqual([rows[1]!.id]);
expect(grants.map((grant) => grant.permissionKey)).toEqual(["agents:configure"]);
expect(grants.find((grant) => grant.principalId === rows[1]!.id)?.scope).toEqual({ agentIds: [rows[0]!.id] });
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId));
await db.delete(agents).where(eq(agents.companyId, companyId));
await db.delete(activityLog).where(eq(activityLog.companyId, companyId));
await db.delete(companies).where(eq(companies.id, companyId));
});
it("adds the default grant when a standard pending hire is approved", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Pending permission default",
issuePrefix: `PP${companyId.slice(0, 6).toUpperCase()}`,
});
const pending = await agentService(db).create(companyId, {
name: "Pending hire",
status: "pending_approval",
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
});
expect(await db.select().from(principalPermissionGrants)
.where(eq(principalPermissionGrants.companyId, companyId))).toEqual([]);
const result = await agentService(db).activatePendingApproval(pending.id);
expect(result?.activated).toBe(true);
expect((await db.select().from(principalPermissionGrants)
.where(eq(principalPermissionGrants.companyId, companyId)))
.map((grant) => grant.permissionKey).sort()).toEqual(expectedNewAgentGrantKeys);
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId));
await db.delete(agents).where(eq(agents.companyId, companyId));
await db.delete(activityLog).where(eq(activityLog.companyId, companyId));
await db.delete(companies).where(eq(companies.id, companyId));
});
it("keeps configuration access when invitation approval replaces a new agent's grants", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Invited permission default",
issuePrefix: `PI${companyId.slice(0, 6).toUpperCase()}`,
});
const invited = await agentService(db).create(companyId, {
name: "Invited agent",
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
});
await accessService(db).ensureMembership(companyId, "agent", invited.id, "member", "active");
await accessService(db).setPrincipalGrants(
companyId, "agent", invited.id, agentJoinGrantsFromDefaults(null, invited.id), null,
);
const grants = await db.select().from(principalPermissionGrants)
.where(eq(principalPermissionGrants.principalId, invited.id));
expect(grants.map((grant) => grant.permissionKey).sort()).toEqual(expectedNewAgentGrantKeys);
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId));
await db.delete(companyMemberships).where(eq(companyMemberships.companyId, companyId));
await db.delete(agents).where(eq(agents.companyId, companyId));
await db.delete(activityLog).where(eq(activityLog.companyId, companyId));
await db.delete(companies).where(eq(companies.id, companyId));
});
});
@@ -880,6 +880,16 @@ describe("persistent agent directories", () => {
expect(await fs.readFile(path.join(blocked.localRoot, entryFile), "utf8")).toBe(initial);
await expect(fs.stat(other.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
});
// A fresh recovery worker retries after the holder releases the lock. It
// cleans the pending copy without changing the failed-save receipt.
const deferred = (await copies.get(companyId, blocked.runId))!;
await db.update(agentInstructionWorkingCopies).set({ nextAttemptAt: new Date(0) })
.where(eq(agentInstructionWorkingCopies.runId, blocked.runId));
await agentInstructionWorkingCopyService(db).recoverCaptured();
expect(await copies.get(companyId, blocked.runId)).toMatchObject({ state: deferred.state,
errorCode: deferred.errorCode, errorMessage: deferred.errorMessage,
candidateHash: deferred.candidateHash, receipt: { cleanupPending: false }, nextAttemptAt: null });
await expect(fs.stat(blocked.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
});
it.each(["environment", "path", "lease-run"])("rejects destruction outside the registered copy binding (%s)", async mismatch => {
@@ -6,8 +6,8 @@ import express from "express";
import request from "supertest";
import { and, eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, companies, companyMemberships, createDb, heartbeatRuns, issues, principalPermissionGrants, toolConnectionInstalls } from "@paperclipai/db";
import { type AiConnectionBinding } from "@paperclipai/shared";
import { activityLog, agents, companies, companyMemberships, createDb, heartbeatRuns, issues, plugins, principalPermissionGrants, toolConnectionInstalls } from "@paperclipai/db";
import { type AiConnectionBinding, type AiConnectionPoolMember, type PaperclipPluginManifestV1 } from "@paperclipai/shared";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { agentRoutes } from "../routes/agents.js";
import { errorHandler } from "../middleware/index.js";
@@ -16,6 +16,9 @@ import { heartbeatService } from "../services/heartbeat.js";
import { getServerAdapter, registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js";
import { prepareManagedAiRuntime } from "../services/ai-connection-runtime.js";
import { secretService } from "../services/secrets.js";
import { aiConnectionRouterService } from "../services/ai-connection-router.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { toolAccessService } from "../services/tool-access.js";
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
@@ -25,6 +28,7 @@ beforeAll(async () => {
home = await mkdtemp(path.join(os.tmpdir(), "paperclip-hire-ai-"));
vi.stubEnv("PAPERCLIP_HOME", home);
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "hire-ai");
vi.stubEnv("PAPERCLIP_IN_WORKTREE", "false");
database = await startEmbeddedPostgresTestDatabase("paperclip-hire-ai-db-");
db = createDb(database.connectionString);
}, 90_000);
@@ -67,7 +71,146 @@ function hired(response: request.Response) {
return response.body.agent ?? response.body;
}
async function poolFixture(f: Awaited<ReturnType<typeof fixture>>, extraMembers: AiConnectionPoolMember[] = []) {
await instanceSettingsService(db).updateExperimental({ enableAiConnectionRouters: true });
const pluginKey = `fixture.pool-${f.companyId}`;
const manifest: PaperclipPluginManifestV1 = { id: pluginKey, apiVersion: 1, version: "0.1.0", displayName: "Pool fixture", description: "Fixture", author: "Tests", categories: ["connector"], capabilities: ["ai.connections.route"], aiConnectionRouter: { name: "AI connection pool", description: "Fixture" }, entrypoints: { worker: "worker.js" } };
await db.insert(plugins).values({ pluginKey, packageName: pluginKey, version: "0.1.0", manifestJson: manifest, status: "ready" });
const member: AiConnectionPoolMember = { id: randomUUID(), binding: { ...f.account, provider: "openai", method: "api_key", mode: "delegated" }, profile: { provider: "codex", model: "gpt-5.6-sol" } };
const pool = await aiConnectionRouterService(db).save(pluginKey, { companyId: f.companyId, config: { name: "Fixture pool", enabled: true, mode: "round_robin", thresholdPercent: 90, members: [member, ...extraMembers] } }, f.userId);
return { pool, binding: { mode: "router", connectionId: pool.id } as const, member };
}
describe("agent-created hires use managed AI connections", () => {
it.each(["agent-hires", "agents"])("%s inherits a manager's pool without copying legacy credentials", async (endpoint) => {
const f = await fixture("openai");
const p = await poolFixture(f);
const secret = await secretService(db).create(f.companyId, {
name: "Stale manager credential", provider: "local_encrypted", value: "fixture-legacy-key",
});
await db.update(agents).set({
runtimeConfig: { aiConnection: p.binding },
adapterConfig: { env: { OPENAI_API_KEY: { type: "secret_ref", secretId: secret.id } } },
}).where(eq(agents.id, f.agentId));
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/${endpoint}`).send({
name: "Inherited pool teammate", role: "engineer", adapterType: f.adapterType,
}));
expect(agent.runtimeConfig.aiConnection).toEqual(p.binding);
expect(agent.adapterConfig.env?.OPENAI_API_KEY).toBeUndefined();
const installs = await db.select().from(toolConnectionInstalls).where(and(eq(toolConnectionInstalls.companyId, f.companyId), eq(toolConnectionInstalls.targetType, "agent"), eq(toolConnectionInstalls.targetId, agent.id)));
expect(installs.map(i => i.connectionId).sort()).toEqual([p.pool.id, f.account.connectionId].sort());
});
it("keeps an inherited pool when copying the caller's native runtime settings", async () => {
const f = await fixture("openai");
const p = await poolFixture(f);
await db.update(agents).set({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol", lifecycleMode: "per_turn" }, runtimeConfig: { aiConnection: p.binding } }).where(eq(agents.id, f.agentId));
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({
name: "Native pool teammate", role: "engineer", adapterType: "paperclip_runner", inheritRuntimeFrom: "caller",
}));
expect(agent.runtimeConfig.aiConnection).toEqual(p.binding);
expect(agent.adapterConfig).toMatchObject({ provider: "codex", model: "gpt-5.6-sol", lifecycleMode: "per_turn" });
});
it("rejects an inherited pool with no compatible harness instead of dropping its binding", async () => {
const f = await fixture("openai");
const p = await poolFixture(f);
await db.update(agents).set({ runtimeConfig: { aiConnection: p.binding } }).where(eq(agents.id, f.agentId));
const response = await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Incompatible teammate", role: "engineer", adapterType: "claude_local" });
expect(response.status, JSON.stringify(response.body)).toBe(422);
// Fixed bindings contain identity only; compatibility is decided from
// authoritative connection metadata when selecting each pool member.
expect(response.body.details?.code).toBe("ai_connection_pool_no_eligible_member");
expect(await db.select().from(agents).where(eq(agents.companyId, f.companyId))).toHaveLength(1);
});
it.each(["OPENAI_API_KEY", "ANTHROPIC_API_KEY"])("pool inheritance honors only the child's own provider auth override (%s)", async (key) => {
const f = await fixture("openai");
const p = await poolFixture(f);
await db.update(agents).set({ runtimeConfig: { aiConnection: p.binding } }).where(eq(agents.id, f.agentId));
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Explicit auth teammate", role: "engineer", adapterType: f.adapterType, adapterConfig: { env: { [key]: "fixture-explicit-key" } } }));
expect(agent.runtimeConfig.aiConnection).toEqual(key === "OPENAI_API_KEY" ? undefined : p.binding);
expect(agent.adapterConfig.env[key]).toEqual({ type: "plain", value: "fixture-explicit-key" });
});
it.each([
["opencode_local", {}, "PAPERCLIP_OPENCODE_PROVIDERS"],
["opencode_local", { model: "anthropic/claude-sonnet-5" }, "OPENCODE_AUTH_JSON"],
["paperclip_runner", { provider: "opencode", model: "anthropic/claude-sonnet-5" }, "OPENCODE_CONFIG_CONTENT"],
] as const)("%s hires with %j keep explicit %s auth outside the managed OpenRouter model catalog", async (adapterType, config, key) => {
const f = await fixture("openai");
const p = await poolFixture(f);
await db.update(agents).set({ runtimeConfig: { aiConnection: p.binding } }).where(eq(agents.id, f.agentId));
const provider = { npm: "@ai-sdk/anthropic", options: { apiKey: "fixture-explicit-key" }, models: { "claude-sonnet-5": { name: "Claude fixture" } } };
const value = JSON.stringify(key === "PAPERCLIP_OPENCODE_PROVIDERS" ? { anthropic: provider }
: key === "OPENCODE_CONFIG_CONTENT" ? { provider: { anthropic: provider } }
: { anthropic: { type: "api", key: "fixture-explicit-key" } });
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({
name: "Independent OpenCode teammate", role: "engineer", adapterType, adapterConfig: { ...config, env: { [key]: value } },
}));
expect(agent.runtimeConfig.aiConnection).toBeUndefined();
expect(agent.adapterConfig.env[key]).toEqual({ type: "plain", value });
expect(await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.targetId, agent.id))).toEqual([]);
});
it.each(["agent-hires", "agents"])("%s installs authorized pool members atomically with the new agent", async (endpoint) => {
const f = await fixture("openai");
const p = await poolFixture(f);
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/${endpoint}`).send({ name: "Pool teammate", role: "engineer", adapterType: f.adapterType, reportsTo: f.agentId, runtimeConfig: { aiConnection: p.binding } }));
expect(agent.runtimeConfig.aiConnection).toEqual(p.binding);
const installs = await db.select().from(toolConnectionInstalls).where(and(eq(toolConnectionInstalls.companyId, f.companyId), eq(toolConnectionInstalls.targetType, "agent"), eq(toolConnectionInstalls.targetId, agent.id)));
expect(installs.map(i => i.connectionId).sort()).toEqual([p.pool.id, f.account.connectionId].sort());
const [creation] = await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.companyId), eq(activityLog.entityId, agent.id), eq(activityLog.action, endpoint === "agent-hires" ? "agent.hire_created" : "agent.created")));
expect(creation.details).toMatchObject({ aiConnectionPoolId: p.pool.id, aiConnectionMemberInstallIds: [f.account.connectionId] });
const runtime = await prepareManagedAiRuntime(db, { companyId: f.companyId, agentId: agent.id, responsibleUserId: f.userId, adapterType: f.adapterType, binding: p.member.binding, config: {} });
try { expect(runtime.attribution.connectionId).toBe(f.account.connectionId); } finally { await runtime.cleanup(); }
});
it("rejects a saved pool binding when no member is installed for that agent", async () => {
const f = await fixture("openai");
const p = await poolFixture(f);
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "agents:configure" });
await db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, f.account.connectionId));
const response = await request(f.app).patch(`/api/agents/${f.agentId}`).send({ runtimeConfig: { aiConnection: p.binding } });
expect(response.status, JSON.stringify(response.body)).toBe(422);
expect(response.body.details).toMatchObject({ code: "ai_connection_pool_no_eligible_member" });
const [saved] = await db.select().from(agents).where(eq(agents.id, f.agentId));
expect(saved.runtimeConfig.aiConnection).toEqual(f.binding);
});
it("does not install a restricted shared connection through pool membership", async () => {
const f = await fixture("openai");
const otherOwner = `other-${f.companyId}`;
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: otherOwner, membershipRole: "member", status: "active" });
const account = await aiConnectionService(db).save(f.companyId, otherOwner, { provider: "openai", method: "api_key", ownership: "shared", name: "Shared credential, restricted installation", apiKey: "fixture", agentIds: [f.agentId], allAgents: false }, "fixture");
await toolAccessService(db).replaceConnectionGrantMembers(account.connectionId, account.grantId, [f.userId], { userId: otherOwner });
const member: AiConnectionPoolMember = { id: randomUUID(), binding: { ...account, provider: "openai", method: "api_key", mode: "shared" }, profile: { provider: "codex", model: "gpt-5.6-sol" } };
const p = await poolFixture(f, [member]);
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agents`).send({ name: "Restricted pool teammate", role: "engineer", adapterType: f.adapterType, runtimeConfig: { aiConnection: p.binding } }));
const input = { companyId: f.companyId, userId: f.userId, adapterType: f.adapterType, binding: member.binding };
expect((await aiConnectionService(db).select({ ...input, agentId: f.agentId })).connection.id).toBe(account.connectionId);
await expect(aiConnectionService(db).select({ ...input, agentId: agent.id })).rejects.toThrow("not permitted for this agent");
const installs = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, account.connectionId));
expect(installs.map(i => i.targetId)).toEqual([f.agentId]);
});
it.each([false, true])("rechecks an unchanged pool when the agent harness changes (compatible member: %s)", async (compatible) => {
const f = await fixture("openai");
const extra: AiConnectionPoolMember[] = [];
if (compatible) {
const account = await aiConnectionService(db).save(f.companyId, f.userId, { provider: "anthropic", method: "api_key", ownership: "personal", name: "Claude fixture", apiKey: "fixture", allAgents: true, agentIds: [] }, "fixture");
extra.push({ id: randomUUID(), binding: { ...account, provider: "anthropic", method: "api_key", mode: "delegated" }, profile: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" } });
}
const p = await poolFixture(f, extra);
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "agents:configure" });
await db.update(agents).set({ runtimeConfig: { aiConnection: p.binding } }).where(eq(agents.id, f.agentId));
const response = await request(f.app).patch(`/api/agents/${f.agentId}`).send({ adapterType: "claude_local", adapterConfig: { model: "claude-sonnet-5" } });
expect(response.status, JSON.stringify(response.body)).toBe(compatible ? 200 : 422);
const [saved] = await db.select().from(agents).where(eq(agents.id, f.agentId));
expect(saved.adapterType).toBe(compatible ? "claude_local" : "codex_local");
expect(saved.runtimeConfig.aiConnection).toEqual(p.binding);
});
for (const operation of ["test", "save"] as const) {
it.each([401, 403, 429, 503, null])(`${operation} changes API-key health only for a provider rejection (status: %s)`, async (status) => {
const f = await fixture("anthropic", "api_key");
@@ -170,15 +313,15 @@ describe("agent-created hires use managed AI connections", () => {
["ANTHROPIC_API_KEY", ""],
["CLAUDE_CONFIG_DIR", "/tmp/child-claude-home"],
["ANTHROPIC_BASE_URL", "https://example.invalid"],
])(`${endpoint}: preserves an explicit child auth setting %s=%s`, async (key, value) => {
])(`${endpoint}: rejects an agent-supplied local environment setting %s=%s`, async (key, value) => {
const f = await fixture("anthropic");
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/${endpoint}`).send({
const response = await request(f.app).post(`/api/companies/${f.companyId}/${endpoint}`).send({
name: "Explicit auth", role: "engineer", adapterType: f.adapterType,
adapterConfig: { env: { [key]: value } },
}));
expect(agent.runtimeConfig.aiConnection).toBeUndefined();
const [saved] = await db.select().from(agents).where(eq(agents.id, agent.id));
expect((saved.adapterConfig.env as Record<string, unknown>)[key]).toEqual({ type: "plain", value });
});
expect(response.status).toBe(403);
expect(response.body.error).toContain("host-executed local adapter settings");
expect(await db.select().from(agents).where(eq(agents.companyId, f.companyId))).toHaveLength(1);
});
}
@@ -226,13 +369,17 @@ describe("agent-created hires use managed AI connections", () => {
["openai", "paperclip_runner", { provider: "acpx", acpxAgent: "claude" }, "OPENAI_API_KEY"],
] as const)(`${endpoint}: ignores the %s auth key for a different provider in %s`, async (provider, adapterType, config, key) => {
const f = await fixture(provider);
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/${endpoint}`).send({
const response = await request(f.app).post(`/api/companies/${f.companyId}/${endpoint}`).send({
name: "Cross-provider config", role: "engineer", adapterType,
adapterConfig: { ...config, env: { [key]: "leftover-parent-setting" } },
}));
expect(agent.runtimeConfig.aiConnection).toMatchObject({
provider: provider === "anthropic" ? "openai" : "anthropic", mode: "responsible_user",
});
if (adapterType.endsWith("_local")) {
expect(response.status).toBe(403);
expect(response.body.error).toContain("host-executed local adapter settings");
return;
}
const agent = hired(response);
expect(agent.runtimeConfig.aiConnection).toMatchObject({ provider: provider === "anthropic" ? "openai" : "anthropic", mode: "responsible_user" });
});
}
@@ -325,7 +472,9 @@ describe("agent-created hires use managed AI connections", () => {
describe("hired agents sharing a subscription", () => {
it.each(["openai", "anthropic"] as const)("runs the %s child alongside a live parent and inherits its connection", async (provider) => {
const f = await fixture(provider, "subscription");
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Concurrent teammate", role: "engineer", adapterType: f.adapterType, reportsTo: f.agentId, adapterConfig: { cwd: home, engine: "cli" }, runtimeConfig: { heartbeat: { enabled: false } } }));
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Concurrent teammate", role: "engineer", adapterType: f.adapterType, reportsTo: f.agentId, adapterConfig: { engine: "cli" }, runtimeConfig: { heartbeat: { enabled: false } } }));
// Host working directories are configured by an operator, not an agent key.
await db.update(agents).set({ adapterConfig: { ...agent.adapterConfig, cwd: home } }).where(eq(agents.id, agent.id));
const [issue] = await db.insert(issues).values({ companyId: f.companyId, title: "Subscription child task", status: "todo", assigneeAgentId: agent.id, responsibleUserId: f.userId, createdByUserId: f.userId }).returning();
const parentRuntime = await prepareManagedAiRuntime(db, { companyId: f.companyId, agentId: f.agentId, responsibleUserId: f.userId, adapterType: f.adapterType, binding: f.binding, config: { cwd: home } });
const execute = vi.fn(async () => {
@@ -0,0 +1,36 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
import { expect, it, vi } from "vitest";
import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js";
it("atomically publishes one complete master key across competing processes", async () => {
const directory = await mkdtemp(join(tmpdir(), "identity-master-race-"));
const keyFile = join(directory, "master.key");
const providerPath = fileURLToPath(new URL("../secrets/local-encrypted-provider.ts", import.meta.url));
const tsx = createRequire(import.meta.url).resolve("tsx/esm");
try {
const script = `import {localEncryptedProvider as p} from ${JSON.stringify(providerPath)}; console.log(JSON.stringify(await p.createSecret({value:'race-challenge'})));`;
const children = await Promise.all(Array.from({ length: 6 }, () => promisify(execFile)(process.execPath,
["--import", tsx, "--input-type=module", "-e", script], {
env: { ...process.env, PAPERCLIP_SECRETS_MASTER_KEY: "", PAPERCLIP_SECRETS_MASTER_KEY_FILE: keyFile },
})));
expect(Buffer.from(await readFile(keyFile, "utf8"), "base64")).toHaveLength(32);
expect((await stat(keyFile)).mode & 0o777).toBe(0o600);
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "");
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", keyFile);
for (const child of children) {
expect(await localEncryptedProvider.resolveVersion(JSON.parse(child.stdout))).toBe("race-challenge");
}
await rm(keyFile);
await expect(localEncryptedProvider.resolveVersion(JSON.parse(children[0].stdout))).rejects.toThrow("master key file is missing");
await expect(stat(keyFile)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
vi.unstubAllEnvs();
await rm(directory, { recursive: true, force: true });
}
}, 30_000);
+256
View File
@@ -0,0 +1,256 @@
import { randomBytes, randomUUID, verify, generateKeyPairSync } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import express from "express";
import request from "supertest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { agentIdentityKeys, agents, companies, createDb, closeRegisteredClients, ensurePostgresDatabase, runDatabaseBackup, runDatabaseRestore, type Db } from "@paperclipai/db";
import { resolveWorktreeSeedPlan } from "../../../cli/src/commands/worktree-lib.js";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { agentIdentityService, supportsManagedAgentIdentity } from "../services/agent-identity.js";
import { agentService } from "../services/agents.js";
import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js";
import { execute } from "../adapters/process/execute.js";
import { createAgentIdentityRedactor } from "../services/agent-identity-redaction.js";
import { heartbeatService } from "../services/heartbeat.js";
import { agentRoutes } from "../routes/agents.js";
import { actorMiddleware } from "../middleware/auth.js";
import { errorHandler } from "../middleware/error-handler.js";
import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.js";
describe("agent cryptographic identity", () => {
let db: Db;
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let directory: string;
let companyId: string;
beforeAll(async () => {
directory = await mkdtemp(join(tmpdir(), "paperclip-identity-"));
vi.stubEnv("PAPERCLIP_HOME", directory);
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "");
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", join(directory, "master.key"));
database = await startEmbeddedPostgresTestDatabase("paperclip-identity-");
db = createDb(database.connectionString);
companyId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Identity test", issuePrefix: "KEY", defaultResponsibleUserId: "identity-test-owner" });
});
afterAll(async () => {
await database?.cleanup();
vi.unstubAllEnvs();
await rm(directory, { recursive: true, force: true });
});
async function oldAgent() {
const [agent] = await db.insert(agents).values({ companyId, name: randomUUID(), role: "engineer" }).returning();
return agent;
}
it("migration and public reads leave existing agents unprovisioned", async () => {
const agent = await oldAgent();
expect(await agentIdentityService(db).getPublicIdentity(companyId, agent.id)).toBeNull();
expect(await db.select().from(agentIdentityKeys).where(eq(agentIdentityKeys.agentId, agent.id))).toHaveLength(0);
for (const type of ["http", "openclaw_gateway", "hermes_gateway"]) expect(supportsManagedAgentIdentity(type)).toBe(false);
for (const provider of ["claude_managed", "aws_agentcore"]) expect(supportsManagedAgentIdentity("paperclip_runner", { provider })).toBe(false);
expect(supportsManagedAgentIdentity("paperclip_runner", { provider: "codex" }, "claude_managed_agents_api")).toBe(false);
expect(supportsManagedAgentIdentity("paperclip_runner", { provider: "claude_managed" }, "codex_app_server")).toBe(true);
});
it("concurrent first runs converge, restart/model/name changes preserve keys, and ownership is enforced", async () => {
const agent = await oldAgent();
const pairs = await Promise.all(Array.from({ length: 8 }, () =>
agentIdentityService(db).ensureAgentIdentity(companyId, agent.id)));
expect(new Set(pairs.map(pair => pair.privateKeyPem)).size).toBe(1);
await db.update(agents).set({ name: "Renamed", adapterConfig: { model: "another-model" }, status: "paused" }).where(eq(agents.id, agent.id));
const freshDb = createDb(database.connectionString);
expect(await agentIdentityService(freshDb).ensureAgentIdentity(companyId, agent.id)).toEqual(pairs[0]);
expect(await agentIdentityService(db).getPublicIdentity(randomUUID(), agent.id)).toBeNull();
await expect(agentIdentityService(db).ensureAgentIdentity(randomUUID(), agent.id)).rejects.toThrow("Agent not found");
const second = await oldAgent();
expect((await agentIdentityService(db).ensureAgentIdentity(companyId, second.id)).keyId).not.toBe(pairs[0].keyId);
await db.delete(agents).where(eq(agents.id, second.id));
expect(await agentIdentityService(db).getPublicIdentity(companyId, second.id)).toBeNull();
});
it("creates identities in the agent creation transaction and rolls both writes back on failure", async () => {
const created = await agentService(db).create(companyId, { name: "New identity", role: "engineer", status: "pending_approval" });
expect(await agentIdentityService(db).getPublicIdentity(companyId, created.id)).toMatchObject({ algorithm: "Ed25519" });
const id = randomUUID();
await expect(db.transaction(async tx => {
await tx.insert(agents).values({ id, companyId, name: "Rollback", role: "engineer" });
await agentIdentityService(tx as unknown as Db).ensureAgentIdentity(companyId, id);
throw new Error("rollback");
})).rejects.toThrow("rollback");
expect(await db.select().from(agents).where(eq(agents.id, id))).toHaveLength(0);
expect(await agentIdentityService(db).getPublicIdentity(companyId, id)).toBeNull();
const fail = vi.spyOn(localEncryptedProvider, "createSecret").mockRejectedValueOnce(new Error("encryption unavailable"));
await expect(agentService(db).create(companyId, { name: "Must roll back", role: "engineer", status: "pending_approval" })).rejects.toThrow("encryption unavailable");
fail.mockRestore();
expect(await db.select().from(agents).where(eq(agents.name, "Must roll back"))).toHaveLength(0);
});
it("fails closed for wrong master keys, corrupted material and mismatched public keys", async () => {
const agent = await oldAgent();
const identity = await agentIdentityService(db).ensureAgentIdentity(companyId, agent.id);
const [stored] = await db.select().from(agentIdentityKeys).where(eq(agentIdentityKeys.agentId, agent.id));
expect(JSON.stringify(stored)).not.toContain(identity.privateKeyPem.split("\n")[1]);
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", randomBytes(32).toString("base64"));
await expect(agentIdentityService(db).ensureAgentIdentity(companyId, agent.id)).rejects.toThrow("decryption failed");
expect(await agentIdentityService(db).getPublicIdentity(companyId, agent.id)).toMatchObject({ keyId: identity.keyId });
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "");
await db.update(agentIdentityKeys).set({ privateKeyMaterial: { ...stored.privateKeyMaterial, ciphertext: "AAAA" } }).where(eq(agentIdentityKeys.agentId, agent.id));
await expect(agentIdentityService(db).ensureAgentIdentity(companyId, agent.id)).rejects.toThrow();
const other = generateKeyPairSync("ed25519").publicKey.export({ type: "spki", format: "pem" }).toString();
await db.update(agentIdentityKeys).set({ privateKeyMaterial: stored.privateKeyMaterial, publicKeyPem: other }).where(eq(agentIdentityKeys.agentId, agent.id));
await expect(agentIdentityService(db).ensureAgentIdentity(companyId, agent.id)).rejects.toThrow("do not match");
expect((await db.select().from(agentIdentityKeys).where(eq(agentIdentityKeys.agentId, agent.id)))[0].keyId).toBe(identity.keyId);
});
it("a real managed process signs a random challenge before and after reconnect, ignoring hostile env overrides", async () => {
const agent = await oldAgent();
const challenge = randomBytes(32).toString("base64");
let publicPem = "";
for (let restart = 0; restart < 2; restart++) {
const restarted = agentIdentityService(createDb(database.connectionString));
const identity = await restarted.ensureAgentIdentity(companyId, agent.id);
const publicIdentity = await restarted.getPublicIdentity(companyId, agent.id);
if (restart) expect(publicIdentity!.publicKeyPem).toBe(publicPem);
publicPem = publicIdentity!.publicKeyPem;
vi.stubEnv("PAPERCLIP_AGENT_PRIVATE_KEY", "inherited override");
const logs: string[] = [];
const metas: unknown[] = [];
await execute({
runId: randomUUID(), agent, agentIdentity: identity,
config: { command: process.execPath, cwd: directory, args: ["-e", `const {sign}=require('node:crypto'); process.stdout.write(sign(null,Buffer.from(process.env.CHALLENGE,'base64'),process.env.PAPERCLIP_AGENT_PRIVATE_KEY).toString('base64'));`], env: { CHALLENGE: challenge, PAPERCLIP_AGENT_PRIVATE_KEY: "configured override" } },
context: {}, runtime: { sessionId: null, sessionParams: null, taskKey: null },
onLog: async (_stream, chunk) => { logs.push(chunk); }, onMeta: async meta => { metas.push(meta); },
});
expect(verify(null, Buffer.from(challenge, "base64"), publicPem, Buffer.from(logs.join(""), "base64"))).toBe(true);
expect(JSON.stringify(metas)).not.toContain(identity.privateKeyPem.split("\n")[1]);
}
});
it("lazily provisions through managed run preparation, exposes only the public API key, and redacts emitted keys", async () => {
const agent = await oldAgent();
const app = express();
app.use(actorMiddleware(db, { deploymentMode: "local_trusted" }));
app.use("/api", agentRoutes(db));
app.use(errorHandler);
const firstRead = await request(app).get(`/api/agents/${agent.id}/identity`);
expect(firstRead.status).toBe(200);
expect(firstRead.body).toBeNull();
const challenge = randomBytes(32).toString("base64");
await db.update(agents).set({ adapterType: "process", adapterConfig: {
command: process.execPath, cwd: directory,
args: ["-e", `const {sign}=require('node:crypto'); const k=process.env.PAPERCLIP_AGENT_PRIVATE_KEY; console.log('SIGNATURE:'+sign(null,Buffer.from(process.env.CHALLENGE,'base64'),k).toString('base64')); process.stdout.write(k.slice(0,40)); setTimeout(()=>process.stdout.write(k.slice(40)),30);`],
env: { CHALLENGE: challenge, PAPERCLIP_AGENT_PRIVATE_KEY: "ignored override" },
} }).where(eq(agents.id, agent.id));
let previousKeyId = "";
for (let restart = 0; restart < 2; restart++) {
const heartbeat = heartbeatService(createDb(database.connectionString));
try {
const run = await heartbeat.invoke(agent.id, "on_demand", {}, "manual");
expect(run).toBeTruthy();
await drainHeartbeatRunsToQuiescence(db, heartbeat);
const finished = await heartbeat.getRun(run!.id);
expect(finished?.status, JSON.stringify(finished)).toBe("succeeded");
const publicResponse = await request(app).get(`/api/agents/${agent.id}/identity`);
expect(publicResponse.status).toBe(200);
expect(Object.keys(publicResponse.body).sort()).toEqual(["algorithm", "createdAt", "keyId", "publicKeyPem"]);
if (restart) expect(publicResponse.body.keyId).toBe(previousKeyId);
previousKeyId = publicResponse.body.keyId;
const signature = /SIGNATURE:([A-Za-z0-9+/=]+)/.exec(finished!.stdoutExcerpt ?? "")?.[1];
expect(signature).toBeTruthy();
expect(verify(null, Buffer.from(challenge, "base64"), publicResponse.body.publicKeyPem, Buffer.from(signature!, "base64"))).toBe(true);
const privateBody = (await agentIdentityService(db).ensureAgentIdentity(companyId, agent.id)).privateKeyPem.split("\n")[1];
expect(JSON.stringify(finished)).not.toContain(privateBody);
expect(JSON.stringify(await heartbeat.readLog(run!.id))).not.toContain(privateBody);
} finally {
await drainHeartbeatRunsToQuiescence(db, heartbeat);
}
}
}, 60_000);
it("persists ordinary stdout and stderr endings after settling the identity buffer", async () => {
const agent = await oldAgent();
await db.update(agents).set({ adapterType: "process", adapterConfig: {
command: process.execPath, cwd: directory,
args: ["-e", "process.stdout.write('safe-'); process.stderr.write('safeM');"],
} }).where(eq(agents.id, agent.id));
const heartbeat = heartbeatService(db);
try {
const run = await heartbeat.invoke(agent.id, "on_demand", {}, "manual");
await drainHeartbeatRunsToQuiescence(db, heartbeat);
const finished = await heartbeat.getRun(run!.id);
expect(finished?.status).toBe("succeeded");
expect(finished?.stdoutExcerpt).toMatch(/safe-$/);
expect(finished?.stderrExcerpt).toMatch(/safeM$/);
const lines = (await heartbeat.readLog(run!.id)).content.trim().split("\n").map(line => JSON.parse(line));
expect(lines.filter(line => line.stream === "stdout").map(line => line.chunk).join("")).toContain("safe-");
expect(lines.filter(line => line.stream === "stderr").map(line => line.chunk).join("")).toContain("safeM");
} finally {
await drainHeartbeatRunsToQuiescence(db, heartbeat);
}
}, 60_000);
it("disaster recovery retains identity; both development seed modes omit it and mint fresh keys", async () => {
const agent = await oldAgent();
const original = await agentIdentityService(db).ensureAgentIdentity(companyId, agent.id);
for (const mode of ["backup", "minimal", "full"] as const) {
const seed = mode === "backup" ? undefined : resolveWorktreeSeedPlan(mode);
const backup = await runDatabaseBackup({
connectionString: database.connectionString, backupDir: join(directory, mode),
backupEngine: "javascript", retention: { dailyDays: 1, weeklyWeeks: 0, monthlyMonths: 0 },
excludeTables: seed?.excludedTables, nullifyColumns: seed?.nullifyColumns,
});
const target = new URL(database.connectionString);
const admin = new URL(database.connectionString);
admin.pathname = "/postgres";
await ensurePostgresDatabase(admin.toString(), `identity_${mode}`);
target.pathname = `/identity_${mode}`;
await runDatabaseRestore({ connectionString: target.toString(), backupFile: backup.backupFile });
const restored = agentIdentityService(createDb(target.toString()));
if (mode === "backup") expect(await restored.ensureAgentIdentity(companyId, agent.id)).toEqual(original);
else {
expect(await restored.getPublicIdentity(companyId, agent.id)).toBeNull();
expect((await restored.ensureAgentIdentity(companyId, agent.id)).keyId).not.toBe(original.keyId);
}
await closeRegisteredClients(target.toString());
}
}, 60_000);
});
describe("identity output redaction", () => {
const pem = generateKeyPairSync("ed25519").privateKey.export({ format: "pem", type: "pkcs8" }).toString();
it("redacts PEM, JSON-escaped PEM and raw private material", () => {
const redactor = createAgentIdentityRedactor(pem);
for (const text of [pem, JSON.stringify(pem), pem.split("\n")[1]]) {
expect(redactor.redact({ output: text }).output).not.toContain(pem.split("\n")[1]);
}
});
it("settles ordinary endings and interrupted keys on item and turn completion", () => {
const redactor = createAgentIdentityRedactor(pem);
expect(redactor.delta("run:turn:item", { kind: "reasoning", text: "Thinking-" }, "item").text).toBe("Thinking");
const completed = redactor.settleDeltas("run:turn:item", {}, false);
expect(completed).toMatchObject({ outputTails: [{ itemId: "item", payload: { kind: "reasoning", text: "-" } }] });
expect(redactor.settleDeltas("run:turn:", {}, true)).toEqual({});
redactor.delta("run:turn:other", { text: pem.slice(0, 42) }, "other");
expect(redactor.settleDeltas("run:turn:", {}, true)).toMatchObject({ outputTails: [{ payload: { text: "***REDACTED***" } }] });
expect(redactor.delta("run:turn:other", { text: "hello" }).text).toBe("hello");
});
it("redacts every split boundary and interleaved streams, including interrupted prefixes", () => {
for (let split = 1; split < pem.length; split++) {
const redactor = createAgentIdentityRedactor(pem);
let output = redactor.chunk("stdout", pem.slice(0, split));
expect(redactor.chunk("stderr", "ordinary output\n")).toBe("ordinary output\n");
output += redactor.chunk("stdout", pem.slice(split));
output += redactor.finish("stdout");
expect(output).not.toContain(pem.split("\n")[1]);
}
const redactor = createAgentIdentityRedactor(pem);
expect(redactor.chunk("stdout", pem.slice(0, 42))).toBe("");
expect(redactor.finish("stdout")).toBe("***REDACTED***");
});
});
@@ -23,6 +23,7 @@ const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
getByIdentifier: vi.fn(),
}));
const mockNativeReviewAssignment = vi.hoisted(() => vi.fn());
const mockExecutionProjection = vi.hoisted(() => ({
executionProjectionForRun: vi.fn(async () => null),
@@ -79,6 +80,10 @@ const mockChatRunRetries = vi.hoisted(() => ({
}));
function registerModuleMocks() {
vi.doMock("../services/native-runtime/native-review-participant.js", async () => ({
...(await vi.importActual<typeof import("../services/native-runtime/native-review-participant.js")>("../services/native-runtime/native-review-participant.js")),
getNativeReviewAssignment: mockNativeReviewAssignment,
}));
vi.doMock("../services/execution-projection.js", () => mockExecutionProjection);
vi.doMock("../routes/authz.js", async () =>
vi.importActual("../routes/authz.js"),
@@ -287,6 +292,7 @@ describe("agent live run routes", () => {
vi.clearAllMocks();
mockChatRunRetries.prepareFailedChatRunRetry.mockReset();
mockChatRunRetries.processFailedChatRunRetry.mockReset();
mockNativeReviewAssignment.mockReset().mockResolvedValue(null);
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.decide.mockImplementation(async (input: { action?: string }) => ({
allowed: true,
@@ -966,6 +972,68 @@ describe("agent live run routes", () => {
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, expect.objectContaining({ manualUserWake: true }));
});
describe("exact native model-rejected review retry", () => {
const reviewContext = {
nativeReviewInteractionId: "55555555-5555-4555-8555-555555555555",
nativeReviewDecisionId: "66666666-6666-4666-8666-666666666666",
};
const selectedRun = {
id: failedChatRunId, companyId: "company-1", agentId: routeAgentId,
status: "failed", runtimeMode: "native", errorCode: "native_provider_model_rejected",
nativeIssueId: failedChatIssueId,
contextSnapshot: { issueId: failedChatIssueId, ...reviewContext },
};
const selectedIssue = { id: failedChatIssueId, companyId: "company-1", status: "in_review", assigneeAgentId: "worker", assigneeUserId: null };
const retryBody = { failedRunId: failedChatRunId, reason: "retry_failed_run" };
beforeEach(() => {
mockAgentService.getById.mockResolvedValue({ id: routeAgentId, companyId: "company-1" });
mockHeartbeatService.getRun.mockResolvedValue(selectedRun);
mockIssueService.getById.mockResolvedValue(selectedIssue);
mockNativeReviewAssignment.mockResolvedValue({ interaction: { status: "pending" } });
});
it("permits the Board to retry the exact pending review using only saved context", async () => {
const fixture = createFailedChatRetryDb(false);
const res = await requestApp(await createApp(fixture.db), url => request(url).post(`/api/agents/${routeAgentId}/wakeup`).send({
...retryBody, payload: { issueId: "forged", nativeReviewInteractionId: "forged", nativeReviewDecisionId: "forged" },
}));
expect(res.status, JSON.stringify(res.body)).toBe(202);
expect(mockNativeReviewAssignment).toHaveBeenCalledExactlyOnceWith(fixture.db, {
companyId: "company-1", issueId: failedChatIssueId, agentId: routeAgentId, contextSnapshot: reviewContext,
});
expect(mockHeartbeatService.wakeup).toHaveBeenCalledExactlyOnceWith(routeAgentId, expect.objectContaining({
failedRunId: failedChatRunId, reason: "retry_failed_run", payload: { issueId: failedChatIssueId },
requestedByActorType: "user", contextSnapshot: expect.objectContaining(reviewContext),
}));
expect(JSON.stringify(mockHeartbeatService.wakeup.mock.calls)).not.toContain("forged");
});
it.each(["agent-caller", "denied-task", "foreign-company", "foreign-agent", "foreign-issue", "legacy", "unknown-error", "missing-context", "stale", "resolved", "changed-reviewer"])(
"rejects an ineligible exact review retry (%s)", async scenario => {
let actor: Record<string, unknown> | undefined;
if (scenario === "agent-caller") actor = { type: "agent", agentId: routeAgentId, companyId: "company-1" };
if (scenario === "denied-task") mockAccessService.decide.mockImplementation(async ({ action }) => ({ allowed: action !== "issue:comment", explanation: "Denied" }));
if (scenario === "foreign-company") mockHeartbeatService.getRun.mockResolvedValue({ ...selectedRun, companyId: "company-2" });
if (scenario === "foreign-agent") mockHeartbeatService.getRun.mockResolvedValue({ ...selectedRun, agentId: "other-reviewer" });
if (scenario === "foreign-issue") mockHeartbeatService.getRun.mockResolvedValue({ ...selectedRun, nativeIssueId: "other-task" });
if (scenario === "legacy") mockHeartbeatService.getRun.mockResolvedValue({ ...selectedRun, runtimeMode: "legacy" });
if (scenario === "unknown-error") mockHeartbeatService.getRun.mockResolvedValue({ ...selectedRun, errorCode: "adapter_failed" });
if (scenario === "missing-context") mockHeartbeatService.getRun.mockResolvedValue({ ...selectedRun, contextSnapshot: { issueId: failedChatIssueId } });
if (scenario === "stale" || scenario === "changed-reviewer") mockNativeReviewAssignment.mockResolvedValue(null);
if (scenario === "resolved") mockNativeReviewAssignment.mockResolvedValue({ interaction: { status: "accepted" } });
const fixture = createFailedChatRetryDb(false);
const res = await requestApp(await createApp(fixture.db, actor), url => request(url).post(`/api/agents/${routeAgentId}/wakeup`).send({
...retryBody, payload: { ...reviewContext },
}));
expect(res.status).toBe(["agent-caller", "denied-task"].includes(scenario) ? 403 : ["foreign-company", "foreign-agent"].includes(scenario) ? 404 : 409);
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
if (["agent-caller", "denied-task", "foreign-company", "foreign-agent", "foreign-issue", "legacy", "unknown-error", "missing-context"].includes(scenario)) {
expect(mockNativeReviewAssignment).not.toHaveBeenCalled();
}
},
);
});
describe("exact failed chat run retry", () => {
const retryBody = {
failedRunId: failedChatRunId,
@@ -1163,7 +1231,7 @@ describe("agent live run routes", () => {
expect(res.status, JSON.stringify(res.body)).toBe(202);
expect(res.body).toEqual(receipt);
expect(mockHeartbeatService.getRun).toHaveBeenCalledWith(
failedChatRunId,
failedChatRunId, { includeExecutionEvidence: true },
);
expect(
mockChatRunRetries.prepareFailedChatRunRetry,
@@ -267,7 +267,7 @@ async function requestApp(
}
}
describe.sequential("agent permission routes", () => {
describe("agent permission routes", () => {
const routeModules = hoistModuleGraph(registerModuleMocks, async () => {
const [{ errorHandler }, { agentRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
@@ -918,6 +918,217 @@ describe.sequential("agent permission routes", () => {
expect(mockLogActivity).not.toHaveBeenCalled();
});
it.each([
["command", { command: "sh" }],
["arguments", { args: ["-c", "id"] }],
["environment", { env: { PATH: "/tmp" } }],
])("blocks agent-authenticated process adapter %s updates", async (_label, adapterConfig) => {
const app = await createApp({
type: "agent",
agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId,
source: "agent_key",
runId: "run-1",
});
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.patch(`/api/agents/${agentId}`)
.send({ adapterConfig }));
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed process adapters");
expect(mockAgentService.update).not.toHaveBeenCalled();
});
it("blocks an agent from switching a peer onto the process adapter", async () => {
mockAgentService.getById.mockResolvedValue({ ...baseAgent, adapterType: "codex_local" });
const app = await createApp({
type: "agent",
agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId,
source: "agent_key",
runId: "run-1",
});
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.patch(`/api/agents/${agentId}`)
.send({ adapterType: "process" }));
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed process adapters");
expect(mockAgentService.update).not.toHaveBeenCalled();
});
it.each([
["claude_local", { engine: "cli", command: "/tmp/untrusted" }],
["codex_local", { args: ["-c", "id"] }],
["cursor_local", { env: { PATH: "/tmp" } }],
["hermes_local", { hermesCommand: "/tmp/untrusted" }],
["gemini_local", { cwd: "/tmp" }],
["opencode_local", { filesystemSandboxCommand: "/tmp/untrusted" }],
])("blocks agent-authenticated host settings for %s", async (adapterType, adapterConfig) => {
mockAgentService.getById.mockResolvedValue({ ...baseAgent, adapterType });
const app = await createApp({
type: "agent",
agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId,
source: "agent_key",
runId: "run-1",
});
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.patch(`/api/agents/${agentId}`)
.send({ adapterConfig }));
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed local adapter settings");
expect(mockAgentService.update).not.toHaveBeenCalled();
});
it("allows an agent to update a peer's local model without host settings", async () => {
mockAgentService.getById.mockResolvedValue({ ...baseAgent, adapterType: "claude_local" });
const app = await createApp({ type: "agent", agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId, source: "agent_key", runId: "run-1" });
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.patch(`/api/agents/${agentId}`)
.send({ adapterConfig: { model: "claude-sonnet-4-5" } }));
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentService.update).toHaveBeenCalledOnce();
});
it("allows a provider credential reference without allowing arbitrary environment variables", async () => {
mockAgentService.getById.mockResolvedValue({ ...baseAgent, adapterType: "claude_local" });
const app = await createApp({ type: "agent", agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId, source: "agent_key", runId: "run-1" });
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.patch(`/api/agents/${agentId}`)
.send({ adapterConfig: { env: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "33333333-3333-4333-8333-333333333333" } } } }));
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentService.update).toHaveBeenCalledOnce();
});
it("blocks an agent from activating inherited host settings by switching onto a local adapter", async () => {
mockAgentService.getById.mockResolvedValue({
...baseAgent,
adapterType: "paperclip_runner",
adapterConfig: { env: { PATH: "/tmp" } },
});
const app = await createApp({ type: "agent", agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId, source: "agent_key", runId: "run-1" });
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.patch(`/api/agents/${agentId}`)
.send({ adapterType: "claude_local", adapterConfig: { engine: "cli" } }));
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed local adapter settings");
expect(mockAgentService.update).not.toHaveBeenCalled();
});
it.each([
["create", `/api/companies/${companyId}/agents`],
["hire", `/api/companies/${companyId}/agent-hires`],
])("blocks agent-authenticated process adapter commands on %s", async (_label, path) => {
const app = await createApp({ type: "agent", agentId, companyId, source: "agent_key", runId: "run-1" });
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.post(path)
.send({ name: "Host process", role: "engineer", adapterType: "process", adapterConfig: { command: "sh" } }));
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed process adapters");
expect(mockAgentService.create).not.toHaveBeenCalled();
});
it.each([
["create", `/api/companies/${companyId}/agents`],
["hire", `/api/companies/${companyId}/agent-hires`],
])("blocks agent-authenticated local adapter commands on %s", async (_label, path) => {
const app = await createApp({ type: "agent", agentId, companyId, source: "agent_key", runId: "run-1" });
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.post(path)
.send({ name: "Local peer", role: "engineer", adapterType: "claude_local", adapterConfig: { engine: "cli", command: "/tmp/untrusted" } }));
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed local adapter settings");
expect(mockAgentService.create).not.toHaveBeenCalled();
});
it("blocks agent-authenticated rollback into process adapter configuration", async () => {
mockAgentService.getConfigRevision.mockResolvedValue({
id: "33333333-3333-4333-8333-333333333333",
afterConfig: { adapterType: "process", adapterConfig: { command: "sh" }, runtimeConfig: {} },
});
const app = await createApp({ type: "agent", agentId, companyId, source: "agent_key", runId: "run-1" });
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.post(`/api/agents/${agentId}/config-revisions/33333333-3333-4333-8333-333333333333/rollback`));
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed process adapters");
expect(mockAgentService.rollbackConfigRevision).not.toHaveBeenCalled();
});
it("blocks agent-authenticated rollback into local adapter command configuration", async () => {
const revisionId = "33333333-3333-4333-8333-333333333333";
mockAgentService.getConfigRevision.mockResolvedValue({
id: revisionId,
afterConfig: { adapterType: "claude_local", adapterConfig: { engine: "cli", command: "/tmp/untrusted" }, runtimeConfig: {} },
});
const app = await createApp({ type: "agent", agentId, companyId, source: "agent_key", runId: "run-1" });
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.post(`/api/agents/${agentId}/config-revisions/${revisionId}/rollback`));
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed local adapter settings");
expect(mockAgentService.rollbackConfigRevision).not.toHaveBeenCalled();
});
it.each(["provisionCommand", "runtimeProvisionCommand", "teardownCommand"])(
"blocks agent-authenticated rollback of workspace %s",
async (commandKey) => {
const revisionId = "33333333-3333-4333-8333-333333333333";
mockAgentService.getConfigRevision.mockResolvedValue({
id: revisionId,
afterConfig: {
adapterType: "codex_local",
adapterConfig: { workspaceStrategy: { type: "git_worktree", [commandKey]: "sh -c id" } },
runtimeConfig: {},
},
});
const app = await createApp({
type: "agent",
agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId,
source: "agent_key",
runId: "run-1",
});
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.post(`/api/agents/${agentId}/config-revisions/${revisionId}/rollback`));
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed workspace commands");
expect(mockAgentService.rollbackConfigRevision).not.toHaveBeenCalled();
},
);
it("allows an agent to restore a non-process revision for a process peer", async () => {
const revisionId = "33333333-3333-4333-8333-333333333333";
mockAgentService.getConfigRevision.mockResolvedValue({
id: revisionId,
afterConfig: { adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {} },
});
mockAgentService.rollbackConfigRevision.mockResolvedValue({
...baseAgent,
adapterType: "codex_local",
});
const app = await createApp({
type: "agent",
agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId,
source: "agent_key",
runId: "run-1",
});
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.post(`/api/agents/${agentId}/config-revisions/${revisionId}/rollback`));
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentService.rollbackConfigRevision).toHaveBeenCalledOnce();
});
it("blocks agent-authenticated self-updates that set instructions bundle roots", async () => {
const app = await createApp({
type: "agent",
@@ -1,4 +1,5 @@
import express from "express";
import { readFile } from "node:fs/promises";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -113,7 +114,9 @@ vi.mock("../services/instance-settings.js", async (importOriginal) => ({
vi.mock("../adapters/index.js", () => ({
findServerAdapter: vi.fn(() => mockAdapter),
findActiveServerAdapter: vi.fn(() => mockAdapter),
findActiveServerAdapter: vi.fn((adapterType: string) => adapterType === "paperclip_runner"
? { ...mockAdapter, supportsInstructionsBundle: true }
: mockAdapter),
listAdapterModels: vi.fn(),
detectAdapterModel: vi.fn(),
}));
@@ -156,7 +159,9 @@ function registerModuleMocks() {
vi.doMock("../adapters/index.js", () => ({
findServerAdapter: vi.fn(() => mockAdapter),
findActiveServerAdapter: vi.fn(() => mockAdapter),
findActiveServerAdapter: vi.fn((adapterType: string) => adapterType === "paperclip_runner"
? { ...mockAdapter, supportsInstructionsBundle: true }
: mockAdapter),
listAdapterModels: vi.fn(),
detectAdapterModel: vi.fn(),
}));
@@ -244,7 +249,7 @@ function makeAgent(adapterType: string) {
};
}
describe.sequential("agent skill routes", () => {
describe("agent skill routes", () => {
beforeEach(async () => {
vi.resetModules();
vi.doUnmock("../routes/agents.js");
@@ -1117,35 +1122,69 @@ describe.sequential("agent skill routes", () => {
expect(mockAgentInstructionsService.materializeManagedBundle).not.toHaveBeenCalled();
});
it("materializes the bundled CEO instruction set for default CEO agents", async () => {
it.each(["claude_local", "paperclip_runner"].flatMap((adapterType) =>
["agents", "agent-hires"].map((route) => ({ adapterType, route })),
))("materializes only the CEO entry file for $adapterType via $route", async ({ adapterType, route }) => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({
enableBetaSkills: false,
enableNativeRunner: true,
});
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
.post("/api/companies/company-1/agents")
.post(`/api/companies/company-1/${route}`)
.send({
name: "CEO",
role: "ceo",
adapterType: "claude_local",
adapterConfig: {},
adapterType,
adapterConfig: adapterType === "paperclip_runner" ? { provider: "codex" } : {},
}));
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
const createdAgentId = expectResponseId(res.body.id);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.objectContaining({
id: createdAgentId,
role: "ceo",
adapterType: "claude_local",
}),
expect.objectContaining({
"AGENTS.md": expect.stringContaining("You are the CEO."),
"HEARTBEAT.md": expect.stringContaining("CEO Heartbeat Checklist"),
"SOUL.md": expect.stringContaining("CEO Persona"),
"TOOLS.md": expect.stringContaining("# Tools"),
}),
expect(res.status, JSON.stringify(res.body)).toBe(201);
const createdAgentId = expectResponseId(route === "agents" ? res.body.id : res.body.agent.id);
const entry = await readFile(new URL("../onboarding-assets/ceo/AGENTS.md", import.meta.url), "utf8");
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ id: createdAgentId, role: "ceo", adapterType }),
{ "AGENTS.md": entry },
{ entryFile: "AGENTS.md", replaceExisting: false },
);
});
it("materializes the bundled default instruction set for non-CEO agents with no prompt template", async () => {
it.each(["claude_local", "paperclip_runner"].flatMap((adapterType) =>
["agents", "agent-hires"].map((route) => ({ adapterType, route })),
))("preserves an explicit CEO instruction bundle for $adapterType via $route", async ({ adapterType, route }) => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({
enableBetaSkills: false,
enableNativeRunner: true,
});
const customFiles = {
"AGENTS.md": "You lead the bespoke research company. Read NOTES.md for its current focus.",
"NOTES.md": "Focus on the board's research questions.",
};
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
.post(`/api/companies/company-1/${route}`)
.send({
name: "Research Lead",
role: "ceo",
adapterType,
adapterConfig: adapterType === "paperclip_runner" ? { provider: "codex" } : {},
instructionsBundle: { entryFile: "AGENTS.md", files: customFiles },
}));
expect(res.status, JSON.stringify(res.body)).toBe(201);
const createdAgentId = expectResponseId(route === "agents" ? res.body.id : res.body.agent.id);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ id: createdAgentId, role: "ceo", adapterType }),
customFiles,
{ entryFile: "AGENTS.md", replaceExisting: false },
);
const returnedAgent = route === "agents" ? res.body : res.body.agent;
expect(returnedAgent.adapterConfig).toMatchObject({
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: `/tmp/${createdAgentId}/instructions/AGENTS.md`,
});
expect(returnedAgent.adapterConfig).not.toHaveProperty("promptTemplate");
});
it("materializes minimal default instructions for non-CEO agents with no prompt template", async () => {
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
.post("/api/companies/company-1/agents")
.send({
@@ -1165,38 +1204,10 @@ describe.sequential("agent skill routes", () => {
adapterType: "claude_local",
}),
expect.objectContaining({
"AGENTS.md": expect.stringMatching(/Start actionable work in the same heartbeat\.[\s\S]*Keep the work moving until it is done\./),
"AGENTS.md": "You are an agent in a Paperclip company.\n",
}),
{ entryFile: "AGENTS.md", replaceExisting: false },
);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
"AGENTS.md": expect.stringContaining('kind: "request_confirmation"'),
}),
expect.any(Object),
);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
"AGENTS.md": expect.stringContaining("confirmation:{issueId}:plan:{revisionId}"),
}),
expect.any(Object),
);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
"AGENTS.md": expect.stringMatching(/PUT \/issues\/\{id\}\/documents\/plan[\s\S]*Re-`GET \/documents\/plan`, assert it returns `200`[\s\S]*latestRevisionId[\s\S]*target=\{ type: 'issue_document', key: 'plan', revisionId: latestRevisionId \}[\s\S]*Never present a plan only in a thread comment or through `ask_user_questions`/),
}),
expect.any(Object),
);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
"AGENTS.md": expect.stringContaining("skills/paperclip/scripts/paperclip-upload-artifact.sh"),
}),
expect.any(Object),
);
});
});
@@ -15,6 +15,7 @@ import {
companySecrets,
createDb,
environments,
principalPermissionGrants,
userSecretDeclarations,
userSecretDefinitions,
} from "@paperclipai/db";
@@ -245,6 +246,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
await db.delete(userSecretDeclarations);
await db.delete(userSecretDefinitions);
await db.delete(adapterAuthSessions);
await db.delete(principalPermissionGrants);
await db.delete(agents);
await db.delete(environments);
await db.delete(companies);
@@ -8,6 +8,7 @@ import {
budgetPolicies,
companies,
createDb,
principalPermissionGrants,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
@@ -40,6 +41,7 @@ describeEmbeddedPostgres("pending approval agent config integrity", () => {
afterEach(async () => {
await db.delete(activityLog);
await db.delete(principalPermissionGrants);
await db.delete(budgetPolicies);
await db.delete(approvals);
await db.delete(agents);
@@ -13,6 +13,7 @@ import {
companySecretVersions,
companySecrets,
createDb,
principalPermissionGrants,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
@@ -46,6 +47,7 @@ describeEmbeddedPostgres("agent service secret binding sync", () => {
afterEach(async () => {
await db.delete(activityLog);
await db.delete(principalPermissionGrants);
await db.delete(companySecretBindings);
await db.delete(companySecretVersions);
await db.delete(companySecrets);
@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from "vitest";
import { AggregatorDiscoveryUnavailableError, discoverArcadeApps, discoverExecutorApps, EXECUTOR_INVENTORY_CODE, inventoryPayload } from "../services/aggregator-app-discovery.js";
import { aggregatorManagementUrl } from "@paperclipai/shared/aggregator-apps";
const account = (id: string, user = "u1") => ({ id, user_id: user, provider_id: "notion-provider", connection_status: "active", provider_user_info: { email: "Work", access_token: "do-not-store" } });
const tool = { qualified_name: "Notion.ListPages", toolkit: { name: "Notion" }, requirements: { met: true, authorization: { provider_id: "notion-provider", token_status: "completed" } } };
describe("aggregator inventory adapters", () => {
it("paginates Arcade, scopes the user and exposed tools, preserves distinct accounts, and strips credentials", async () => {
const request = vi.fn(async (path: string) => path.startsWith("/v1/tools") ? { items: [tool, { ...tool, toolkit: { name: "Private" }, qualified_name: "Private.Read" }] }
: path.includes("offset=100") ? { items: [account("last"), account("foreign", "u2")], total_count: 102, offset: 0 }
: { items: Array.from({ length: 100 }, (_, index) => account(String(index))), total_count: 102, offset: 100 });
const apps = await discoverArcadeApps({ request, userId: "u1", gatewayTools: ["Notion_ListPages"] });
expect(apps).toHaveLength(1);
expect(apps[0].accounts).toHaveLength(101);
expect(apps[0].accounts[0]).toMatchObject({ appSlug: "notion", status: "ACTIVE", alias: "Work" });
expect(JSON.stringify(apps)).not.toContain("do-not-store");
expect(request.mock.calls[0][0]).toContain("user[id]=u1");
});
it("uses Arcade's next offset after a short page and rejects truncated final pages", async () => {
const request = vi.fn(async (path: string) => path.startsWith("/v1/tools") ? { items: [tool], total_count: 1, offset: 0 }
: path.includes("offset=1") ? { items: [account("second")], total_count: 2, offset: 0 }
: { items: [account("first")], total_count: 2, offset: 1 });
expect((await discoverArcadeApps({ request, userId: "u1", gatewayTools: ["Notion.ListPages"] }))[0].accounts).toHaveLength(2);
expect(request.mock.calls.some(([path]) => path.includes("offset=1"))).toBe(true);
await expect(discoverArcadeApps({ userId: "u1", gatewayTools: ["Notion.ListPages"], request: async path => path.startsWith("/v1/tools")
? { items: [tool], total_count: 1, offset: 0 } : { items: [account("one")], total_count: 2, offset: 0 } })).rejects.toThrow("Incomplete");
});
it("rejects an Arcade partial page rather than returning an authoritative empty inventory", async () => {
for (const items of [[], [account("one")]]) await expect(discoverArcadeApps({ userId: "u1", gatewayTools: ["Notion.ListPages"], request: async path => path.startsWith("/v1/tools") ? { items: [tool] } : { items, total: 10 } })).rejects.toThrow("Incomplete");
});
it("does not claim Arcade authorization from account existence alone", async () => {
const apps = await discoverArcadeApps({ userId: "u1", gatewayTools: ["Notion.ListPages"], request: async path => ({ items: path.startsWith("/v1/tools") ? [{ ...tool, requirements: { ...tool.requirements, met: false } }] : [account("1")] }) });
expect(apps[0].accounts[0].status).toBe("UNVERIFIED");
});
it("enumerates Executor pages and uses owner/integration/account identity with honest health", async () => {
const call = vi.fn(async (_name, args) => ({ result: { structuredContent: { items: [{ integration: args.offset ? "custom-notion-helper" : "notion", integrationName: "Custom helper", connection: "Work", owner: args.offset ? "user" : "org", identityLabel: "Work", lastHealth: args.offset ? null : { status: "healthy", checkedAt: 1000 }, secret: "never" }], hasMore: !args.offset, nextOffset: args.offset ? null : 50 } } }));
const apps = await discoverExecutorApps({ call, toolNames: ["integrations"], managementUrl: "https://executor.example/team/integrations" });
expect(apps[0].accounts[0]).toMatchObject({ appSlug: "notion", status: "ACTIVE", healthCheckedAt: "1970-01-01T00:00:01.000Z" });
expect(apps[1].accounts[0]).toMatchObject({ appSlug: "executor:custom-notion-helper", status: "UNVERIFIED" });
expect(JSON.stringify(apps)).not.toContain("never");
expect(call).toHaveBeenCalledTimes(2);
});
it("uses fixed read-only code and preserves an upstream workspace management link", async () => {
const call = vi.fn(async () => ({ structuredContent: { status: "completed", result: { connections: [{ integration: "notion", name: "Work", owner: "user", lastHealth: { status: "expired" } }], integrations: [{ slug: "notion" }], managementUrls: { notion: "https://self-host.example/my-org/integrations/notion?addAccount=1" } } } }));
const apps = await discoverExecutorApps({ call, toolNames: ["execute"] });
expect(call).toHaveBeenCalledWith("execute", { code: EXECUTOR_INVENTORY_CODE });
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
expect(await new AsyncFunction("tools", EXECUTOR_INVENTORY_CODE)({})).toEqual({ discoveryUnavailable: true });
expect(apps[0].accounts[0]).toMatchObject({ status: "EXPIRED", managementUrl: "https://self-host.example/my-org/integrations/notion" });
});
it("rejects unsupported, failed, malformed and truncated Executor inventories", async () => {
await expect(discoverExecutorApps({ toolNames: [], call: vi.fn() })).rejects.toThrow("unavailable");
await expect(discoverExecutorApps({ toolNames: ["execute"], call: async () => ({ structuredContent: { result: { incompatible: true } } }) })).rejects.toBeInstanceOf(AggregatorDiscoveryUnavailableError);
await expect(discoverExecutorApps({ toolNames: ["execute"], call: async () => ({ isError: true }) })).rejects.toThrow("failed");
await expect(discoverExecutorApps({ toolNames: ["integrations"], call: async () => ({ items: [], hasMore: true, nextOffset: 0 }) })).rejects.toThrow("Incomplete");
expect(() => inventoryPayload({ content: [{ type: "text", text: "No accounts" }] })).toThrow();
});
it("validates browser destinations without leaking credentials", () => {
expect(aggregatorManagementUrl("composio")).toBe("https://dashboard.composio.dev/~/org/connect/apps");
for (const url of ["javascript:alert(1)", "https://user:password@executor.example/", "https://executor.example/?api_key=secret"]) expect(aggregatorManagementUrl("executor", url)).toBeNull();
expect(aggregatorManagementUrl("arcade", "https://other.example")).toBeNull();
expect(aggregatorManagementUrl("executor", "https://executor.example/team/accounts")).toBe("https://executor.example/team/accounts");
});
});
@@ -0,0 +1,160 @@
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { eq, sql } from "drizzle-orm";
import express from "express";
import request from "supertest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { companies, companyMemberships, connectionGrants, createDb, toolApplications, toolCatalogEntries, toolConnections, toolConnectionAppSnapshots } from "@paperclipai/db";
import { toolAccessService } from "../services/tool-access.js";
import { secretService } from "../services/secrets.js";
import { toolAccessRoutes } from "../routes/tool-access.js";
import { errorHandler } from "../middleware/error-handler.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
// Optional reuse of the explicitly disposable browser-test cluster on hosts with limited PostgreSQL startup resources.
const acceptanceDatabaseUrl = process.env.PAPERCLIP_AGGREGATOR_TEST_DATABASE_URL;
if (acceptanceDatabaseUrl && !["127.0.0.1", "localhost"].includes(new URL(acceptanceDatabaseUrl).hostname)) throw new Error("The acceptance database must be local and disposable");
const support = acceptanceDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
if (!support.supported) console.warn(`Managed-account database tests unavailable: ${support.reason}`);
(support.supported ? describe : describe.skip)("provider-neutral account sync", () => {
let db: ReturnType<typeof createDb>;
let cleanup: (() => Promise<void>) | undefined;
beforeAll(async () => {
if (acceptanceDatabaseUrl) { db = createDb(acceptanceDatabaseUrl); return; }
const database = await startEmbeddedPostgresTestDatabase("paperclip-aggregator-sync-"); db = createDb(database.connectionString); cleanup = database.cleanup;
}, 90_000);
afterAll(async () => { await cleanup?.(); });
async function fixture(provider: "arcade" | "executor" = "executor") {
const [company] = await db.insert(companies).values({ name: "Aggregator sync test", issuePrefix: randomUUID().slice(0, 6).toUpperCase() }).returning();
const userId = randomUUID();
await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: userId, membershipRole: "admin", status: "active" });
const [application] = await db.insert(toolApplications).values({ companyId: company.id, applicationKey: randomUUID(), name: provider, type: "mcp_http", metadata: { sourceTemplateKey: provider } }).returning();
const [connection] = await db.insert(toolConnections).values({ companyId: company.id, applicationId: application.id, uid: randomUUID(), name: `${provider} gateway`, transport: "mcp_remote", authKind: "none", credentialPolicy: "shared", status: "active", enabled: true, config: { sourceTemplateKey: provider, url: provider === "arcade" ? "https://api.arcade.dev/mcp/test" : "https://executor.example/mcp", managementUrl: "https://executor.example/team/integrations" } }).returning();
await db.insert(connectionGrants).values({ companyId: company.id, connectionId: connection.id, kind: "organization", status: "active", isDefault: true });
const names = provider === "executor" ? ["integrations"] : ["Notion.ListPages"];
await db.insert(toolCatalogEntries).values(names.map(toolName => ({ companyId: company.id, connectionId: connection.id, name: toolName, toolName, versionHash: "v1", status: "active", riskLevel: "read" as const })));
let accounts = [{ integration: "notion", owner: "user", connection: "Work", lastHealth: { status: "healthy", checkedAt: Date.now() } }];
let fail = false;
let partial = false;
const calls: { url: string; method: string; name?: string; authorization?: string }[] = [];
const access = toolAccessService(db, { remoteHttpRequest: async (url, init) => {
const rpc = init.body ? JSON.parse(String(init.body)) : undefined;
calls.push({ url: String(url), method: init.method ?? "GET", name: rpc?.params?.name, authorization: new Headers(init.headers).get("Authorization") ?? undefined });
if (rpc?.method === "tools/list") return Response.json({ jsonrpc: "2.0", id: rpc.id, result: { tools: names.map(name => ({ name, inputSchema: { type: "object" } })) } });
if (fail) return new Response("upstream error with private token", { status: 401 });
if (!rpc) return Response.json({ items: String(url).includes("/v1/tools") ? [{ qualified_name: names[0], toolkit: { name: "Notion" }, requirements: { met: true, authorization: { provider_id: "notion", token_status: "completed" } } }] : [{ id: "arcade-work", provider_id: "notion", user_id: "arcade-user", connection_status: "active", provider_user_info: { email: "Work" } }] });
return Response.json({ jsonrpc: "2.0", id: rpc.id, result: { structuredContent: { items: accounts, hasMore: partial, nextOffset: partial ? 0 : null } } });
} });
return { company, connection, actor: { actorType: "user" as const, actorId: userId }, access, calls, disconnect: () => { accounts = []; }, fail: () => { fail = true; }, partial: () => { partial = true; } };
}
async function sync(f: Awaited<ReturnType<typeof fixture>>) {
await f.access.syncAggregatorApps(f.connection.id, true, f.actor);
let result = await f.access.listAggregatorApps(f.connection.id, f.actor);
await vi.waitFor(async () => { result = await f.access.listAggregatorApps(f.connection.id, f.actor); expect(result.sync.status).not.toBe("syncing"); }, { timeout: 5000 });
return result;
}
it("replays the account migrations without losing observations or weakening company boundaries", async () => {
const f = await fixture();
await sync(f);
const before = await db.select().from(toolConnectionAppSnapshots).where(eq(toolConnectionAppSnapshots.connectionId, f.connection.id));
for (const name of ["0295_public_captain_cross", "0296_stiff_thaddeus_ross"]) {
const migration = await readFile(new URL(`../../../packages/db/src/migrations/${name}.sql`, import.meta.url), "utf8");
for (const statement of migration.split("--> statement-breakpoint")) await db.execute(sql.raw(statement));
}
expect(await db.select().from(toolConnectionAppSnapshots).where(eq(toolConnectionAppSnapshots.connectionId, f.connection.id))).toEqual(before);
const other = await fixture();
await expect(db.insert(toolConnectionAppSnapshots).values({ companyId: other.company.id, connectionId: f.connection.id,
userId: other.actor.actorId, credentialKey: "different", toolkit: "notion", status: "connected", accounts: [] })).rejects.toThrow();
});
it("imports and reconciles Executor accounts without new executable connections or access grants", async () => {
const f = await fixture();
const grants = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connection.id));
expect((await sync(f)).apps[0]).toMatchObject({ provider: "executor", appSlug: "notion", status: "connected" });
expect(await db.select().from(toolConnections).where(eq(toolConnections.companyId, f.company.id))).toHaveLength(1);
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connection.id))).toEqual(grants);
f.disconnect();
expect((await sync(f)).apps.flatMap(app => app.accounts)).toHaveLength(0);
});
it.each(["fail", "partial"] as const)("retains stale observations after %s and isolates users, gateways and credential changes", async failure => {
const f = await fixture(); await sync(f); f[failure]();
const result = await sync(f);
expect(result.sync.status).toBe("error");
expect(result.apps[0].accounts).toHaveLength(1);
expect(result.apps[0].errorAt).toBeTruthy();
expect(JSON.stringify(result)).not.toContain("private token");
expect((await f.access.listAggregatorApps(f.connection.id, { actorType: "user", actorId: randomUUID() })).apps).toHaveLength(0);
const other = await fixture(); expect((await other.access.listAggregatorApps(other.connection.id, other.actor)).apps).toHaveLength(0);
await db.update(toolConnections).set({ config: { ...f.connection.config, url: "https://executor.example/rotated" } }).where(eq(toolConnections.id, f.connection.id));
expect((await f.access.listAggregatorApps(f.connection.id, f.actor)).apps).toHaveLength(0);
});
it("reports unsupported Executor inventory without trying arbitrary tools", async () => {
const f = await fixture(); await sync(f); f.calls.length = 0; await db.update(toolCatalogEntries).set({ status: "inactive" }).where(eq(toolCatalogEntries.connectionId, f.connection.id));
expect((await f.access.syncAggregatorApps(f.connection.id, true, f.actor)).discovery.availability).toBe("unsupported");
expect(f.calls).toHaveLength(0);
expect((await f.access.listAggregatorApps(f.connection.id, f.actor)).apps[0].freshness).toBe("stale");
});
it("reuses an existing Arcade API key and secret-backed user header without extra sync setup", async () => {
const f = await fixture("arcade");
const key = await secretService(db).create(f.company.id, { name: "Arcade project", provider: "local_encrypted", value: "existing-key" }, { userId: f.actor.actorId });
const user = await secretService(db).create(f.company.id, { name: "Arcade user", provider: "local_encrypted", value: "arcade-user" }, { userId: f.actor.actorId });
await db.update(toolConnections).set({ authKind: "api_key", credentialRefs: [
{ name: "authorization", secretId: key.id, placement: "header", key: "Authorization", prefix: "Bearer " },
{ name: "headers.Arcade-User-ID", secretId: user.id, placement: "header", key: "Arcade-User-ID", prefix: "" },
], credentialSecretRefs: [
{ secretId: key.id, configPath: "credentials.authorization", versionSelector: "latest" },
{ secretId: user.id, configPath: "headers.Arcade-User-ID", versionSelector: "latest" },
] }).where(eq(toolConnections.id, f.connection.id));
for (const [secretId, configPath] of [[key.id, "credentials.authorization"], [user.id, "headers.Arcade-User-ID"]]) await secretService(db).createBinding({ companyId: f.company.id, secretId, targetType: "tool_connection", targetId: f.connection.id, configPath });
expect((await f.access.listAggregatorApps(f.connection.id, f.actor)).discovery.availability).toBe("available");
expect((await sync(f)).apps[0]).toMatchObject({ provider: "arcade", appSlug: "notion" });
expect(f.calls.filter(call => call.method === "GET").every(call => call.authorization === "Bearer existing-key")).toBe(true);
});
it("keeps optional Arcade discovery credentials in the vault, uses them only for discovery, and invalidates rotation", async () => {
const f = await fixture("arcade");
expect((await f.access.listAggregatorApps(f.connection.id, f.actor)).discovery.availability).toBe("setup_required");
await f.access.configureArcadeDiscovery(f.connection.id, { apiKey: "project-discovery-key", userId: "arcade-user" }, f.actor);
const result = await sync(f);
expect(result.apps[0]).toMatchObject({ provider: "arcade", appSlug: "notion" });
const [saved] = await db.select().from(toolConnections).where(eq(toolConnections.id, f.connection.id));
expect(JSON.stringify(saved)).not.toContain("project-discovery-key");
expect(saved.credentialSecretRefs).toHaveLength(0);
expect(f.calls.filter(call => call.method === "POST").every(call => call.authorization !== "Bearer project-discovery-key")).toBe(true);
expect(f.calls.filter(call => call.method === "GET").every(call => call.authorization === "Bearer project-discovery-key")).toBe(true);
const metadata = (saved.config.aggregatorDiscovery as Record<string, { secretId: string }>)[f.actor.actorId];
await secretService(db).rotateCurrentUserSecretValue(f.company.id, f.actor.actorId, metadata.secretId, { value: "rotated-discovery-key" }, { userId: f.actor.actorId });
expect((await f.access.listAggregatorApps(f.connection.id, f.actor)).apps).toHaveLength(0);
expect(await db.select().from(toolConnectionAppSnapshots).where(eq(toolConnectionAppSnapshots.connectionId, f.connection.id))).toHaveLength(1);
expect((await sync(f)).apps[0].accounts).toHaveLength(1);
expect(f.calls.filter(call => call.method === "GET").at(-1)?.authorization).toBe("Bearer rotated-discovery-key");
});
it("rejects optional sync configuration by ordinary members and agents", async () => {
const f = await fixture("arcade");
const memberId = randomUUID();
await db.insert(companyMemberships).values({ companyId: f.company.id, principalType: "user", principalId: memberId, membershipRole: "member", status: "active" });
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = req.headers["x-test-agent"] ? { type: "agent", agentId: randomUUID(), companyId: f.company.id } : { type: "board", userId: memberId, source: "session", isInstanceAdmin: false, companyIds: [f.company.id] }; next(); });
app.use("/api", toolAccessRoutes(db)); app.use(errorHandler);
for (const agent of [false, true]) {
const response = await request(app).put(`/api/tool-connections/${f.connection.id}/aggregator/discovery`).set("x-test-agent", agent ? "yes" : "").send({ apiKey: "not-saved", userId: "arcade-user" });
expect(response.status).toBe(403);
}
const [saved] = await db.select().from(toolConnections).where(eq(toolConnections.id, f.connection.id));
expect(saved.config.aggregatorDiscovery).toBeUndefined();
});
it("enforces company membership and connection-manager access on all generic endpoints", async () => {
const f = await fixture("arcade");
const app = express(); app.use(express.json());
let viewingUser = randomUUID();
app.use((req, _res, next) => { req.actor = { type: "board", userId: viewingUser, source: "session", isInstanceAdmin: false, companyIds: viewingUser === f.actor.actorId ? [f.company.id] : [] }; next(); });
app.use("/api", toolAccessRoutes(db)); app.use(errorHandler);
for (const path of ["/aggregator/apps", "/aggregator/apps/sync", "/aggregator/apps/refresh", "/aggregator/discovery"]) {
const endpoint = `/api/tool-connections/${f.connection.id}${path}`;
const response = path.endsWith("discovery") ? await request(app).put(endpoint).send({ apiKey: "no", userId: "no" }) : path.endsWith("apps") ? await request(app).get(endpoint) : await request(app).post(endpoint).send({});
expect([403, 404]).toContain(response.status);
}
viewingUser = f.actor.actorId;
await request(app).get(`/api/tool-connections/${f.connection.id}/aggregator/apps`).expect(200).expect("Cache-Control", "private, no-store");
});
});
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { aiBindingForAuthRecovery, isAiAuthenticationBlocked, isAiAuthenticationRepairable } from "../services/ai-auth-failure.js";
import { classifyRunLiveness } from "../services/run-liveness.js";
const missingClaudeKey = {
bindingType: "user_secret_ref", envKey: "ANTHROPIC_API_KEY",
configPath: "env.ANTHROPIC_API_KEY", errorCode: "user_secret_missing",
consumerType: "agent", responsibleUserId: "new-teammate",
};
const failure = (missingBindings: unknown, reason = "secret_binding_missing") => ({
errorCode: "configuration_incomplete",
resultJson: { configurationIncomplete: { reason, missingBindings } },
});
describe("personal AI credential preflight recovery", () => {
it.each(["user_secret_missing", "secret_inactive"])("offers Claude setup for %s without adopting another provider", errorCode => {
const run = failure([{ ...missingClaudeKey, errorCode }]);
expect(isAiAuthenticationRepairable(run)).toBe(true);
expect(aiBindingForAuthRecovery("claude_local", {}, run)).toEqual({
provider: "anthropic", method: "subscription", mode: "responsible_user",
});
expect(aiBindingForAuthRecovery("codex_local", {}, run)).toBeUndefined();
expect(isAiAuthenticationBlocked({ ...run, livenessState: "failed" })).toBe(false);
expect(isAiAuthenticationBlocked({ ...run, livenessState: "blocked" })).toBe(true);
});
it.each([
["tool credential", [{ ...missingClaudeKey, envKey: "GITHUB_TOKEN", configPath: "env.GITHUB_TOKEN" }]],
["company secret", [{ ...missingClaudeKey, bindingType: "secret_ref" }]],
["unknown error", [{ ...missingClaudeKey, errorCode: "user_secret_definition_missing" }]],
["adapter field", [{ ...missingClaudeKey, configPath: "apiKey" }]],
["additional tool blocker", [missingClaudeKey, { ...missingClaudeKey, envKey: "GITHUB_TOKEN", configPath: "env.GITHUB_TOKEN" }]],
["different providers", [missingClaudeKey, { ...missingClaudeKey, envKey: "OPENAI_API_KEY", configPath: "env.OPENAI_API_KEY" }]],
["empty evidence", []], ["malformed evidence", [null]], ["missing evidence", null],
])("keeps %s on the existing configuration recovery path", (_name, bindings) => {
expect(isAiAuthenticationRepairable(failure(bindings))).toBe(false);
expect(isAiAuthenticationBlocked({ ...failure(bindings), livenessState: "blocked" })).toBe(false);
});
it("only classifies a failed preflight as an inline wait after the card is persisted", () => {
const input = { ...failure([missingClaudeKey]), runStatus: "failed", issue: { status: "blocked" },
stdoutExcerpt: null, stderrExcerpt: null, error: null, continuationAttempt: 0, evidence: null };
expect(classifyRunLiveness(input).livenessState).toBe("failed");
expect(classifyRunLiveness({ ...input, authenticationRepairRequested: true })).toMatchObject({
livenessState: "blocked", nextAction: "Complete the connection request to continue this task",
});
expect(classifyRunLiveness({ ...input, ...failure([]), authenticationRepairRequested: true }).livenessState).toBe("failed");
});
});
@@ -0,0 +1,404 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { and, eq } from "drizzle-orm";
import { createDb, companies, agents, companyMemberships, connectionGrants, plugins, aiConnectionRouterCursors, aiConnectionTaskPins, toolConnections, companySecrets, heartbeatRuns, issues } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres";
import type { AiConnectionPoolMember, AiConnectionRouterRequest, AiConnectionRouterResult, PaperclipPluginManifestV1 } from "@paperclipai/shared";
import { aiConnectionRouterService, AiConnectionPoolExhausted, poolMemberRuntimeConfig, applyAiConnectionRouterTaskSettings } from "../services/ai-connection-router.js";
import { aiConnectionSessionCompatibilityInputs } from "../services/ai-connection-session.js";
import { projectPaperclipRunnerTaskConfig, resolvePaperclipRunnerNativeProviderInput } from "../services/native-runtime/provider-profile.js";
import { aiConnectionService } from "../services/ai-connections.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { managedAiSessionFingerprintConfig, prepareManagedAiRuntime } from "../services/ai-connection-runtime.js";
import { secretService } from "../services/secrets.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import express from "express";
import request from "supertest";
import { toolAccessRoutes } from "../routes/tool-access.js";
import { aiConnectionRoutes } from "../routes/ai-connections.js";
import { errorHandler } from "../middleware/index.js";
import { heartbeatService, buildEffectiveRunSessionConfigMetadata, resolveTaskSessionConfigFreshness } from "../services/heartbeat.js";
import { connectionIntentService } from "../services/connection-intents.js";
import { legacyExecutionNeedsReconciliation } from "../services/legacy-execution-recovery.js";
import { getServerAdapter, registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js";
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let home: string;
const companyId = randomUUID(), otherCompanyId = randomUUID(), agentId = randomUUID(), secondAgentId = randomUUID(), pluginId = randomUUID();
const pluginKey = "fixture.ai-router";
const manifest: PaperclipPluginManifestV1 = { id: pluginKey, apiVersion: 1, version: "0.1.0", displayName: "Fixture router", description: "Fixture", author: "Tests", categories: ["connector"], capabilities: ["ai.connections.route"], aiConnectionRouter: { name: "AI connection pool", description: "Use saved connections" }, entrypoints: { worker: "worker.js" } };
let members: AiConnectionPoolMember[];
const proposal = vi.fn(async (_id: string, _method: string, request: AiConnectionRouterRequest): Promise<AiConnectionRouterResult> => {
const next = (request.memberOrder.indexOf(request.lastMemberId ?? "") + 1) % request.memberOrder.length;
const order = request.pinnedMemberId ? [request.pinnedMemberId] : [...request.memberOrder.slice(next), ...request.memberOrder.slice(0, next)];
const selected = order.find(id => request.candidates.some(candidate => candidate.member.id === id));
return selected ? { kind: "selected", memberId: selected } : { kind: "unavailable", skipped: {} };
});
const worker = { isRunning: () => true, call: proposal } as unknown as PluginWorkerManager;
const defaultProposal = proposal.getMockImplementation()!;
const service = () => aiConnectionRouterService(db, worker);
const resolve = (poolId: string, taskKey: string, extra = {}) => service().resolve({ companyId, poolId, agentId, userId: "alice", adapterType: "paperclip_runner", taskKey, ...extra });
const makePool = async (config = {}) => service().save(pluginKey, { companyId, config: { name: randomUUID(), enabled: true, mode: "round_robin", thresholdPercent: 90, members, ...config } }, "alice");
beforeAll(async () => {
home = await mkdtemp(path.join(os.tmpdir(), "paperclip-router-tests-"));
vi.stubEnv("PAPERCLIP_HOME", home); vi.stubEnv("PAPERCLIP_INSTANCE_ID", "router-fixture");
database = await startEmbeddedPostgresTestDatabase("paperclip-router-db-"); db = createDb(database.connectionString);
await db.insert(companies).values([{ id: companyId, name: "Router fixture", issuePrefix: "RTF" }, { id: otherCompanyId, name: "Other", issuePrefix: "RTO" }]);
await db.insert(agents).values([agentId, secondAgentId].map(id => ({ id, companyId, name: "Router agent", adapterType: "paperclip_runner" })));
await db.insert(companyMemberships).values(["alice", "bob"].map(principalId => ({ companyId, principalId, principalType: "user", status: "active", membershipRole: "member" })));
await db.insert(plugins).values({ id: pluginId, pluginKey, packageName: pluginKey, version: "0.1.0", manifestJson: manifest, status: "ready" });
const accounts = aiConnectionService(db);
const codex = await accounts.save(companyId, "alice", { provider: "openai", method: "api_key", ownership: "personal", name: "Codex", apiKey: "fixture", agentIds: [], allAgents: true }, "fixture-codex");
const claude = await accounts.save(companyId, "alice", { provider: "anthropic", method: "subscription", ownership: "personal", name: "Claude", loginSessionId: "fixture", agentIds: [], allAgents: true }, "fixture-claude");
members = [
{ id: randomUUID(), binding: { mode: "delegated", provider: "openai", method: "api_key", ...codex }, profile: { provider: "codex", model: "gpt-5.6-sol", effort: "low" } },
{ id: randomUUID(), binding: { mode: "delegated", provider: "anthropic", method: "subscription", ...claude }, profile: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" } },
];
}, 90000);
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); if (home) await rm(home, { recursive: true, force: true }); });
describe("durable, authorized connection routing", () => {
it("defaults off and does not expose selectable pools before opt-in", async () => {
expect((await instanceSettingsService(db).getExperimental()).enableAiConnectionRouters).toBe(false);
expect((await service().catalog())[0]?.availability).toEqual({ available: false, reason: "Ask your instance operator to enable AI connection routing." });
await expect(makePool()).rejects.toThrow("instance operator");
expect(await service().selectable(companyId, "alice")).toEqual([]);
await instanceSettingsService(db).updateExperimental({ enableAiConnectionRouters: true });
});
it("registers native connectors only for declared installed routers", async () => {
const entry = (await service().catalog())[0]!;
expect(entry.name).toBe("AI connection pool");
expect(entry.aiConnectionRouter).toEqual({ pluginKey });
expect(entry.availability?.available).toBe(true);
await db.update(plugins).set({ status: "disabled" }).where(eq(plugins.id, pluginId));
try {
expect((await service().catalog())[0]?.availability?.available).toBe(false);
await db.update(plugins).set({ status: "uninstalled" }).where(eq(plugins.id, pluginId));
expect(await service().catalog()).toEqual([]);
} finally { await db.update(plugins).set({ status: "ready" }).where(eq(plugins.id, pluginId)); }
});
it("native connector removal retains durable pins and ordinary updates cannot bypass pool revisions", async () => {
const pool = await makePool(); const selected = await resolve(pool.id, "catalog-removal");
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", userId: "alice", source: "local_implicit", companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "owner" }] } as typeof req.actor; next(); });
app.use("/api", toolAccessRoutes(db)); app.use("/api", aiConnectionRoutes(db)); app.use(errorHandler);
expect((await request(app).patch(`/api/tool-connections/${pool.id}`).send({ name: "Bypass" })).status).toBe(400);
expect((await request(app).delete(`/api/tool-connections/${pool.id}`)).status).toBe(400);
expect((await request(app).delete(`/api/companies/${companyId}/ai-connection-pools/${pool.id}`).send({ expectedRevision: pool.revision + 1 })).status).toBe(409);
expect((await request(app).delete(`/api/companies/${companyId}/ai-connection-pools/${pool.id}`).send({ expectedRevision: pool.revision })).status).toBe(200);
expect((await service().list(companyId)).some(row => row.id === pool.id)).toBe(false);
expect(await db.select().from(aiConnectionTaskPins).where(eq(aiConnectionTaskPins.poolId, pool.id))).toHaveLength(1);
expect(await resolve(pool.id, "catalog-removal", { persisted: selected })).toEqual(selected);
expect((await db.select().from(toolConnections).where(eq(toolConnections.id, members[0]!.binding.connectionId)))[0]?.status).toBe("active");
});
it("requires company connection management authority on pool configuration routes", async () => {
const app = express(); app.use(express.json());
let actor = { type: "board", userId: "alice", source: "session", companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "owner" }] };
app.use((req, _res, next) => { req.actor = actor as typeof req.actor; next(); }); app.use("/api", aiConnectionRoutes(db)); app.use(errorHandler);
const pool = await makePool(); const url = `/api/companies/${companyId}/ai-connection-pools`;
expect((await request(app).get(url)).status).toBe(200);
expect((await request(app).get(`/api/companies/${otherCompanyId}/ai-connection-pools`)).status).toBe(403);
expect((await request(app).delete(`/api/companies/${otherCompanyId}/ai-connection-pools/${pool.id}`).send({ expectedRevision: pool.revision })).status).toBe(403);
actor = { ...actor, memberships: [{ companyId, status: "active", membershipRole: "viewer" }] };
expect((await request(app).get(url)).status).toBe(403);
expect((await request(app).post(url).send({ pluginKey, config: pool })).status).toBe(403);
expect((await request(app).get(`${url}/${pool.id}/inspection`)).status).toBe(403);
expect((await request(app).delete(`${url}/${pool.id}`).send({ expectedRevision: pool.revision })).status).toBe(403);
actor = { type: "agent", agentId, companyId, source: "agent_jwt" } as unknown as typeof actor;
expect((await request(app).get(url)).status).toBe(403);
expect((await request(app).delete(`${url}/${pool.id}`).send({ expectedRevision: pool.revision })).status).toBe(403);
actor = { type: "board", userId: "alice", source: "session", companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "owner" }] };
expect((await request(app).delete(`${url}/${pool.id}`).send({ expectedRevision: pool.revision + 1 })).status).toBe(409);
expect((await request(app).delete(`${url}/${pool.id}`).send({ expectedRevision: pool.revision })).status).toBe(200);
expect((await request(app).get(url)).body.some((entry: { id: string }) => entry.id === pool.id)).toBe(false);
});
it("deletes pools without erasing pins, cursors, or admitted recovery evidence", async () => {
const pool = await makePool(); const selected = await resolve(pool.id, "retained");
await expect(service().remove(otherCompanyId, pool.id, pool.revision, "alice")).rejects.toThrow("not found");
await expect(service().remove(companyId, pool.id, pool.revision + 1, "alice")).rejects.toThrow("reload before deleting");
await service().remove(companyId, pool.id, pool.revision, "alice");
expect((await service().list(companyId)).some(entry => entry.id === pool.id)).toBe(false);
expect((await service().selectable(companyId, "alice")).some(entry => entry.id === pool.id)).toBe(false);
expect(await db.select().from(aiConnectionTaskPins).where(eq(aiConnectionTaskPins.poolId, pool.id))).toHaveLength(1);
const [cursor] = await db.select().from(aiConnectionRouterCursors).where(eq(aiConnectionRouterCursors.poolId, pool.id)); expect(cursor?.version).toBe(1);
await expect(resolve(pool.id, "new")).rejects.toThrow("choose another AI connection");
expect(await resolve(pool.id, "retained", { persisted: selected })).toEqual(selected);
const { name, enabled, mode, thresholdPercent, members: savedMembers } = pool;
await expect(service().save(pluginKey, { companyId, id: pool.id, expectedRevision: pool.revision + 1, config: { name, enabled, mode, thresholdPercent, members: savedMembers } }, "alice")).rejects.toThrow("not found");
});
it("does not commit a selection when its pool is deleted during a proposal", async () => {
const pool = await makePool();
proposal.mockImplementationOnce(async () => { await service().remove(companyId, pool.id, pool.revision, "alice"); return { kind: "selected", memberId: members[0]!.id }; });
await expect(resolve(pool.id, "deleted-during-selection")).rejects.toThrow("choose another AI connection");
expect(await db.select().from(aiConnectionTaskPins).where(eq(aiConnectionTaskPins.poolId, pool.id))).toHaveLength(0);
const [cursor] = await db.select().from(aiConnectionRouterCursors).where(eq(aiConnectionRouterCursors.poolId, pool.id)); expect(cursor?.version).toBe(0);
});
it("allows cleanup while experimental routing and its plugin are disabled", async () => {
const pool = await makePool();
await instanceSettingsService(db).updateExperimental({ enableAiConnectionRouters: false });
await db.update(plugins).set({ status: "disabled" }).where(eq(plugins.id, pluginId));
try { await expect(service().remove(companyId, pool.id, pool.revision, "alice")).resolves.toEqual({ ok: true }); }
finally { await instanceSettingsService(db).updateExperimental({ enableAiConnectionRouters: true }); await db.update(plugins).set({ status: "ready" }).where(eq(plugins.id, pluginId)); }
});
it("rotates new tasks across qualified backends and retains pins on turns, resets and service restart", async () => {
const pool = await makePool();
expect((await resolve(pool.id, "one")).memberId).toBe(members[0]!.id);
expect((await resolve(pool.id, "two")).memberId).toBe(members[1]!.id);
expect((await resolve(pool.id, "one", { overrides: { model: "claude-sonnet-5", modelReasoningEffort: "high" } })).memberId).toBe(members[0]!.id);
expect((await aiConnectionRouterService(db, worker).resolve({ companyId, poolId: pool.id, agentId, userId: "alice", adapterType: "paperclip_runner", taskKey: "one" })).memberId).toBe(members[0]!.id);
const [cursor] = await db.select().from(aiConnectionRouterCursors).where(eq(aiConnectionRouterCursors.poolId, pool.id)); expect(cursor?.version).toBe(2);
});
it("commits one allocation for duplicate starts and serializes concurrent tasks", async () => {
const pool = await makePool();
const same = await Promise.all(Array.from({ length: 8 }, () => resolve(pool.id, "duplicate")));
expect(new Set(same.map(s => s.memberId)).size).toBe(1);
const many = await Promise.all(Array.from({ length: 8 }, (_, n) => resolve(pool.id, `concurrent-${n}`)));
expect(many.filter(s => s.memberId === members[0]!.id)).toHaveLength(4);
const pins = await db.select().from(aiConnectionTaskPins).where(eq(aiConnectionTaskPins.poolId, pool.id)); expect(pins).toHaveLength(9);
const [cursor] = await db.select().from(aiConnectionRouterCursors).where(eq(aiConnectionRouterCursors.poolId, pool.id)); expect(cursor?.version).toBe(9);
});
it("rolls back both pin and cursor on a failure during commit", async () => {
const pool = await makePool();
await db.execute(`CREATE FUNCTION fixture_router_crash() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'fixture commit crash'; END $$`);
await db.execute(`CREATE TRIGGER fixture_router_crash BEFORE UPDATE ON ai_connection_router_cursors FOR EACH ROW EXECUTE FUNCTION fixture_router_crash()`);
try { await expect(resolve(pool.id, "crash")).rejects.toThrow(); } finally { await db.execute(`DROP TRIGGER fixture_router_crash ON ai_connection_router_cursors`); await db.execute(`DROP FUNCTION fixture_router_crash()`); }
expect(await db.select().from(aiConnectionTaskPins).where(eq(aiConnectionTaskPins.poolId, pool.id))).toHaveLength(0);
const selected = await resolve(pool.id, "crash"); expect(selected.memberId).toBe(members[0]!.id);
const [cursor] = await db.select().from(aiConnectionRouterCursors).where(eq(aiConnectionRouterCursors.poolId, pool.id)); expect(cursor?.version).toBe(1);
});
it("rechecks config revisions after a proposal and preserves removed member snapshots", async () => {
const pool = await makePool(); const old = await resolve(pool.id, "existing");
const saved = await service().save(pluginKey, { companyId, id: pool.id, expectedRevision: pool.revision, config: { name: "Edited", enabled: true, mode: "round_robin", thresholdPercent: 90, members: [members[1]!] } }, "alice");
await expect(service().save(pluginKey, { companyId, id: pool.id, expectedRevision: pool.revision, config: saved }, "alice")).rejects.toThrow();
expect((await resolve(pool.id, "existing")).binding).toEqual(old.binding);
expect((await resolve(pool.id, "future")).memberId).toBe(members[1]!.id);
});
it("keeps account and harness pinned through independent composer fallback", async () => {
const pool = await makePool();
const first = await resolve(pool.id, "composer");
const changed = await resolve(pool.id, "composer", { overrides: { model: "claude-sonnet-5", modelReasoningEffort: "high" } });
expect(changed.binding).toEqual(first.binding); expect(changed.runtimeConfig).toMatchObject({ provider: "codex", model: "gpt-5.6-sol", modelReasoningEffort: "high" }); expect(changed.notes).toHaveLength(1);
const claude = poolMemberRuntimeConfig(members[1]!, "paperclip_runner", { model: "gpt-5.6-sol", modelReasoningEffort: "ultra" });
expect(claude.config).toMatchObject({ provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" }); expect(claude.notes).toHaveLength(2);
const overrides = applyAiConnectionRouterTaskSettings({ model: "claude-sonnet-5", effort: "invalid", cwd: "/fixture" }, changed);
const native = resolvePaperclipRunnerNativeProviderInput({ backend: "codex_app_server", adapterConfig: projectPaperclipRunnerTaskConfig("codex_app_server", changed.runtimeConfig, overrides) });
expect(native).toMatchObject({ provider: "codex", model: "gpt-5.6-sol", codexReasoningEffort: "high" });
expect(overrides).toMatchObject({ cwd: "/fixture" }); expect(overrides).not.toHaveProperty("effort");
});
it("retries a changed config proposal without advancing the abandoned selection", async () => {
const pool = await makePool();
proposal.mockImplementationOnce(async () => {
await service().save(pluginKey, { companyId, id: pool.id, expectedRevision: pool.revision, config: { name: pool.name, enabled: true, mode: pool.mode, thresholdPercent: pool.thresholdPercent, members: [members[1]!] } }, "alice");
return { kind: "selected", memberId: members[0]!.id };
});
expect((await resolve(pool.id, "revision-conflict")).memberId).toBe(members[1]!.id);
const [cursor] = await db.select().from(aiConnectionRouterCursors).where(eq(aiConnectionRouterCursors.poolId, pool.id)); expect(cursor?.version).toBe(1);
});
it("fails closed when the experimental flag changes during a proposal", async () => {
const pool = await makePool();
proposal.mockImplementationOnce(async () => { await instanceSettingsService(db).updateExperimental({ enableAiConnectionRouters: false }); return { kind: "selected", memberId: members[0]!.id }; });
try { await expect(resolve(pool.id, "disabled-in-flight")).rejects.toThrow("disabled during allocation"); expect(await db.select().from(aiConnectionTaskPins).where(eq(aiConnectionTaskPins.poolId, pool.id))).toHaveLength(0); }
finally { await instanceSettingsService(db).updateExperimental({ enableAiConnectionRouters: true }); }
});
it("uses only compatible legacy members and makes reassignment an independent pin", async () => {
const pool = await makePool();
expect((await resolve(pool.id, "legacy", { adapterType: "claude_local" })).memberId).toBe(members[1]!.id);
expect((await resolve(pool.id, "legacy", { agentId: secondAgentId, adapterType: "codex_local" })).memberId).toBe(members[0]!.id);
const pins = await db.select().from(aiConnectionTaskPins).where(eq(aiConnectionTaskPins.poolId, pool.id)); expect(pins).toHaveLength(2);
});
it("adopts a valid existing account and requires explicit reset for an unrelated session", async () => {
const pool = await makePool();
expect((await resolve(pool.id, "adopt", { requireExisting: true, existingGrantId: members[1]!.binding.grantId })).memberId).toBe(members[1]!.id);
await expect(resolve(pool.id, "foreign-session", { requireExisting: true, existingGrantId: randomUUID() })).rejects.toThrow("Reset");
expect((await resolve(pool.id, "foreign-session")).memberId).toBe(members[0]!.id);
});
it("rejects cross-company access and unauthorized plugin proposals", async () => {
const pool = await makePool();
await expect(resolve(pool.id, "foreign", { companyId: otherCompanyId })).rejects.toThrow();
proposal.mockResolvedValueOnce({ kind: "selected", memberId: randomUUID() });
await expect(resolve(pool.id, "bad-proposal")).rejects.toThrow("unauthorized");
expect(await db.select().from(aiConnectionTaskPins).where(eq(aiConnectionTaskPins.poolId, pool.id))).toHaveLength(0);
});
it("does not grant members to another responsible user or move revoked pins", async () => {
const pool = await makePool(); const selected = await resolve(pool.id, "revoke");
await expect(resolve(pool.id, "revoke", { userId: "bob" })).rejects.toThrow();
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, selected.binding.mode === "responsible_user" ? "" : selected.binding.grantId));
try { await expect(resolve(pool.id, "revoke")).rejects.toThrow(); expect((await resolve(pool.id, "new")).memberId).toBe(members[1]!.id); }
finally { await db.update(connectionGrants).set({ status: "active" }).where(eq(connectionGrants.id, members[0]!.binding.grantId)); }
});
it("never probes pure rotation, caches authorized observations, and invalidates on credential change", async () => {
const spy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(JSON.stringify({ five_hour: { utilization: 12 } })));
try {
const pure = await makePool(); await resolve(pure.id, "pure"); expect(spy).not.toHaveBeenCalled();
const aware = await makePool({ mode: "usage_aware", members: [members[1]!] }); await resolve(aware.id, "aware"); expect(spy).toHaveBeenCalledTimes(1); await resolve(aware.id, "aware"); expect(spy).toHaveBeenCalledTimes(1);
expect((await service().inspect(companyId, aware.id, "alice"))[members[1]!.id]?.checkedAt).not.toBeNull();
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, members[1]!.binding.grantId)); const ref = grant!.credentialSecretRefs.find(ref => ref.configPath === "ai.credential")!;
await secretService(db).rotate(ref.secretId, { value: "fixture-refreshed", preserveAiSessionEpoch: true }, { userId: "alice" });
expect((await service().inspect(companyId, aware.id, "alice"))[members[1]!.id]?.checkedAt).toBeNull();
await resolve(aware.id, "aware"); expect(spy).toHaveBeenCalledTimes(2);
proposal.mockResolvedValueOnce({ kind: "exhausted", retryAt: new Date(Date.now() + 60_000).toISOString(), skipped: {} });
await expect(resolve(aware.id, "aware")).rejects.toBeInstanceOf(AiConnectionPoolExhausted);
const pins = await db.select().from(aiConnectionTaskPins).where(eq(aiConnectionTaskPins.poolId, aware.id)); expect(pins).toHaveLength(1);
} finally { spy.mockRestore(); }
});
it("shares an in-flight usage observation across concurrent allocations", async () => {
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, members[1]!.binding.grantId));
const ref = grant!.credentialSecretRefs.find(ref => ref.configPath === "ai.credential")!;
await secretService(db).rotate(ref.secretId, { value: "fixture-shared-probe", preserveAiSessionEpoch: true }, { userId: "alice" });
const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
await new Promise(resolve => setTimeout(resolve, 100));
return new Response(JSON.stringify({ five_hour: { utilization: 12 } }));
});
try {
const pool = await makePool({ mode: "usage_aware", members: [members[1]!] });
await Promise.all(Array.from({ length: 6 }, (_, index) => resolve(pool.id, `shared-probe-${index}`)));
expect(fetch).toHaveBeenCalledTimes(1);
} finally { fetch.mockRestore(); }
});
it("expires a usage observation at a reported reset", async () => {
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, members[1]!.binding.grantId));
const ref = grant!.credentialSecretRefs.find(ref => ref.configPath === "ai.credential")!;
await secretService(db).rotate(ref.secretId, { value: "fixture-reset-cache", preserveAiSessionEpoch: true }, { userId: "alice" });
const spy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(JSON.stringify({ five_hour: { utilization: 12, resets_at: new Date(Date.now() + 200).toISOString() } })));
try {
const pool = await makePool({ mode: "usage_aware", members: [members[1]!] }); await resolve(pool.id, "reset-cache"); expect(spy).toHaveBeenCalledTimes(1);
await new Promise(resolve => setTimeout(resolve, 250)); await resolve(pool.id, "reset-cache"); expect(spy).toHaveBeenCalledTimes(2);
} finally { spy.mockRestore(); }
});
it("treats unfinished probes as unknown within the shared selection budget", async () => {
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, members[1]!.binding.grantId));
const ref = grant!.credentialSecretRefs.find(ref => ref.configPath === "ai.credential")!;
await secretService(db).rotate(ref.secretId, { value: "fixture-slow-probe", preserveAiSessionEpoch: true }, { userId: "alice" });
const spy = vi.spyOn(globalThis, "fetch").mockImplementation(() => new Promise<Response>(() => {}));
try { const pool = await makePool({ mode: "usage_aware", members: [members[1]!] }); const start = Date.now(); expect((await resolve(pool.id, "slow-probe")).memberId).toBe(members[1]!.id); expect(Date.now() - start).toBeLessThan(20_000); expect(proposal.mock.calls.at(-1)?.[2].candidates[0]?.usage).toBeUndefined(); }
finally { spy.mockRestore(); }
}, 25_000);
it("persists run-key affinity before policy calls and reuses it across allocation recovery", async () => {
await instanceSettingsService(db).updateExperimental({ enableAiConnectionRouters: true, enableWorktreeRunExecution: true });
const pool = await makePool({ members: [members[1]!] });
const [agent] = await db.insert(agents).values({ companyId, name: "Run-key fixture", adapterType: "claude_local", adapterConfig: { cwd: home, engine: "cli" }, runtimeConfig: { aiConnection: { mode: "router", connectionId: pool.id }, heartbeat: { enabled: false } } }).returning();
let affinity: string | undefined;
const execute = vi.fn(); registerServerAdapter({ ...getServerAdapter("claude_local"), execute });
const heartbeat = heartbeatService(db, { pluginWorkerManager: worker, runtimeEnv: {} });
proposal.mockImplementationOnce(async (_id, _method, request) => {
const [run] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, agent!.id), eq(heartbeatRuns.status, "running")));
expect(run?.contextSnapshot?.aiRouterTaskKey).toBe(run?.id);
expect(request.taskKey).toBe(run?.id);
affinity = run!.id;
throw new Error("fixture policy interruption");
});
try {
const run = await heartbeat.wakeup(agent!.id, { source: "on_demand", reason: "manual", triggerDetail: "manual", contextSnapshot: { responsibleUserId: "alice", wakeReason: "manual" } });
expect(run).not.toBeNull();
await expect.poll(async () => (await heartbeat.getRun(run!.id))?.status, { timeout: 20_000 }).toBe("failed");
expect(affinity).toBe(run!.id);
expect((await heartbeat.getRun(run!.id))?.contextSnapshot?.aiRouterTaskKey).toBe(affinity);
expect(execute).not.toHaveBeenCalled();
const input = { agentId: agent!.id, adapterType: "claude_local" };
const committed = await resolve(pool.id, affinity!, input);
expect(await aiConnectionRouterService(db, worker).resolve({ companyId, poolId: pool.id, agentId: agent!.id, userId: "alice", adapterType: "claude_local", taskKey: affinity! })).toEqual(committed);
const [cursor] = await db.select().from(aiConnectionRouterCursors).where(eq(aiConnectionRouterCursors.poolId, pool.id)); expect(cursor?.version).toBe(1);
expect(await db.select().from(aiConnectionTaskPins).where(eq(aiConnectionTaskPins.poolId, pool.id))).toHaveLength(1);
} finally { proposal.mockReset(); proposal.mockImplementation(defaultProposal); unregisterServerAdapter("claude_local"); await heartbeat.drainActiveRunExecutions(); }
}, 30_000);
it("defers a pinned task before provider work and schedules a retry with its affinity intact", async () => {
await instanceSettingsService(db).updateExperimental({ enableAiConnectionRouters: true, enableWorktreeRunExecution: true });
const pool = await makePool({ members: [members[1]!] });
const [agent] = await db.insert(agents).values({ companyId, name: "Quota fixture", adapterType: "claude_local", adapterConfig: { cwd: home, engine: "cli" }, runtimeConfig: { aiConnection: { mode: "router", connectionId: pool.id }, heartbeat: { enabled: false } } }).returning();
const [issue] = await db.insert(issues).values({ companyId, title: "Quota fixture", status: "todo", assigneeAgentId: agent!.id, responsibleUserId: "alice", createdByUserId: "alice" }).returning();
const pinned = await resolve(pool.id, issue!.id, { agentId: agent!.id, adapterType: "claude_local" });
await service().save(pluginKey, { companyId, id: pool.id, expectedRevision: pool.revision, config: { name: pool.name, enabled: true, mode: "usage_aware", thresholdPercent: 90, members: [members[1]!] } }, "alice");
const execute = vi.fn(); registerServerAdapter({ ...getServerAdapter("claude_local"), execute });
const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(JSON.stringify({ five_hour: { utilization: 100 } })));
proposal.mockResolvedValueOnce({ kind: "exhausted", retryAt: new Date(Date.now() + 60_000).toISOString(), skipped: { [members[1]!.id]: "Exhausted" } });
const heartbeat = heartbeatService(db, { pluginWorkerManager: worker, runtimeEnv: {} });
try {
const run = await heartbeat.wakeup(agent!.id, { source: "assignment", reason: "issue_assigned", payload: { issueId: issue!.id }, contextSnapshot: { issueId: issue!.id, wakeReason: "issue_assigned", responsibleUserId: "alice" }, triggerDetail: "system" });
expect(run).not.toBeNull();
await expect.poll(async () => (await heartbeat.getRun(run!.id))?.status, { timeout: 20_000 }).toBe("cancelled");
const cancelled = await heartbeat.getRun(run!.id); expect(cancelled?.errorCode).toBe("ai_connection_pool_exhausted");
expect(legacyExecutionNeedsReconciliation(cancelled!)).toBe(false);
expect(legacyExecutionNeedsReconciliation({ ...cancelled!, resultJson: { executionRecovery: { kind: "ai_connection_wait", providerWorkStarted: true } } })).toBe(true);
await expect.poll(async () => (await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.agentId, agent!.id), eq(heartbeatRuns.scheduledRetryReason, "ai_connection_pool_wait")))).length, { timeout: 5000 }).toBe(1);
const [retry] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.agentId, agent!.id), eq(heartbeatRuns.scheduledRetryReason, "ai_connection_pool_wait")));
expect(retry?.contextSnapshot?.aiRouterTaskKey).toBe(issue!.id); expect(retry?.contextSnapshot?.executionRetryAccounting).toMatchObject({ failureRetries: 0 }); expect(execute).not.toHaveBeenCalled();
const [pin] = await db.select().from(aiConnectionTaskPins).where(and(eq(aiConnectionTaskPins.poolId, pool.id), eq(aiConnectionTaskPins.agentId, agent!.id))); expect(pin?.selection).toEqual(pinned);
const [cursor] = await db.select().from(aiConnectionRouterCursors).where(eq(aiConnectionRouterCursors.poolId, pool.id)); expect(cursor?.version).toBe(1);
await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date() }).where(eq(heartbeatRuns.agentId, agent!.id));
} finally { fetch.mockRestore(); proposal.mockReset(); proposal.mockImplementation(defaultProposal); unregisterServerAdapter("claude_local"); await heartbeat.drainActiveRunExecutions(); }
}, 30_000);
it("repairs the failed pool account without replacing the pool or adopting the configured provider", async () => {
const pool = await makePool({ members: [members[1]!] });
const [agent] = await db.insert(agents).values({ companyId, name: "Pool repair", adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" }, runtimeConfig: { aiConnection: { mode: "router", connectionId: pool.id } } }).returning();
const [issue] = await db.insert(issues).values({ companyId, title: "Pool repair", status: "in_progress", assigneeAgentId: agent!.id, responsibleUserId: "alice", createdByUserId: "alice" }).returning();
const selection = await resolve(pool.id, issue!.id, { agentId: agent!.id });
const runtime = await prepareManagedAiRuntime(db, { companyId, agentId: agent!.id, responsibleUserId: "alice", adapterType: "paperclip_runner", config: selection.runtimeConfig, binding: selection.binding });
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId: agent!.id, status: "failed", errorCode: "acpx_auth_required", responsibleUserId: "alice", startedAt: new Date(), contextSnapshot: { issueId: issue!.id, aiRouterTaskKey: issue!.id, aiRouterSelection: selection, aiConnection: { ...runtime!.attribution, identity: runtime!.identity } } }).returning();
try {
const intents = connectionIntentService(db);
const card = await intents.requestForRunAuthFailure(run!.id);
expect(card?.service).toBe("anthropic"); expect(card?.state).toBe("needs_user_action");
const options = await intents.setupOptions(card!.interactionId!);
expect(options.aiConnection).toEqual(selection.binding); expect(options.aiConnectionRequiresAdoption).toBeUndefined();
const [failedGrant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, members[1]!.binding.grantId)); expect(failedGrant?.status).toBe("needs_reauthorization");
await db.update(connectionGrants).set({ status: "active" }).where(eq(connectionGrants.id, members[1]!.binding.grantId));
await db.update(toolConnections).set({ healthStatus: "ok" }).where(eq(toolConnections.id, members[1]!.binding.connectionId));
expect(await intents.complete(card!.interactionId!, members[1]!.binding.connectionId, "alice")).toMatchObject({ status: "accepted" });
const [savedAgent] = await db.select().from(agents).where(eq(agents.id, agent!.id)); expect(savedAgent?.runtimeConfig.aiConnection).toEqual({ mode: "router", connectionId: pool.id });
expect((await resolve(pool.id, issue!.id, { agentId: agent!.id })).binding).toEqual(selection.binding);
await db.update(agents).set({ runtimeConfig: { aiConnection: { mode: "router", connectionId: randomUUID() } } }).where(eq(agents.id, agent!.id));
await expect(intents.setupOptions(card!.interactionId!)).rejects.toThrow("configuration changed");
} finally {
await runtime?.cleanup();
await db.update(connectionGrants).set({ status: "active" }).where(eq(connectionGrants.id, members[1]!.binding.grantId));
await db.update(toolConnections).set({ healthStatus: "ok" }).where(eq(toolConnections.id, members[1]!.binding.connectionId));
}
});
it("rejects disabled routing but recovers server-owned native evidence after disable/uninstall", async () => {
const pool = await makePool(); const selected = await resolve(pool.id, "recovery");
await instanceSettingsService(db).updateExperimental({ enableAiConnectionRouters: false });
try { await expect(resolve(pool.id, "recovery")).rejects.toMatchObject({ status: 422, details: { code: "ai_connection_router_disabled" } }); expect(await resolve(pool.id, "recovery", { persisted: selected })).toEqual(selected); }
finally { await instanceSettingsService(db).updateExperimental({ enableAiConnectionRouters: true }); }
await db.update(plugins).set({ status: "disabled" }).where(eq(plugins.id, pluginId));
try { await expect(resolve(pool.id, "new")).rejects.toThrow("plugin"); expect(await resolve(pool.id, "recovery", { persisted: selected })).toEqual(selected); await db.delete(toolConnections).where(eq(toolConnections.id, pool.id)); expect(await resolve(pool.id, "recovery", { persisted: selected })).toEqual(selected); }
finally { await db.update(plugins).set({ status: "ready" }).where(eq(plugins.id, pluginId)); }
});
it("resumes a responsible-user session through a delegated pool using real managed account configurations", async () => {
await aiConnectionService(db).setDefault(companyId, "alice", members[0]!.binding.grantId!);
const pool = await makePool({ members: [members[0]!] });
const selected = await resolve(pool.id, "managed-mode-adoption", { requireExisting: true, existingGrantId: members[0]!.binding.grantId });
const directBinding = { mode: "responsible_user" as const, provider: "openai" as const, method: "api_key" as const };
const prepare = { companyId, agentId, responsibleUserId: "alice", adapterType: "paperclip_runner", config: selected.runtimeConfig };
const before = await prepareManagedAiRuntime(db, { ...prepare, binding: directBinding });
const after = await prepareManagedAiRuntime(db, { ...prepare, binding: selected.binding });
try {
expect(before.config.managedAiConnection.mode).toBe("responsible_user");
expect(after.config.managedAiConnection.mode).toBe("delegated");
expect(after.sessionIdentity).toBe(before.sessionIdentity);
const common = { adapterType: "paperclip_runner", issueOverrides: null, workspaceConfig: {}, environment: {}, environmentEnv: null, projectEnv: null, routineEnv: null, runtimeSkills: [], agentConfigRevision: null };
const previous = await buildEffectiveRunSessionConfigMetadata({ ...common, effectiveAdapterConfig: before.config, managedAiHome: before.home, agentRuntimeConfig: { aiConnection: directBinding } });
const currentInput = { ...common, effectiveAdapterConfig: after.config, managedAiHome: after.home, agentRuntimeConfig: { aiConnection: { mode: "router", connectionId: pool.id } } };
const current = await buildEffectiveRunSessionConfigMetadata(currentInput);
const alternatives = aiConnectionSessionCompatibilityInputs({ ...currentInput, originalIssueOverrides: null, binding: selected.binding, router: true, storedIdentity: before.sessionIdentity, sessionIdentity: after.sessionIdentity, credentialIdentity: after.identity, revisions: [] });
const compatibleConfigMetadata = await Promise.all(alternatives.map(candidate => buildEffectiveRunSessionConfigMetadata({ ...currentInput, ...candidate })));
expect(resolveTaskSessionConfigFreshness({ hasTaskSession: true, configuredModel: String(selected.runtimeConfig.model), taskSessionParams: { __paperclipConfiguredModel: selected.runtimeConfig.model, __paperclipConfigFingerprint: previous.fingerprint, __paperclipConfigFingerprintVersion: previous.version, __paperclipConfigCategoryFingerprints: previous.categoryFingerprints }, configMetadata: current, compatibleConfigMetadata }).reset).toBe(false);
} finally { await before.cleanup(); await after.cleanup(); }
});
it("keeps session identity and fingerprints stable on authenticated refresh, but changes them on manual replacement", async () => {
const input = { companyId, agentId, responsibleUserId: "alice", adapterType: "paperclip_runner", binding: members[0]!.binding, config: { provider: "codex", model: "gpt-5.6-sol" } };
const first = await prepareManagedAiRuntime(db, input);
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, members[0]!.binding.grantId)); const ref = grant!.credentialSecretRefs.find(ref => ref.configPath === "ai.credential")!;
try {
await secretService(db).rotate(ref.secretId, { value: "fixture-new-token", preserveAiSessionEpoch: true }, { userId: "alice" });
const refreshed = await prepareManagedAiRuntime(db, input);
try { expect(refreshed.identity).not.toBe(first.identity); expect(refreshed.sessionIdentity).toBe(first.sessionIdentity); expect(managedAiSessionFingerprintConfig(refreshed.config, refreshed.home)).toEqual(managedAiSessionFingerprintConfig(first.config, first.home)); } finally { await refreshed.cleanup(); }
await secretService(db).rotate(ref.secretId, { value: "fixture-replacement" }, { userId: "alice" });
const replaced = await prepareManagedAiRuntime(db, input); try { expect(replaced.sessionIdentity).not.toBe(first.sessionIdentity); } finally { await replaced.cleanup(); }
const [secret] = await db.select().from(companySecrets).where(and(eq(companySecrets.companyId, companyId), eq(companySecrets.id, ref.secretId))); expect(secret!.aiSessionEpoch).toBeGreaterThan(0);
} finally { await first.cleanup(); }
});
});
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { aiConnectionSessionCompatibilityInputs, managedAiSessionIdentityCompatible, aiConnectionOnlyConfigRevision, aiConnectionRevisionMetadata } from "../services/ai-connection-session.js";
import { buildEffectiveRunSessionConfigMetadata, resolveTaskSessionConfigFreshness } from "../services/heartbeat.js";
const binding = { mode: "shared" as const, provider: "openai" as const, method: "subscription" as const, connectionId: "account", grantId: "grant" };
const pool = { mode: "router", connectionId: "pool" };
const sessionIdentity = "grant:alice:0", credentialIdentity = "grant:alice:token";
const config = { provider: "codex", model: "gpt-5.6-sol", env: { OPENAI_API_KEY: "fixture-token" }, managedAiConnection: { ...binding, identity: credentialIdentity, sessionIdentity } };
const base = { adapterType: "paperclip_runner", effectiveAdapterConfig: config, agentRuntimeConfig: { aiConnection: pool }, agentConfigRevision: null as unknown, issueOverrides: null as unknown, workspaceConfig: {}, environment: {}, environmentEnv: null, projectEnv: null, routineEnv: null, runtimeSkills: [] };
type MetadataInput = Parameters<typeof buildEffectiveRunSessionConfigMetadata>[0];
type CompatibilityInput = Parameters<typeof aiConnectionSessionCompatibilityInputs>[0];
const revision = (id: string, beforeBinding: unknown, afterBinding: unknown, extra = {}) => ({
id, changedKeys: ["runtimeConfig"], createdAt: new Date(`2026-10-02T00:00:0${id.slice(-1)}Z`),
beforeConfig: { name: "Agent", runtimeConfig: { aiConnection: beforeBinding }, adapterConfig: { model: "gpt-5.6-sol" } },
afterConfig: { name: "Agent", runtimeConfig: { aiConnection: afterBinding }, adapterConfig: { model: "gpt-5.6-sol" }, ...extra },
});
async function decision(previous: Partial<MetadataInput>, next: Partial<MetadataInput> = {}, compatibility: Partial<CompatibilityInput> = {}, wakeResetReason?: string) {
const before = await buildEffectiveRunSessionConfigMetadata({ ...base, ...previous });
const afterInput = { ...base, ...next };
const after = await buildEffectiveRunSessionConfigMetadata(afterInput);
const candidates = aiConnectionSessionCompatibilityInputs({ ...afterInput, originalIssueOverrides: null, binding, router: true, storedIdentity: sessionIdentity, sessionIdentity, credentialIdentity, revisions: [], ...compatibility });
const alternatives = await Promise.all(candidates.map(candidate => buildEffectiveRunSessionConfigMetadata({ ...afterInput, ...candidate })));
return resolveTaskSessionConfigFreshness({ hasTaskSession: true, configuredModel: String(afterInput.effectiveAdapterConfig.model), taskSessionParams: { __paperclipConfiguredModel: "gpt-5.6-sol", __paperclipConfigFingerprint: before.fingerprint, __paperclipConfigFingerprintVersion: before.version, __paperclipConfigCategoryFingerprints: before.categoryFingerprints }, configMetadata: after, compatibleConfigMetadata: alternatives, wakeResetReason });
}
describe("AI pool session adoption", () => {
it.each([binding, { ...binding, mode: "delegated" }, { mode: "responsible_user", provider: "openai", method: "api_key" }])("preserves the exact existing configuration through a compatible binding change: %j", async aiConnection => {
expect((await decision({ agentRuntimeConfig: { aiConnection }, effectiveAdapterConfig: { ...config, managedAiConnection: { ...config.managedAiConnection, mode: aiConnection.mode } } })).reset).toBe(false);
});
it("bridges only account-binding edits in revision history", async () => {
const first = revision("revision-1", null, binding);
const adoption = revision("revision-2", binding, pool);
expect(aiConnectionOnlyConfigRevision(adoption)).toBe(true);
expect((await decision({ agentRuntimeConfig: { aiConnection: binding }, agentConfigRevision: aiConnectionRevisionMetadata(first) }, { agentConfigRevision: aiConnectionRevisionMetadata(adoption) }, { revisions: [adoption, first] })).reset).toBe(false);
});
it("handles consecutive binding edits without erasing another configuration revision", async () => {
const first = revision("revision-1", null, binding);
const adoption = revision("revision-2", binding, { mode: "router", connectionId: "previous-pool" });
const switchPool = revision("revision-3", { mode: "router", connectionId: "previous-pool" }, pool);
expect((await decision({ agentRuntimeConfig: { aiConnection: binding }, agentConfigRevision: aiConnectionRevisionMetadata(first) }, { agentConfigRevision: aiConnectionRevisionMetadata(switchPool) }, { revisions: [switchPool, adoption, first] })).reset).toBe(false);
});
it("retains resets when the same edit also changes unrelated configuration", async () => {
const first = revision("revision-1", null, binding);
const edit = revision("revision-2", binding, pool, { name: "Renamed agent" });
expect(aiConnectionOnlyConfigRevision(edit)).toBe(false);
expect((await decision({ agentRuntimeConfig: { aiConnection: binding }, agentConfigRevision: aiConnectionRevisionMetadata(first) }, { agentConfigRevision: aiConnectionRevisionMetadata(edit) }, { revisions: [edit, first] })).reset).toBe(true);
});
it("does not bridge a stale revision over a newer runtime settings change", async () => {
const first = revision("revision-1", null, binding);
const adoption = revision("revision-2", binding, pool);
expect((await decision({ agentRuntimeConfig: { aiConnection: binding }, agentConfigRevision: aiConnectionRevisionMetadata(first) }, { agentRuntimeConfig: { aiConnection: pool, heartbeat: { enabled: false } }, agentConfigRevision: aiConnectionRevisionMetadata(adoption) }, { revisions: [adoption, first] })).reset).toBe(true);
});
it.each([
{ effectiveAdapterConfig: { ...config, model: "gpt-5.6-terra" } },
{ effectiveAdapterConfig: { ...config, provider: "acpx", acpxAgent: "codex" } },
{ effectiveAdapterConfig: { ...config, env: { ...config.env, OTHER: "changed" } } },
{ agentRuntimeConfig: { aiConnection: pool, heartbeat: { maxConcurrentRuns: 2 } } },
{ workspaceConfig: { effectiveMode: "isolated_workspace" } },
])("preserves normal resets for effective settings changes: %j", async next => {
expect((await decision({ agentRuntimeConfig: { aiConnection: binding } }, next)).reset).toBe(true);
});
it("preserves an explicit session reset even when a compatibility fingerprint matches", async () => {
expect((await decision({ agentRuntimeConfig: { aiConnection: binding } }, {}, {}, "explicit session reset")).reset).toBe(true);
});
it("migrates a legacy token fingerprint only for the unchanged credential at epoch zero", async () => {
const managed = { ...config.managedAiConnection } as Record<string, unknown>; delete managed.sessionIdentity;
expect((await decision({ agentRuntimeConfig: { aiConnection: binding }, effectiveAdapterConfig: { ...config, managedAiConnection: managed } }, {}, { storedIdentity: credentialIdentity })).reset).toBe(false);
});
it("does not treat a changed token or a manual replacement of the same bytes as legacy-compatible", () => {
expect(managedAiSessionIdentityCompatible("grant:alice:old-token", sessionIdentity, credentialIdentity)).toBe(false);
expect(managedAiSessionIdentityCompatible(credentialIdentity, "grant:alice:1", credentialIdentity)).toBe(false);
expect(managedAiSessionIdentityCompatible(sessionIdentity, "grant:alice:1", credentialIdentity)).toBe(false);
expect(managedAiSessionIdentityCompatible(sessionIdentity, sessionIdentity, "grant:alice:refreshed-token")).toBe(true);
});
it("rejects compatibility candidates for a different account or responsible user", () => {
const input = { ...base, originalIssueOverrides: null, binding, router: true, sessionIdentity, credentialIdentity, revisions: [] };
expect(aiConnectionSessionCompatibilityInputs({ ...input, storedIdentity: "other-grant:alice:0" })).toEqual([]);
expect(aiConnectionSessionCompatibilityInputs({ ...input, storedIdentity: "grant:bob:0" })).toEqual([]);
});
});
+307 -26
View File
@@ -1,25 +1,33 @@
import { connectionIntentService } from "../services/connection-intents.js";
import { connectionIntentDeliveryService } from "../services/connection-intent-delivery.js";
import { issueRecoveryActionService } from "../services/issue-recovery-actions.js";
import { localAiLoginService } from "../services/local-ai-login.js";
import * as localCredentials from "../services/local-ai-credentials.js";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { mkdtemp, rm, access, readFile, writeFile } from "node:fs/promises";
import { createHash, randomUUID } from "node:crypto";
import { spawnSync } from "node:child_process";
import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js";
import { mkdtemp, realpath, rm, access, readFile, writeFile, stat } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { and, eq, sql } from "drizzle-orm";
import { createDb, companies, agents, heartbeatRuns, companyMemberships, connectionGrants, connectionGrantDelegations, connectionGrantMembers, toolConnections, toolConnectionInstalls, aiConnectionDefaults, aiProviderDefaults, adapterAuthSessions, environments, issues, issueThreadInteractions, issueRecoveryActions, connectionIntentDeliveries, agentWakeupRequests, companySecrets, principalPermissionGrants } from "@paperclipai/db";
import { createDb, companies, agents, agentTaskSessions, heartbeatRuns, companyMemberships, connectionGrants, toolApplications, connectionGrantDelegations, connectionGrantMembers, toolConnections, toolConnectionInstalls, aiConnectionDefaults, aiProviderDefaults, adapterAuthSessions, environments, issues, issueThreadInteractions, issueRecoveryActions, connectionIntentDeliveries, agentWakeupRequests, companySecrets, principalPermissionGrants } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres";
import { aiConnectionService } from "../services/ai-connections.js";
import * as executionTarget from "@paperclipai/adapter-utils/execution-target";
import { prepareManagedAiRuntime, assertManagedAiProjectAuth } from "../services/ai-connection-runtime.js";
import { execute as executeGemini, testEnvironment as testGeminiEnvironment } from "@paperclipai/adapter-gemini-local/server";
import { toolAccessService } from "../services/tool-access.js";
import { secretService } from "../services/secrets.js";
import { resolveExecutionRunAdapterConfig } from "../services/heartbeat.js";
import { aiConnectionBindingSchema, connectionPurposeTransportSchema, isAiConnectionCompatible } from "@paperclipai/shared";
import express from "express";
import request from "supertest";
import { aiConnectionRoutes, canInstallSharedAiConnectionForNewAgent, responsibleUserForAiRequest } from "../routes/ai-connections.js";
import { validateAiApiKey } from "../routes/ai-connections.js";
vi.mock("../services/local-ai-browser-login.js", () => ({
startLocalBrowserLogin: () => ({ authorizationUrl: "https://auth.openai.com/codex/device", code: "ABCD-EFGHJ", abort: () => {} }),
}));
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
@@ -33,7 +41,7 @@ const input = { companyId, agentId, adapterType: "claude_local", binding };
const create = (userId: string, name: string, ownership: "personal" | "shared" = "personal") => service.save(companyId, userId, { provider: "anthropic", method: "api_key", ownership, name, apiKey: "fixture", agentIds: [], allAgents: true }, `fixture-${name}`);
beforeAll(async () => {
home = await mkdtemp(path.join(os.tmpdir(), "paperclip-ai-tests-"));
home = await realpath(await mkdtemp(path.join(os.tmpdir(), "paperclip-ai-tests-")));
vi.stubEnv("PAPERCLIP_HOME", home);
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "ai-connection-fixture");
database = await startEmbeddedPostgresTestDatabase("paperclip-ai-db-");
@@ -46,6 +54,134 @@ beforeAll(async () => {
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); if (home) await rm(home, { recursive: true, force: true }); });
describe("managed AI connections", () => {
it.each([
["openai", "codex_local", "responses"],
["anthropic", "claude_local", "messages"],
["openai", "opencode_local", "chat"],
] as const)("prepares a no-auth %s endpoint without a vault secret", async (provider, adapterType, protocol) => {
const account = await service.save(companyId, "alice", {
provider, method: "api_key", ownership: "personal", name: `No-auth ${adapterType}`,
agentIds: [], allAgents: true,
routing: { kind: "local", protocol, baseUrl: "http://127.0.0.1:9000/v1", auth: "none", models: [] },
}, "");
const binding = { provider, method: "api_key", mode: "connection", connectionId: account.connectionId, grantId: account.grantId } as const;
const config = { model: "fixture-model", env: { OPENAI_API_KEY: "host-key", ANTHROPIC_API_KEY: "host-key" } };
const first = await prepareManagedAiRuntime(db, { ...input, adapterType, responsibleUserId: "alice", binding, config });
const second = await prepareManagedAiRuntime(db, { ...input, adapterType, responsibleUserId: "alice", binding, config });
try {
expect(first.sessionIdentity).toBe(second.sessionIdentity);
expect(first.sessionIdentity).toContain("no-auth");
expect(JSON.stringify(first.config)).not.toContain("host-key");
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, account.grantId));
expect(grant.credentialSecretRefs).toEqual([]);
} finally { await Promise.all([first.cleanup(), second.cleanup()]); }
});
it("never restores retained Grok transcripts into same-user runtime homes", async () => {
const account = await service.save(companyId, "alice", {
provider: "xai", method: "api_key", ownership: "personal",
name: "Grok history isolation", agentIds: [], allAgents: true,
}, "fixture-grok-key");
const taskKey = `grok-history-${randomUUID()}`;
const runInput = { ...input, taskKey, adapterType: "grok_local", responsibleUserId: "alice", config: {},
binding: { provider: "xai", method: "api_key", mode: "connection", connectionId: account.connectionId, grantId: account.grantId } as const };
const first = await prepareManagedAiRuntime(db, runInput);
const scope = createHash("sha256").update(JSON.stringify([
companyId, agentId, taskKey, first.sessionIdentity,
])).digest("hex");
await first.cleanup();
// Seed a valid archive from the earlier implementation. Runtime preparation
// must not decrypt it onto the host, even for its authorized agent/task.
const retained = await localEncryptedProvider.createVersion({ value: JSON.stringify({
scope, entries: [{ name: "history.json", bytes: Buffer.from("previous-task-private-transcript").toString("base64") }],
}) });
await db.insert(agentTaskSessions).values({ companyId, agentId, adapterType: "grok_local", taskKey,
sessionParamsJson: { sessionId: "history", paperclipGrokHistory: { scope, material: retained.material } } });
const resumed = await prepareManagedAiRuntime(db, runInput);
const peer = await prepareManagedAiRuntime(db, { ...runInput, agentId: randomUUID() });
try {
expect(resumed.home).not.toBe(first.home);
expect(peer.home).not.toBe(resumed.home);
const transcript = path.join(String(resumed.config.env.GROK_HOME), "sessions", "history.json");
// A separate same-UID process can bypass 0700. It must find no restored
// transcript because none was materialized, not because of permissions.
const crossAgentRead = spawnSync(process.execPath, ["-e", `
const fs = require("node:fs");
try { process.stdout.write(fs.readFileSync(process.argv[1], "utf8")); }
catch (error) { process.stdout.write(error.code); }
`, transcript], { encoding: "utf8" });
expect(crossAgentRead.status).toBe(0);
expect(crossAgentRead.stdout).toBe("ENOENT");
await expect(access(transcript)).rejects.toMatchObject({ code: "ENOENT" });
} finally { await Promise.all([resumed.cleanup(), peer.cleanup()]); }
});
it("authenticates local Gemini probes and runs with the saved key in an isolated home", async () => {
const root = await mkdtemp(path.join(home, "gemini-auth-"));
const command = path.join(root, "gemini");
await writeFile(command, `#!/usr/bin/env node
const fs = require("node:fs");
const path = require("node:path");
const settings = JSON.parse(fs.readFileSync(path.join(process.env.HOME, ".gemini/settings.json"), "utf8"));
if (settings.selectedAuthType !== "gemini-api-key" || settings.security?.auth?.selectedType !== "gemini-api-key" || process.env.GEMINI_API_KEY !== "saved-google-key") {
console.error("Invalid auth method selected");
process.exit(1);
}
console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-managed" }));
console.log(JSON.stringify({ type: "assistant", message: { content: [{ type: "output_text", text: "hello" }] } }));
console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello", session_id: "gemini-managed" }));
`, { mode: 0o700 });
const saved = await service.save(companyId, "alice", {
provider: "google", method: "api_key", ownership: "personal", name: "Saved Google",
apiKey: "fixture", allAgents: true, agentIds: [],
}, "saved-google-key");
const runtime = await prepareManagedAiRuntime(db, {
companyId, agentId, responsibleUserId: "alice", adapterType: "gemini_local",
binding: { provider: "google", method: "api_key", mode: "responsible_user" },
config: { engine: "cli", command, cwd: root, promptTemplate: "Say hello.", paperclipRuntimeSkills: [], env: { GEMINI_API_KEY: "ambient-google-key" } },
});
try {
expect(runtime.attribution.grantId).toBe(saved.grantId);
expect(runtime.home).not.toBe(os.homedir());
const settingsFile = path.join(runtime.home!, ".gemini/settings.json");
expect(await readFile(settingsFile, "utf8")).not.toContain("saved-google-key");
expect((await stat(settingsFile)).mode & 0o777).toBe(0o600);
const probe = await testGeminiEnvironment({ companyId, adapterType: "gemini_local", config: runtime.config });
expect(probe.status).toBe("pass");
expect(probe.checks).toContainEqual(expect.objectContaining({ code: "gemini_hello_probe_passed" }));
const result = await executeGemini({
runId: randomUUID(),
agent: { id: agentId, companyId, name: "Gemini", adapterType: "gemini_local", adapterConfig: { engine: "cli" } },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: runtime.config, context: {}, onLog: async () => {},
});
expect(result.exitCode).toBe(0);
expect(result.sessionId).toBe("gemini-managed");
} finally {
await runtime.cleanup();
await rm(root, { recursive: true, force: true });
}
await expect(access(runtime.home!)).rejects.toThrow();
});
it("preserves Google account defaults when the provider constraint migration is reapplied", async () => {
const saved = await service.save(companyId, "bob", {
provider: "google", method: "api_key", ownership: "personal", name: "Google migration",
apiKey: "fixture", allAgents: true, agentIds: [],
}, "google-migration-key");
const migration = await readFile(new URL("../../../packages/db/src/migrations/0306_familiar_titania.sql", import.meta.url), "utf8");
for (let attempt = 0; attempt < 2; attempt += 1) {
await db.transaction(async (tx) => {
for (const statement of migration.split("--> statement-breakpoint")) {
if (statement.trim()) await tx.execute(sql.raw(statement));
}
});
}
const selected = await service.select({
companyId, agentId, userId: "bob", adapterType: "gemini_local",
binding: { provider: "google", method: "api_key", mode: "responsible_user" },
});
expect(selected.grant.id).toBe(saved.grantId);
});
it.each([false, true])("reports the authoritative connection-manager capability for custom grants (manager: %s)", async (manager) => {
const userId = `custom-manager-${manager}`;
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "member" });
@@ -120,6 +256,61 @@ describe("managed AI connections", () => {
} finally { fetchSpy.mockRestore(); }
});
it("vaults routed credentials, enforces compatibility and access, and configures both Codex runners", async () => {
const routing = { kind: "openrouter", protocol: "responses", auth: "bearer", models: [{ id: "openai/gpt-5.4" }] } as const;
const saved = await service.save(companyId, "alice", { provider: "openrouter", method: "api_key", name: "Routed test", ownership: "personal", apiKey: "fixture", allAgents: true, agentIds: [], routing: { ...routing, models: [...routing.models] } }, "fixture-routed-credential");
const selected = { provider: "openrouter", method: "api_key", mode: "delegated", ...saved } as const;
expect(JSON.stringify(await service.list(companyId, "alice"))).not.toContain("fixture-routed-credential");
expect(await service.list(companyId, "alice")).toEqual(expect.arrayContaining([expect.objectContaining({ id: saved.connectionId, routing, isDefault: false })]));
await expect(service.setDefault(companyId, "alice", saved.grantId)).rejects.toThrow("explicit connection");
await expect(service.select({ ...input, binding: selected, userId: "bob", adapterType: "codex_local", model: "openai/gpt-5.4" })).rejects.toThrow("not shared");
await expect(service.select({ ...input, companyId: otherCompanyId, binding: selected, userId: "alice", adapterType: "codex_local" })).rejects.toThrow();
for (const adapterType of ["codex_local", "paperclip_runner"]) {
const runtime = await prepareManagedAiRuntime(db, { companyId, agentId, responsibleUserId: "alice", binding: selected, adapterType, config: { provider: "codex", model: "openai/gpt-5.4", env: { OPENAI_API_KEY: "ambient" } } });
try {
expect(runtime.config.env.OPENAI_API_KEY).toBe("");
expect(runtime.config.env.PAPERCLIP_AI_PROVIDER_KEY).toBe("fixture-routed-credential");
const toml = await readFile(path.join(String(runtime.config.env.CODEX_HOME), "config.toml"), "utf8");
expect(toml).toContain('base_url = "https://openrouter.ai/api/v1"');
expect(toml).toContain('wire_api = "responses"');
expect(toml).not.toContain("fixture-routed-credential");
} finally { await runtime.cleanup(); }
}
await expect(service.save(companyId, "alice", { provider: "openrouter", method: "api_key", name: "Routed test", ownership: "personal", apiKey: "fixture", connectionId: saved.connectionId, allAgents: true, agentIds: [], routing: { ...routing, kind: "gateway", baseUrl: "https://other.example/v1", models: [] } }, "fixture-replacement")).rejects.toThrow("retain");
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, saved.grantId));
await expect(service.select({ ...input, binding: selected, userId: "alice", adapterType: "codex_local", model: "openai/gpt-5.4" })).rejects.toThrow("Reconnect");
});
it("reconnects JSONB routing without changing its identity or access", async () => {
const routing = { kind: "gateway", protocol: "responses", auth: "bearer", baseUrl: "https://gateway.example/v1", models: [{ id: "gateway-model" }] } as const;
const input = { provider: "openai", method: "api_key", name: "Reconnect gateway", ownership: "personal", allAgents: false, agentIds: [agentId], routing: { ...routing, models: [...routing.models] } } as const;
const saved = await service.save(companyId, "alice", { ...input, agentIds: [...input.agentIds] }, "old-gateway-credential");
const [stored] = await db.select().from(toolConnections).where(eq(toolConnections.id, saved.connectionId));
expect(stored!.config.ai).toMatchObject({ routing });
expect(stored!.config.sourceTemplateKey).toBe("responses-api");
const [application] = await db.select().from(toolApplications).where(eq(toolApplications.id, stored!.applicationId));
expect(application).toMatchObject({ applicationKey: "app-gallery:responses-api", metadata: { sourceTemplateKey: "responses-api" } });
// PostgreSQL JSONB reorders object keys; compare values, not serialization.
const reordered = { models: [...routing.models], auth: routing.auth, baseUrl: routing.baseUrl, protocol: routing.protocol, kind: routing.kind };
expect(await service.save(companyId, "alice", { ...input, agentIds: [], allAgents: true, connectionId: saved.connectionId, routing: reordered }, "new-gateway-credential")).toEqual(saved);
const selection = { companyId, agentId, userId: "alice", adapterType: "codex_local", binding: { provider: "openai", method: "api_key", mode: "delegated", ...saved } } as const;
const selected = await service.select(selection);
expect(await service.credential(selected)).toBe("new-gateway-credential");
const installs = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, saved.connectionId));
expect(installs).toEqual([expect.objectContaining({ targetType: "agent", targetId: agentId })]);
await expect(service.save(companyId, "alice", { ...input, agentIds: [agentId], connectionId: saved.connectionId, routing: { ...reordered, baseUrl: "https://different.example/v1" } }, "rejected-credential")).rejects.toThrow("retain");
expect(await service.credential(await service.select(selection))).toBe("new-gateway-credential");
});
it("saves no-auth endpoints without a secret and refuses protocol mismatches", async () => {
const routing = { kind: "local", protocol: "chat", auth: "none", baseUrl: "http://localhost:11434/v1", models: [] } as const;
const saved = await service.save(companyId, "alice", { provider: "openai", method: "api_key", name: "Local", ownership: "personal", allAgents: true, agentIds: [], routing: { ...routing, models: [] } }, "");
const selected = { provider: "openai", method: "api_key", mode: "delegated", ...saved } as const;
await expect(service.select({ ...input, binding: selected, userId: "alice", adapterType: "codex_local" })).rejects.toThrow("incompatible");
const row = await service.select({ ...input, binding: selected, userId: "alice", adapterType: "opencode_local", model: "qwen" });
expect(row.grant.credentialSecretRefs).toEqual([]);
expect(await service.credential(row)).toBe("");
});
it.each([
["anthropic", false], ["openai", false], ["anthropic", true], ["openai", true],
] as const)("turns a %s auth failure into one card and resumes after repair (switch method: %s)", async (provider, switchMethod) => {
@@ -188,6 +379,73 @@ describe("managed AI connections", () => {
await db.insert(heartbeatRuns).values({ companyId, agentId: id, status: "succeeded", responsibleUserId: "alice", contextSnapshot: { issueId }, createdAt: new Date(Date.now() + 1000) });
expect(await intents.requestForRunAuthFailure(runId)).toBeNull();
});
it("repairs a teammate's missing onboarding key with their own inline AI connection", async () => {
const userId = `new-teammate-${randomUUID()}`;
const id = randomUUID();
const issueId = randomUUID();
const runId = randomUUID();
const vault = secretService(db);
const definition = await vault.createUserSecretDefinition(companyId, {
key: `ANTHROPIC_API_KEY.${randomUUID()}`, name: "ANTHROPIC_API_KEY for onboarding",
}, { userId: "alice" });
const ownerSecret = await vault.createCurrentUserSecretValue(companyId, "alice", {
definitionId: definition.id, value: "fixture-original-owner-key",
}, { userId: "alice" });
const adapterConfig = { env: { ANTHROPIC_API_KEY: {
type: "user_secret_ref", key: definition.key, version: "latest", required: true,
} } };
await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" });
const [agent] = await db.insert(agents).values({ id, companyId, name: "Chief of Staff", adapterType: "claude_local", adapterConfig }).returning();
await vault.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: id }, adapterConfig.env);
await db.insert(issues).values({ id: issueId, companyId, title: "Chat with Chief of Staff", status: "blocked", assigneeAgentId: id, responsibleUserId: userId });
const failure = await resolveExecutionRunAdapterConfig({
companyId, agentId: id, adapterType: "claude_local", issueId,
responsibleUserId: userId, executionRunConfig: adapterConfig, projectEnv: null, secretsSvc: vault,
}).catch(error => error);
expect(failure).toMatchObject({ code: "configuration_incomplete", resultJson: {
configurationIncomplete: { reason: "secret_binding_missing", missingBindings: [
expect.objectContaining({ bindingType: "user_secret_ref", envKey: "ANTHROPIC_API_KEY", errorCode: "user_secret_missing", responsibleUserId: userId }),
] },
} });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId: id, status: "failed",
errorCode: failure.code, resultJson: failure.resultJson, responsibleUserId: userId, contextSnapshot: { issueId } });
await issueRecoveryActionService(db).upsertSourceScoped({ companyId, sourceIssueId: issueId,
kind: "configuration_validation", cause: "configuration_incomplete", fingerprint: `missing-key:${issueId}`,
nextAction: "Connect your AI account", ownerType: "board", evidence: { latestRunId: runId } });
const intents = connectionIntentService(db);
const card = await intents.requestForRunAuthFailure(runId);
expect(card).toMatchObject({ service: "anthropic", state: "needs_user_action" });
expect((await intents.setupOptions(card!.interactionId!))).toMatchObject({
aiConnectionRequiresAdoption: true, aiConnection: { provider: "anthropic", mode: "responsible_user" },
interaction: { addresseeUserId: userId, sourceRunId: runId },
});
expect((await intents.requestForRunAuthFailure(runId))?.interactionId).toBe(card!.interactionId);
expect((await db.select().from(agents).where(eq(agents.id, id)))[0].runtimeConfig.aiConnection).toBeUndefined();
const account = await create(userId, "Teammate's Claude");
const setup = await intents.setupOptions(card!.interactionId!);
await intents.complete(card!.interactionId!, account.connectionId, userId, {
validatedAdoption: { agentUpdatedAt: agent.updatedAt, binding: setup.aiConnection! },
});
const wakeup = vi.fn(async (_agentId, opts) => {
await db.insert(agentWakeupRequests).values({ companyId, agentId: id, source: "automation", status: "queued", idempotencyKey: opts.idempotencyKey });
return null;
});
const delivery = connectionIntentDeliveryService(db, { wakeup } as never);
await delivery.deliver(card!.interactionId!);
await delivery.deliver(card!.interactionId!);
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0].status).toBe("in_progress");
expect(wakeup).toHaveBeenCalledTimes(1);
expect(wakeup).toHaveBeenCalledWith(id, expect.objectContaining({ contextSnapshot: expect.objectContaining({ forceFreshSession: true }) }));
expect(await issueRecoveryActionService(db).getActiveForIssue(companyId, issueId)).toBeNull();
expect((await db.select().from(companySecrets).where(eq(companySecrets.id, ownerSecret.id)))[0]).toMatchObject({ ownerUserId: "alice", latestVersion: 1, status: "active" });
const runtime = await prepareManagedAiRuntime(db, { companyId, agentId: id, responsibleUserId: userId, adapterType: "claude_local", binding: setup.aiConnection!, config: adapterConfig });
try {
expect(runtime.config.env).toMatchObject({ ANTHROPIC_API_KEY: "fixture-Teammate's Claude" });
expect(runtime.attribution.grantId).toBe(account.grantId);
} finally { await runtime.cleanup(); }
}, 30000);
it.each([
["anthropic", "claude_local", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
["openai", "codex_local", "CODEX_HOME", "OPENAI_API_KEY"],
@@ -644,7 +902,22 @@ describe("managed AI connections", () => {
expect(network).not.toHaveBeenCalled();
} finally { network.mockRestore(); }
});
it("imports only for the local operator and preserves identity and permissions on reconnect", async () => {
it.each(["anthropic", "openai"] as const)("reports a missing %s browser process after a server restart", async provider => {
const [environment] = await db.select().from(environments).where(eq(environments.driver, "local")).limit(1);
const id = randomUUID();
const owner = `restart-${provider}`;
const intent = { provider, method: "subscription", ownership: "personal", name: "Interrupted sign-in", allAgents: false, agentIds: [] } as const;
await db.insert(adapterAuthSessions).values({ id, publicSessionId: id, companyId, environmentId: environment.id, startedByUserId: owner, adapterType: provider === "anthropic" ? "claude_local" : "codex_local", aiConnection: { ...intent, agentIds: [] }, connectionMethod: "local_subscription", status: "waiting_for_user", expiresAt: new Date(Date.now() + 60_000) });
const reader = vi.spyOn(localCredentials, "readVerifiedLocalAiCredential").mockRejectedValue(new Error("No credential yet"));
try {
const login = localAiLoginService(db);
await expect(login.check(companyId, owner, { ...intent, agentIds: [] }, id)).resolves.toEqual({ status: "sign_in_required", error: "The server restarted during sign-in. Start sign-in again." });
await expect(login.check(companyId, "bob", { ...intent, agentIds: [] }, id)).rejects.toThrow("not found");
reader.mockResolvedValue("completed-before-restart");
await expect(login.check(companyId, owner, { ...intent, agentIds: [] }, id)).resolves.toEqual({ status: "ready" });
} finally { reader.mockRestore(); await db.delete(adapterAuthSessions).where(eq(adapterAuthSessions.id, id)); }
});
it("requires an owned browser sign-in attempt for local subscriptions", async () => {
const reader = vi.spyOn(localCredentials, "readVerifiedLocalAiCredential").mockResolvedValue("fixture-local-token");
const app = express();
app.use(express.json());
@@ -662,30 +935,18 @@ describe("managed AI connections", () => {
expect((await request(app).post(`${url}/check`).send(payload)).status).toBe(403);
expect(reader).not.toHaveBeenCalled();
const checked = await request(app).post(`${url}/check`).set("x-local", "yes").send(payload);
expect(checked.status).toBe(200);
expect(checked.body).toEqual({ status: "ready" });
expect(checked.status).toBe(422);
expect((await service.list(companyId, "alice")).some(c => c.name === payload.name)).toBe(false);
const connected = await request(app).post(url).set("x-local", "yes").send(payload);
expect(connected.status).toBe(201);
expect(JSON.stringify(connected.body)).not.toContain("fixture-local-token");
const before = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connected.body.connectionId));
expect(before.map(i => [i.targetType, i.targetId])).toEqual([["agent", agentId]]);
const reconnected = await request(app).post(url).set("x-local", "yes").send({ ...payload, connectionId: connected.body.connectionId, allAgents: true });
expect(reconnected.status).toBe(201);
expect(reconnected.body).toEqual(connected.body);
const after = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connected.body.connectionId));
expect(after).toEqual(before);
reader.mockRejectedValueOnce(Object.assign(new Error("Sign in locally and retry"), { status: 422 }));
const failed = await request(app).post(url).set("x-local", "yes").send({ ...payload, name: "Unsuccessful local login" });
expect(failed.status).toBe(422);
expect((await service.list(companyId, "alice")).some(c => c.name === "Unsuccessful local login")).toBe(false);
expect(connected.status).toBe(422);
expect(reader).not.toHaveBeenCalled();
const codex = { ...payload, provider: "openai", name: "Isolated terminal login" };
const attempts = `${url}/attempts`;
expect((await request(app).post(attempts).send(codex)).status).toBe(403); // This member cannot authorize agentId.
expect((await request(app).post(url).set("x-local", "yes").send(codex)).status).toBe(422);
const prepared = await request(app).post(attempts).set("x-local", "yes").send(codex);
expect(prepared.status).toBe(201);
expect(prepared.body.command).toMatch(/^\(export CODEX_HOME=.* && mkdir -p .* && codex -c .* login --device-auth\)$/);
expect(prepared.body.command).toBeUndefined();
expect((await request(app).post(attempts).set("x-local", "yes").send(codex)).body).toEqual(prepared.body);
expect((await request(app).delete(`${attempts}/${prepared.body.sessionId}`).set("x-test-user", "bob").send()).status).toBe(404);
expect((await request(app).delete(`${attempts}/${prepared.body.sessionId}`).set("x-local", "yes").send()).status).toBe(200);
@@ -732,13 +993,18 @@ describe("managed AI connections", () => {
const started = await request(app).post(`${base}/attempts`).send(intent);
expect(started.status).toBe(201);
expect(started.headers["cache-control"]).toBe("no-store");
expect(started.body.command).toContain(provider === "anthropic" ? "CLAUDE_CONFIG_DIR=" : "login --device-auth");
expect(started.body.command).toBeUndefined();
expect((await request(app).post(`${base}/attempts`).send(intent)).body).toEqual(started.body);
const input = { ...intent, localSessionId: started.body.sessionId };
for (const endpoint of [base, `${base}/check`]) {
expect((await request(app).post(endpoint).set("x-test-user", "bob").send(input)).status).toBe(404);
expect((await request(app).post(endpoint.replace(companyId, otherCompanyId)).send(input)).status).toBe(403);
}
if (provider === "anthropic") {
const codeUrl = `${base}/attempts/${started.body.sessionId}/code`;
expect((await request(app).post(codeUrl).set("x-test-user", "bob").send({ browserCode: "fixture-code" })).status).toBe(404);
expect((await request(app).post(codeUrl).send({ browserCode: "fixture-code" })).status).toBe(422);
}
expect(reader).not.toHaveBeenCalled();
const checked = await request(app).post(`${base}/check`).send(input);
expect(checked.body).toEqual({ status: "ready" });
@@ -824,6 +1090,13 @@ describe("managed AI connections", () => {
await db.insert(agents).values({ id, companyId, name: "Runner adoption", adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" } });
const account = await service.save(companyId, "alice", { provider: "openai", method: "api_key", ownership: "personal", name: "Runner adoption account", apiKey: "fixture-adoption-key", agentIds: inline ? [] : [id], allAgents: false }, "fixture-adoption-key");
await service.setDefault(companyId, "alice", account.grantId);
const tryDeliver = vi.fn(async (interactionId: string) => {
// Dispatch observes committed adoption, never a partially saved choice.
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId)))[0].status).toBe("accepted");
expect((await db.select().from(agents).where(eq(agents.id, id)))[0].runtimeConfig.aiConnection).toMatchObject({ mode: "responsible_user" });
});
const deliveryModule = await import("../services/connection-intent-delivery.js");
const delivery = vi.spyOn(deliveryModule, "connectionIntentDeliveryService").mockReturnValue({ tryDeliver } as never);
let interactionId: string | undefined;
if (inline) {
const issueId = randomUUID();
@@ -854,6 +1127,7 @@ describe("managed AI connections", () => {
resolveTarget.mockResolvedValueOnce(null);
const unavailable = await adopt();
expect(unavailable.status, JSON.stringify(unavailable.body)).toBe(422);
expect(tryDeliver).not.toHaveBeenCalled();
expect(probe).not.toHaveBeenCalled();
expect(providerRequest).not.toHaveBeenCalled();
expect((await db.select().from(agents).where(eq(agents.id, id)))[0].runtimeConfig.aiConnection).toBeUndefined();
@@ -870,6 +1144,7 @@ describe("managed AI connections", () => {
try {
const failed = await adopt();
expect(failed.status).toBe(500);
expect(tryDeliver).not.toHaveBeenCalled();
await assertUnchanged();
} finally {
await db.execute(sql.raw("DROP TRIGGER reject_atomic_adoption ON issue_thread_interactions"));
@@ -885,6 +1160,8 @@ describe("managed AI connections", () => {
expect(saved.body.status).toBe("accepted");
expect(await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, account.connectionId))).toHaveLength(1);
expect((await adopt()).status).toBe(200);
expect(tryDeliver).toHaveBeenCalledTimes(2);
expect(tryDeliver).toHaveBeenCalledWith(interactionId);
}
expect(acquire).toHaveBeenCalledWith(expect.objectContaining({ companyId, environment: expect.objectContaining({ id: environment.id }) }));
expect(probe).toHaveBeenCalledWith(expect.objectContaining({ executionTarget: target, config: expect.objectContaining({ provider: "codex", model: "gpt-5.6-sol", managedAiConnection: expect.any(Object) }) }));
@@ -895,7 +1172,7 @@ describe("managed AI connections", () => {
expect(release).toHaveBeenCalledTimes(inline ? 3 : 2);
expect(JSON.stringify(saved.body)).not.toContain("fixture-adoption-key");
} finally {
providerRequest.mockRestore(); probe.mockRestore(); runtime.mockRestore(); resolveTarget.mockRestore();
delivery.mockRestore(); providerRequest.mockRestore(); probe.mockRestore(); runtime.mockRestore(); resolveTarget.mockRestore();
await settings.update({ defaultEnvironmentId: previous.defaultEnvironmentId });
}
});
@@ -943,8 +1220,10 @@ describe("managed AI connections", () => {
describe("AI connection recovery delivery", () => {
it.each(["restored", "newer failure", "different blocker", "revoked again", "closed task"])(
"continues only the repaired source failure: %s", async (scenario) => {
it.each(["ai_connection_unavailable", "secret_binding_missing"].flatMap(reason =>
["restored", "newer failure", "different blocker", "revoked again", "closed task"].map(scenario => [reason, scenario]),
))(
"continues only the repaired %s source failure: %s", async (reason, scenario) => {
const userId = `recovery-${randomUUID()}`;
const recoveringAgentId = randomUUID();
const issueId = randomUUID();
@@ -957,7 +1236,9 @@ describe("AI connection recovery delivery", () => {
const pending = await intents.request({ sub: recoveringAgentId, company_id: companyId, run_id: failedRunId, responsible_user_id: userId }, "anthropic", { purpose: "ai" });
const account = await create(userId, `Recovered ${scenario}`);
expect((await intents.setupOptions(pending.interactionId!)).existingConnections.map(connection => connection.id)).toEqual([account.connectionId]);
await db.update(heartbeatRuns).set({ status: "failed", errorCode: "configuration_incomplete", resultJson: { configurationIncomplete: { reason: "ai_connection_unavailable" } } }).where(eq(heartbeatRuns.id, failedRunId));
await db.update(heartbeatRuns).set({ status: "failed", errorCode: "configuration_incomplete", resultJson: { configurationIncomplete: { reason,
...(reason === "secret_binding_missing" ? { missingBindings: [{ bindingType: "user_secret_ref", envKey: "ANTHROPIC_API_KEY", configPath: "env.ANTHROPIC_API_KEY", errorCode: "user_secret_missing" }] } : {}),
} } }).where(eq(heartbeatRuns.id, failedRunId));
await db.update(issues).set({ status: "blocked" }).where(eq(issues.id, issueId));
await issueRecoveryActionService(db).upsertSourceScoped({ companyId, sourceIssueId: issueId, kind: "configuration_validation", cause: "configuration_incomplete", fingerprint: `ai:${issueId}`, nextAction: "Reconnect", ownerType: "board", evidence: { latestRunId: failedRunId } });
await intents.complete(pending.interactionId!, account.connectionId, userId);
@@ -4,15 +4,25 @@ import { mkdtemp, readFile, writeFile, mkdir, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { eq, sql } from "drizzle-orm";
import { createDb, companies, agents, companyMemberships, adapterAuthSessions, environments, connectionGrants, toolConnections, activityLog } from "@paperclipai/db";
import { createDb, companies, agents, companyMemberships, adapterAuthSessions, environments, connectionGrants, toolConnections, activityLog, companySecrets } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres";
import { secretService } from "../services/secrets.js";
import { aiConnectionService } from "../services/ai-connections.js";
import { prepareManagedAiRuntime } from "../services/ai-connection-runtime.js";
import { prepareManagedAiRuntime, managedAiSessionFingerprintConfig } from "../services/ai-connection-runtime.js";
import { localAiLoginService } from "../services/local-ai-login.js";
import { readVerifiedLocalAiCredential } from "../services/local-ai-credentials.js";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
const browserLogin = vi.hoisted(() => ({ submitCode: vi.fn(), abort: vi.fn() }));
vi.mock("../services/local-ai-browser-login.js", () => ({
startLocalBrowserLogin: () => ({
authorizationUrl: "https://auth.openai.com/codex/device",
code: "TEST-CODE",
submitCode: browserLogin.submitCode,
abort: browserLogin.abort,
}),
}));
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let home: string;
@@ -39,12 +49,25 @@ it("reconnect detaches an indexed credential without changing unadopted legacy a
const migration = await readFile(new URL("../../../packages/db/src/migrations/0276_hard_mandroid.sql", import.meta.url), "utf8");
await db.execute(sql.raw(migration.slice(migration.indexOf("DO $$", migration.indexOf("-- Only declared")))));
const connection = (await service.list(companyId, owner)).find(c => c.name === secret.name)!;
const input = { companyId, agentId, responsibleUserId: owner, adapterType: "claude_local", binding: { provider: "anthropic", method: "api_key", mode: "delegated", connectionId: connection.id, grantId: connection.grantId } as const, config: {}, allowLegacyValidation: true };
const before = await prepareManagedAiRuntime(db, input);
await before.cleanup();
const resolve = () => vault.resolveUserSecretValue(companyId, { definitionId: definition.id, responsibleUserId: owner, required: true, version: "latest" }, { companyId, responsibleUserId: owner, actorType: "system" });
expect((await resolve())?.value).toBe("fixture-original");
await service.save(companyId, owner, { provider: "anthropic", method: "api_key", name: connection.name, ownership: "personal", agentIds: [], allAgents: true, connectionId: connection.id, apiKey: "fixture-new-account" }, "fixture-new-account");
expect((await resolve())?.value).toBe("fixture-original");
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, connection.grantId));
expect(grant.credentialSecretRefs[0].secretId).not.toBe(secret.id);
const [oldSecret] = await db.select().from(companySecrets).where(eq(companySecrets.id, secret.id));
const [newSecret] = await db.select().from(companySecrets).where(eq(companySecrets.id, grant.credentialSecretRefs[0].secretId));
expect(oldSecret.aiSessionEpoch).toBe(0);
expect(newSecret.aiSessionEpoch).toBe(0);
const after = await prepareManagedAiRuntime(db, input);
try {
expect(after.attribution.grantId).toBe(before.attribution.grantId);
expect(after.sessionIdentity).not.toBe(before.sessionIdentity);
expect(managedAiSessionFingerprintConfig(after.config, after.home)).not.toEqual(managedAiSessionFingerprintConfig(before.config, before.home));
} finally { await after.cleanup(); }
const selected = await service.select({ companyId, agentId, userId: owner, adapterType: "claude_local", binding: { provider: "anthropic", method: "api_key", mode: "responsible_user" } });
expect(await service.credential(selected)).toBe("fixture-new-account");
await service.save(companyId, owner, { provider: "anthropic", method: "api_key", name: connection.name, ownership: "personal", agentIds: [], allAgents: true, connectionId: connection.id, apiKey: "fixture-second" }, "fixture-second");
@@ -59,6 +82,35 @@ const loginIntent = () => ({ ...intent, agentIds: [] });
const auth = (mark: string, hour = 10) => JSON.stringify({ tokens: { account_id: "fixture-account", id_token: `id-${mark}`, access_token: `access-${mark}`, refresh_token: `refresh-${mark}` }, last_refresh: `2026-09-10T${hour}:00:00Z` });
const directoryFor = (id: string) => path.join(resolvePaperclipInstanceRoot(), "ai-local-logins", id);
it("accepts a Claude code once, only from its owner, and audits without the code", async () => {
const login = localAiLoginService(db);
const intent = { ...loginIntent(), provider: "anthropic" as const };
const attempt = await login.start(companyId, owner, intent);
browserLogin.submitCode.mockClear();
await expect(login.submitCode(companyId, "another-owner", attempt.sessionId, "fixture-secret-code")).rejects.toThrow("not found");
await expect(login.submitCode(randomUUID(), owner, attempt.sessionId, "fixture-secret-code")).rejects.toThrow("not found");
expect(browserLogin.submitCode).not.toHaveBeenCalled();
await login.submitCode(companyId, owner, attempt.sessionId, "fixture-secret-code");
expect(browserLogin.submitCode).toHaveBeenCalledExactlyOnceWith("fixture-secret-code");
await expect(login.submitCode(companyId, owner, attempt.sessionId, "fixture-secret-code")).rejects.toThrow("cannot accept a code");
const events = await db.select().from(activityLog).where(eq(activityLog.entityId, attempt.sessionId));
expect(events.filter(event => event.action === "ai_connection.local_login_code_submitted")).toHaveLength(1);
expect(JSON.stringify(events)).not.toContain("fixture-secret-code");
await login.cancel(companyId, owner, attempt.sessionId);
});
it.each(["cancelled", "expired"])("rejects code submission to a %s Claude attempt", async state => {
const login = localAiLoginService(db);
const intent = { ...loginIntent(), provider: "anthropic" as const };
const attempt = await login.start(companyId, owner, intent);
browserLogin.submitCode.mockClear();
if (state === "cancelled") await login.cancel(companyId, owner, attempt.sessionId);
else await db.update(adapterAuthSessions).set({ expiresAt: new Date(Date.now() - 1000) }).where(eq(adapterAuthSessions.id, attempt.sessionId));
await expect(login.submitCode(companyId, owner, attempt.sessionId, "fixture-code")).rejects.toThrow("expired or was cancelled");
expect(browserLogin.submitCode).not.toHaveBeenCalled();
await login.reapExpired();
});
it("isolates sign-in and refresh from the host, survives restart, and completes only once", async () => {
const hostHome = path.join(home, "host-codex");
await mkdir(hostHome);
@@ -69,15 +121,17 @@ it("isolates sign-in and refresh from the host, survives restart, and completes
const attempt = await login.start(companyId, owner, loginIntent());
const directory = directoryFor(attempt.sessionId);
expect(await localAiLoginService(db).start(companyId, owner, loginIntent())).toEqual(attempt);
expect(attempt.command).toContain(`(export CODEX_HOME='${directory}' && mkdir -p "$CODEX_HOME" && codex`);
expect(attempt.command).toBeUndefined();
expect(await readFile(path.join(directory, "config.toml"), "utf8")).toContain('cli_auth_credentials_store = "file"');
expect(await login.check(companyId, owner, loginIntent(), attempt.sessionId)).toEqual({ status: "sign_in_required" });
expect(await localAiLoginService(db).check(companyId, owner, loginIntent(), attempt.sessionId)).toEqual({
status: "sign_in_required", authorizationUrl: "https://auth.openai.com/codex/device", code: "TEST-CODE",
});
// Resuming a valid attempt also repairs a missing directory without changing its ID.
await rm(directory, { recursive: true });
expect(await login.start(companyId, owner, loginIntent())).toEqual(attempt);
expect(await readFile(path.join(directory, "config.toml"), "utf8")).toContain('cli_auth_credentials_store = "file"');
// A valid host login cannot satisfy an unfinished connection-specific login.
await expect(login.complete(companyId, owner, attempt.sessionId, loginIntent())).rejects.toThrow("sign-in command shown");
await expect(login.complete(companyId, owner, attempt.sessionId, loginIntent())).rejects.toThrow("Finish browser sign-in");
expect((await aiConnectionService(db).list(companyId, owner)).filter(c => c.provider === "openai")).toHaveLength(0);
await writeFile(path.join(directory, "auth.json"), auth("independent-login"));
expect(await login.check(companyId, owner, loginIntent(), attempt.sessionId)).toEqual({ status: "ready" });
@@ -0,0 +1,152 @@
import { describe, it, expect } from "vitest";
import {
aiProviderRoutingSchema,
createAiConnectionSchema,
isAiConnectionCompatible,
} from "@paperclipai/shared";
import { managedProviderRouting } from "../services/ai-provider-routing.js";
describe("provider routing", () => {
it.each([
"https://user:secret@gateway.example/v1",
"https://gateway.example/v1?key=secret",
"http://169.254.169.254/",
"file:///tmp/secret",
"https://gateway.example/#secret",
])("rejects unsafe or credential-bearing endpoint %s", (baseUrl) => {
expect(
aiProviderRoutingSchema.safeParse({
kind: "gateway",
protocol: "responses",
baseUrl,
}).success,
).toBe(false);
});
it("requires exactly the selected credential and disallows routed subscriptions", () => {
const input = {
provider: "openai",
method: "api_key",
name: "Local",
ownership: "personal",
routing: {
kind: "local",
protocol: "responses",
auth: "none",
baseUrl: "http://localhost:11434/v1",
},
};
expect(createAiConnectionSchema.safeParse(input).success).toBe(true);
expect(
createAiConnectionSchema.safeParse({ ...input, apiKey: "unwanted" })
.success,
).toBe(false);
expect(
createAiConnectionSchema.safeParse({
...input,
method: "subscription",
loginSessionId: "session",
}).success,
).toBe(false);
});
it("rejects unsupported protocols and excluded remote adapters", () => {
const routing = aiProviderRoutingSchema.parse({
kind: "gateway",
protocol: "chat",
baseUrl: "https://gateway.example/v1",
});
for (const adapter of [
"codex_local",
"claude_local",
"http",
"process",
"openclaw_gateway",
"hermes_gateway",
"acpx_local",
])
expect(
isAiConnectionCompatible(
{ provider: "openai", method: "api_key", routing },
adapter,
),
).toBe(false);
expect(
isAiConnectionCompatible(
{ provider: "openai", method: "api_key", routing },
"opencode_local",
),
).toBe(true);
expect(
isAiConnectionCompatible(
{ provider: "openai", method: "api_key", routing },
"paperclip_runner",
"model",
"aws_agentcore",
),
).toBe(false);
});
it.each(["gateway", "openrouter"] as const)("uses a terminal-filtered secret variable for Hermes %s authentication", kind => {
const route = aiProviderRoutingSchema.parse({ kind, protocol: "chat", auth: "bearer", ...(kind === "gateway" ? { baseUrl: "https://gateway.example/v1" } : {}) });
const projected = managedProviderRouting(route, "hermes_local", "selected-key", "fixture-model");
expect(projected.env.OPENAI_API_KEY).toBe("selected-key");
expect(projected.env.PAPERCLIP_AI_PROVIDER_KEY).toBeUndefined();
expect(projected.hermesConfig).toContain('api_key: "${OPENAI_API_KEY}"');
expect(projected.hermesConfig).not.toContain("selected-key");
expect(Object.entries(projected.env).filter(([, value]) => value === "selected-key").map(([name]) => name)).toEqual(kind === "openrouter" ? ["OPENAI_API_KEY", "OPENROUTER_API_KEY"] : ["OPENAI_API_KEY"]);
});
it("routes OpenRouter through each harness's protocol without putting secrets in Codex config", () => {
const routing = aiProviderRoutingSchema.parse({
kind: "openrouter",
protocol: "responses",
});
const codex = managedProviderRouting(
routing,
"codex_local",
"fixture-secret",
"openai/gpt-5.4",
);
expect(codex.codexConfig).toContain('wire_api = "responses"');
expect(codex.codexConfig).not.toContain("fixture-secret");
const claude = managedProviderRouting(
routing,
"claude_local",
"fixture-secret",
"anthropic/claude-sonnet-4.6",
);
expect(claude.env.ANTHROPIC_BASE_URL).toBe("https://openrouter.ai/api");
expect(claude.env.ANTHROPIC_AUTH_TOKEN).toBe("fixture-secret");
expect(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe(
"anthropic/claude-sonnet-4.6",
);
const opencode = managedProviderRouting(
routing,
"opencode_local",
"fixture-secret",
"anthropic/claude-sonnet-4.6",
);
expect(opencode.config.model).toBe(
"openrouter/anthropic/claude-sonnet-4.6",
);
});
it("projects only a Bedrock API key and rejects general AWS access keys", () => {
const routing = aiProviderRoutingSchema.parse({
kind: "bedrock",
protocol: "bedrock",
auth: "bearer",
region: "us-east-1",
});
const projected = managedProviderRouting(
routing,
"claude_local",
"fixture-bedrock-key",
"bedrock-model",
);
expect(projected.env).toMatchObject({
CLAUDE_CODE_USE_BEDROCK: "1",
AWS_REGION: "us-east-1",
AWS_BEARER_TOKEN_BEDROCK: "fixture-bedrock-key",
AWS_EC2_METADATA_DISABLED: "true",
});
expect(aiProviderRoutingSchema.safeParse({ ...routing, auth: "aws_credentials" }).success).toBe(false);
for (const key of ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN"]) expect(projected.env).not.toHaveProperty(key);
});
});
+12 -1
View File
@@ -49,7 +49,7 @@ function createDbStub(selectResults: ApprovalRecord[][], updateResults: Approval
const update = vi.fn(() => ({ set }));
return {
db: { select, update },
db: { select, update, transaction: vi.fn(async (callback: (db: unknown) => Promise<unknown>) => callback({ select, update })) },
selectWhere,
returning,
};
@@ -105,6 +105,17 @@ describe("approvalService resolution idempotency", () => {
expect(mockNotifyHireApproved).toHaveBeenCalledTimes(1);
});
it("does not notify the adapter when the approval transaction fails to commit", async () => {
const approved = createApproval("approved");
const dbStub = createDbStub([[createApproval("pending")]], [approved]);
dbStub.db.transaction.mockImplementationOnce(async callback => {
await callback(dbStub.db);
throw new Error("commit failed");
});
await expect(approvalService(dbStub.db as any).approve("approval-1", "board")).rejects.toThrow("commit failed");
expect(mockNotifyHireApproved).not.toHaveBeenCalled();
});
it("creates the agent from payload when approval does not reference a pending agent", async () => {
const approved = {
...createApproval("approved"),
+1 -1
View File
@@ -51,7 +51,7 @@ function createApp(actor: Express.Request["actor"], row: Record<string, unknown>
return app;
}
describe.sequential("auth routes", () => {
describe("auth routes", () => {
const baseUser = {
id: "user-1",
name: "Jane Example",
@@ -2606,6 +2606,44 @@ describeEmbeddedPostgres("authorization service", () => {
})).resolves.toMatchObject({ allowed: true, reason: "allow_explicit_grant" });
});
it("keeps a default inbox grant within the responsible user's policy", async () => {
const company = await createCompany(db, "InboxDefaultScoped");
const actorAgent = await createAgent(db, company.id);
const responsibleUserId = await createUser(db);
const disabledUserId = await createUser(db);
const disallowedUserId = await createUser(db);
await db.insert(companyMemberships).values(
[responsibleUserId, disabledUserId, disallowedUserId].map((principalId) => ({
companyId: company.id,
principalType: "user" as const,
principalId,
status: "active" as const,
membershipRole: "operator" as const,
})),
);
await db.insert(userInboxAgentPolicies).values([
{ companyId: company.id, userId: disabledUserId, mode: "disabled" },
{ companyId: company.id, userId: disallowedUserId, mode: "allowlist", allowedAgentIds: [] },
]);
await grantAgentPermission(db, company.id, actorAgent.id, "inbox:manage", { responsibleUserOnly: true });
const decideFor = (userId: string) => authorizationService(db).decide({
actor: {
type: "agent" as const,
agentId: actorAgent.id,
companyId: company.id,
onBehalfOfUserId: responsibleUserId,
source: "agent_jwt" as const,
},
action: "inbox:manage" as const,
resource: { type: "company" as const, companyId: company.id },
scope: { userId },
});
await expect(decideFor(responsibleUserId)).resolves.toMatchObject({ allowed: true, reason: "allow_self" });
await expect(decideFor(disabledUserId)).resolves.toMatchObject({ allowed: false, reason: "inbox_management_disabled" });
await expect(decideFor(disallowedUserId)).resolves.toMatchObject({ allowed: false, reason: "inbox_agent_not_allowed" });
});
it("enforces user-scoped cross-user inbox grants", async () => {
const company = await createCompany(db, "InboxCrossUserScoped");
const actorAgent = await createAgent(db, company.id);
@@ -983,8 +983,15 @@ const actor = { actorType: "user" as const, actorId: "browser-reviewer" };
)[0],
).toMatchObject({ eventCursor: 9, eventsDrained: 1, accountedCents: 15 });
});
it("protects interactive viewer routes from agent, other-company and read-only access", async () => {
it.each(["task", "saved chat"])("protects %s browser routes from agent, other-company and read-only access", async (kind) => {
const f = await fixture();
if (kind === "saved chat") {
await db.update(issues).set({
conversationAgentId: f.agent.id,
conversationUserId: actor.actorId,
conversationState: "active",
}).where(eq(issues.id, f.issue.id));
}
await f.service.execute(
f.binding,
f.grant,
@@ -1021,11 +1028,19 @@ const actor = { actorType: "user" as const, actorId: "browser-reviewer" };
});
app.use(browserUseRoutes(db, f.service));
app.use(errorHandler);
const listPath = `/issues/${f.issue.id}/browsers`;
const listed = await http(app).get(listPath);
expect(listed.status).toBe(200);
expect(listed.body).toMatchObject([{ id: browser.id, issueId: f.issue.id }]);
const missing = await http(app).get(`/issues/${randomUUID()}/browsers`);
expect(missing.status).toBe(404);
expect(missing.body).toEqual({ error: "Task not found" });
const path = `/issues/${f.issue.id}/browsers/${browser.id}/viewer`;
const ok = await http(app).get(path);
expect(ok.status).toBe(200);
expect(ok.headers["cache-control"]).toBe("no-store");
expect(ok.headers["referrer-policy"]).toBe("no-referrer");
const controlPath = path.replace("/viewer", "/control");
const presencePath = path.replace("/viewer", "/presence");
expect((await http(app).post(presencePath)).status).toBe(200);
const resizePath = path.replace("/viewer", "/viewport");
@@ -1065,7 +1080,10 @@ const actor = { actorType: "user" as const, actorId: "browser-reviewer" };
(await http(app).post(resizePath).send({ preset: "custom", width: 1 }))
.status,
).toBe(400);
expect((await http(app).post(controlPath).send({ action: "cancel" })).status).toBe(200);
mode = "agent";
expect((await http(app).get(listPath)).status).toBe(403);
expect((await http(app).post(controlPath).send({ action: "keep_open" })).status).toBe(403);
expect((await http(app).post(presencePath)).status).toBe(403);
expect(
(await http(app).post(`${resizePath}/release`).send({ viewerId }))
@@ -1076,6 +1094,10 @@ const actor = { actorType: "user" as const, actorId: "browser-reviewer" };
).toBe(403);
expect((await http(app).get(path)).status).toBe(403);
mode = "other-company";
const inaccessible = await http(app).get(listPath);
expect(inaccessible.status).toBe(missing.status);
expect(inaccessible.body).toEqual(missing.body);
expect((await http(app).post(controlPath).send({ action: "keep_open" })).status).toBe(404);
expect((await http(app).post(presencePath)).status).toBe(404);
expect(
(await http(app).post(`${resizePath}/release`).send({ viewerId }))
@@ -1091,6 +1113,8 @@ const actor = { actorType: "user" as const, actorId: "browser-reviewer" };
.set({ membershipRole: "viewer" })
.where(eq(companyMemberships.companyId, f.company.id));
expect((await http(app).get(path)).status).toBe(403);
expect((await http(app).get(listPath)).body).toEqual([]);
expect((await http(app).post(controlPath).send({ action: "keep_open" })).status).toBe(403);
expect((await http(app).post(presencePath)).status).toBe(403);
expect(
(await http(app).post(resizePath).send({ preset: "phone" })).status,
@@ -0,0 +1,73 @@
import { randomUUID } from "node:crypto";
import express from "express";
import http from "supertest";
import { describe, expect, it, vi } from "vitest";
import type { Db } from "@paperclipai/db";
import { errorHandler } from "../middleware/error-handler.js";
import { browserUseRoutes } from "../routes/browser-use.js";
const routes = [
{ method: "get", suffix: "" },
{ method: "get", suffix: "/viewer" },
{ method: "post", suffix: "/presence" },
{ method: "post", suffix: "/viewport" },
{ method: "post", suffix: "/viewport/release" },
{ method: "post", suffix: "/control" },
] as const;
describe("browser task route scope", () => {
it.each([
"12345678-1234-4234-8234-123456789abc",
"12345678-1234-7234-8234-123456789ABC",
"00000000-0000-0000-0000-000000000000",
])("keeps canonical PostgreSQL task UUIDs queryable: %s", async (id) => {
const companyId = randomUUID();
const limit = vi.fn().mockResolvedValue([{ companyId }]);
const db = { select: () => ({ from: () => ({ where: () => ({ limit }) }) }) } as unknown as Db;
const list = vi.fn().mockResolvedValue([]);
const service = { list } as unknown as NonNullable<Parameters<typeof browserUseRoutes>[1]>;
const app = express();
app.use((req, _res, next) => {
req.actor = { type: "board", userId: "browser-reviewer", source: "session", companyIds: [companyId] };
next();
});
app.use(browserUseRoutes(db, service));
app.use(errorHandler);
expect((await http(app).get(`/issues/${id}/browsers`)).status).toBe(200);
expect(limit).toHaveBeenCalledExactlyOnceWith(1);
expect(list).toHaveBeenCalledExactlyOnceWith(companyId, id, "browser-reviewer");
});
it.each(routes)("rejects invalid task IDs before any query: $method $suffix", async ({ method, suffix }) => {
const select = vi.fn(() => { throw new Error("Invalid scope reached the database"); });
const db = { select } as unknown as Db;
const service = Object.fromEntries(
["list", "viewer", "presence", "resize", "releaseViewport", "humanSession", "control"]
.map((name) => [name, vi.fn()]),
) as unknown as NonNullable<Parameters<typeof browserUseRoutes>[1]>;
let actorType: "board" | "agent" | "none" = "board";
const app = express();
app.use((req, _res, next) => {
req.actor = actorType === "board"
? { type: "board", userId: "browser-reviewer", source: "session", companyIds: [] }
: actorType === "agent"
? { type: "agent", companyId: randomUUID(), agentId: randomUUID(), source: "agent_key" }
: { type: "none" };
next();
});
app.use(browserUseRoutes(db, service));
app.use(errorHandler);
const id = randomUUID();
for (const invalidId of [`chat:${id}`, "not-a-uuid", `${id}'`, ` ${id}`, `${id}\n`]) {
const path = `/issues/${encodeURIComponent(invalidId)}/browsers${suffix ? `/${id}${suffix}` : ""}`;
for (const type of ["board", "agent", "none"] as const) {
actorType = type;
const response = await http(app)[method](path);
expect(response.status).toBe(type === "board" ? 404 : 403);
expect(response.body).toEqual({ error: type === "board" ? "Task not found" : "Board access required" });
}
}
expect(select).not.toHaveBeenCalled();
for (const handler of Object.values(service)) expect(handler).not.toHaveBeenCalled();
});
});
+6 -1
View File
@@ -26,7 +26,10 @@ type SelectResult = unknown[];
function createDbStub(selectResults: SelectResult[]) {
const pendingSelects = [...selectResults];
const selectWhere = vi.fn(async () => pendingSelects.shift() ?? []);
const selectWhere = vi.fn(() => {
const query = Promise.resolve(pendingSelects.shift() ?? []);
return Object.assign(query, { for: () => query });
});
const selectThen = vi.fn((resolve: (value: unknown[]) => unknown) => Promise.resolve(resolve(pendingSelects.shift() ?? [])));
const selectOrderBy = vi.fn(async () => pendingSelects.shift() ?? []);
const selectFrom = vi.fn(() => ({
@@ -62,6 +65,7 @@ function createDbStub(selectResults: SelectResult[]) {
select,
insert,
update,
transaction: async (callback: (db: unknown) => Promise<unknown>) => callback({ select, insert, update }),
},
queueInsert: (rows: unknown[]) => {
pendingInserts.push(rows);
@@ -105,6 +109,7 @@ describe("budgetService", () => {
status: "running",
pauseReason: null,
}],
[{ id: "agent-1", companyId: "company-1", status: "running" }],
]);
dbStub.queueInsert([{
+3 -3
View File
@@ -611,9 +611,9 @@ describeEmbeddedPostgres("built-in agents", () => {
expect(result.defaultGrantsEnsured).toBeGreaterThanOrEqual(4);
const rootGrantKeys = await permissionKeysForAgent(root.id);
expect(rootGrantKeys).toEqual(expect.arrayContaining(["agents:configure", "skills:create"]));
expect(rootGrantKeys).not.toContain("agents:suggest-changes");
expect(rootGrantKeys).not.toContain("skills:suggest-changes");
expect(rootGrantKeys).toEqual(expect.arrayContaining([
"agents:configure", "agents:suggest-changes", "skills:create", "skills:suggest-changes",
]));
const state = await builtInAgentService(db).get(companyId, "reflection-coach");
expect(state).toMatchObject({
+1 -1
View File
@@ -37,7 +37,7 @@ import { instanceSettingsService } from "../services/instance-settings.js";
import type { StorageService } from "../storage/types.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
@@ -198,7 +198,7 @@ const embeddedPostgresSupport = externalTestDatabaseUrl
? { supported: true }
: await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported
? describe.sequential
? describe
: describe.skip;
if (!embeddedPostgresSupport.supported) {
@@ -1088,7 +1088,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
await db.insert(companies).values({
id: companyId,
name: `Chat Test ${companyId.slice(0, 8)}`,
issuePrefix: `C${companyId.replaceAll("-", "").slice(0, 7).toUpperCase()}`,
issuePrefix: `C${companyId.replace(/-/g, "").toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const now = new Date();
@@ -1,3 +1,4 @@
import { projectService } from "../services/projects.js";
import { callProjectTool } from "../services/project-tools.js";
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { randomUUID } from "node:crypto";
@@ -19,6 +20,55 @@ const support = await getEmbeddedPostgresTestSupport();
afterAll(async () => { await server?.close(); if (originalSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET; else process.env.PAPERCLIP_AGENT_JWT_SECRET = originalSecret; });
const call = (fixture: Awaited<ReturnType<typeof server.fixture>>, tool: string, args: Record<string, unknown>) => fixture.authority.execute({ tool, arguments: args, callId: randomUUID() });
it("pages only visible projects and prefilters low-trust agent and run scopes", async () => {
const f = await server.fixture({ disableWakeOnDemand: true });
const ids = Array.from({ length: 53 }, (_, i) => `abcdefab-0000-4000-8000-${String(i).padStart(12, "0")}`);
await server.db.insert(projects).values(ids.map((id, i) => ({
id, companyId: f.companyId, name: `Project ${i}`, status: "in_progress",
description: "日本語🦀".repeat(5_000), executionWorkspacePolicy: { oversized: "x".repeat(20_000) },
})));
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
const toolInput = { name: "list_projects", apiUrl: server.apiUrl, token, companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, conversation: false };
const first = await callProjectTool({ ...toolInput, arguments: {} });
expect(first.projects).toHaveLength(50);
expect(Buffer.byteLength(JSON.stringify(first))).toBeLessThan(256 * 1024);
expect(first.projects.find((p: { id: string }) => p.id === ids[0])).toMatchObject({ descriptionTruncated: true });
expect(first.projects.every((p: object) => !Object.hasOwn(p, "executionWorkspacePolicy") && !Object.hasOwn(p, "workspaces"))).toBe(true);
const last = await callProjectTool({ ...toolInput, arguments: { cursor: first.nextCursor } });
expect([...first.projects, ...last.projects].map((p: { id: string }) => p.id).sort()).toEqual([...ids, f.projectId].sort());
expect(last.nextCursor).toBeNull();
// A permitted project beyond the first database batch must remain discoverable.
const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { mode: "low_trust_review", companyId: f.companyId, projectIds: ids.slice(-2) } } };
await server.db.update(agents).set({ permissions: policy }).where(eq(agents.id, f.agentId));
expect((await projectService(server.db).listSummaries(f.companyId, {
limit: 51, includeArchived: false, candidateIds: ids.slice(-2),
})).map(p => p.id)).toEqual(ids.slice(-2));
const restricted = await callProjectTool({ ...toolInput, arguments: { limit: 1 } });
expect(restricted.projects.map((p: { id: string }) => p.id)).toEqual([ids[51]]);
expect(restricted.nextCursor).toBe(ids[51]);
const restrictedLast = await callProjectTool({ ...toolInput, arguments: { limit: 1, cursor: restricted.nextCursor } });
expect(restrictedLast.projects.map((p: { id: string }) => p.id)).toEqual([ids[52]]);
expect(restrictedLast.nextCursor).toBeNull();
// A run-only boundary also restricts the database candidates.
await server.db.update(agents).set({ permissions: {} }).where(eq(agents.id, f.agentId));
const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
await server.db.update(heartbeatRuns).set({ contextSnapshot: { ...run.contextSnapshot, executionPolicy: policy } }).where(eq(heartbeatRuns.id, f.runId));
const runRestricted = await callProjectTool({ ...toolInput, arguments: {} });
expect(runRestricted.projects.map((p: { id: string }) => p.id)).toEqual(ids.slice(-2));
expect(runRestricted.nextCursor).toBeNull();
// Adding denied projects must not change either the visible page or cursor.
await server.db.insert(projects).values({ companyId: f.companyId, name: "Hidden" });
expect(await callProjectTool({ ...toolInput, arguments: {} })).toEqual(runRestricted);
// Project policy may contribute a root scope, so candidate narrowing must
// not suppress a project that the full authorization decision permits.
await server.db.update(projects).set({ executionWorkspacePolicy: {
authorizationPolicy: { trustBoundary: { mode: "low_trust_review", companyId: f.companyId, rootIssueId: f.issueId } },
} }).where(eq(projects.id, f.projectId));
const withProjectScope = await callProjectTool({ ...toolInput, arguments: {} });
expect(withProjectScope.projects.map((p: { id: string }) => p.id).sort()).toEqual([...ids.slice(-2), f.projectId].sort());
expect(withProjectScope.nextCursor).toBeNull();
});
it("allows a conversation reply to enter review without manufacturing a review interaction", async () => {
const f = await server.fixture({ conversation: true });
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
@@ -639,9 +639,12 @@ describe("claude execute", () => {
const instructionsFile = path.join(root, "instructions.md");
await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8");
const metaEvents: Array<{ commandArgs: string[]; commandNotes: string[] }> = [];
const getFreshSessionHandoff = vi.fn(async () => "FRESH_HANDOFF: original goal and prior decisions");
const prompts: string[] = [];
try {
const result = await execute({
runId: "run-resume-fallback",
getFreshSessionHandoff,
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
runtime: { sessionId: "11111111-1111-4111-8111-111111111111", sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
@@ -659,6 +662,7 @@ describe("claude execute", () => {
authToken: "tok",
onLog: async () => {},
onMeta: async (meta) => {
prompts.push(String(meta.prompt ?? ""));
metaEvents.push({
commandArgs: ((meta.commandArgs as string[]) ?? []).slice(),
commandNotes: ((meta.commandNotes as string[]) ?? []).slice(),
@@ -671,6 +675,9 @@ describe("claude execute", () => {
appendedSystemPromptFileContents: string | null;
}>;
expect(captured).toHaveLength(2);
expect(getFreshSessionHandoff).toHaveBeenCalledOnce();
expect(prompts[0]).not.toContain("FRESH_HANDOFF");
expect(prompts[1]).toContain("FRESH_HANDOFF: original goal and prior decisions");
expect(captured[0]?.argv).toContain("--resume");
expect(captured[0]?.argv).not.toContain("--append-system-prompt-file");
expect(captured[1]?.argv).not.toContain("--resume");
@@ -1163,7 +1170,7 @@ describe("claude execute", () => {
})).toBe(false);
});
it("reuses a stable Paperclip-managed Claude prompt bundle across equivalent runs", async () => {
it.each(["unchanged", "added", "removed"])("resumes a stable Claude prompt bundle with %s MCP tools", async (change) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-bundle-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "claude");
@@ -1232,7 +1239,9 @@ describe("claude execute", () => {
});
expect(typeof first.sessionParams?.promptBundleKey).toBe("string");
const getFreshSessionHandoff = vi.fn(async () => "FRESH_HANDOFF_ONLY");
const second = await execute({
getFreshSessionHandoff,
runId: "run-2",
agent: {
id: "agent-1",
@@ -1265,6 +1274,7 @@ describe("claude execute", () => {
taskId: "issue-1",
wakeReason: "issue_commented",
wakeCommentId: "comment-2",
refreshTools: change !== "unchanged",
paperclipWake: {
reason: "issue_commented",
issue: {
@@ -1296,12 +1306,12 @@ describe("claude execute", () => {
},
},
runtimeMcp: {
getServers: () => [{
getServers: () => change === "removed" ? [] : [{
name: "Paperclip projects",
url: "http://localhost:3100/api/mcp/project-tools",
connectionId: "paperclip-project-tools",
token: "next-run-jwt-token",
}],
}, ...(change === "added" ? [{ name: "GitHub", url: "https://example.test/github/mcp", connectionId: "github", token: "fresh-github-token" }] : [])],
},
authToken: "run-jwt-token",
onLog: async () => {},
@@ -1331,7 +1341,14 @@ describe("claude execute", () => {
expect(capture1.instructionsContents).toContain(`The above agent instructions were loaded from ${instructionsPath}.`);
expect(capture1.skillEntries).toContain("paperclip");
expect(capture2.argv).toContain("--resume");
expect(getFreshSessionHandoff).not.toHaveBeenCalled();
expect(capture2.argv).toContain("11111111-1111-4111-8111-111111111111");
if (change === "removed") expect(capture2.mcpConfigContents).toBeNull();
else {
expect(capture2.mcpConfigContents).toContain("next-run-jwt-token");
expect(capture2.mcpConfigContents).not.toContain('"run-jwt-token"');
if (change === "added") expect(capture2.mcpConfigContents).toContain("fresh-github-token");
}
expect(capture2.prompt).toContain("## Paperclip Resume Delta");
expect(capture2.prompt).not.toContain("Follow the paperclip heartbeat.");
} finally {
@@ -38,7 +38,7 @@ describe("claude local skill sync", () => {
expect(snapshot.mode).toBe("ephemeral");
expect(snapshot.supported).toBe(true);
expect(snapshot.desiredSkills).toEqual([paperclipKey]);
expect(snapshot.desiredSkills).toEqual([paperclipKey, "paperclipai/paperclip/complain", "paperclipai/paperclip/suggestion-box"]);
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
expect(snapshot.entries.find((entry) => entry.key === createAgentKey)?.state).toBe("available");
});
+11 -11
View File
@@ -90,7 +90,7 @@ async function createApp(actor: any, db: any = {} as any) {
return app;
}
describe.sequential("cli auth routes", () => {
describe("cli auth routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/index.js");
@@ -101,7 +101,7 @@ describe.sequential("cli auth routes", () => {
vi.resetAllMocks();
});
it.sequential("creates a CLI auth challenge with approval metadata", async () => {
it("creates a CLI auth challenge with approval metadata", async () => {
mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({
challenge: {
id: "12345678-1234-4123-8123-123456789abc",
@@ -132,7 +132,7 @@ describe.sequential("cli auth routes", () => {
expect(res.body.approvalUrl).toContain("/cli-auth/12345678-1234-4123-8123-123456789abc?token=pcp_cli_auth_secret");
});
it.sequential("rejects anonymous access to generic skill documents", async () => {
it("rejects anonymous access to generic skill documents", async () => {
const indexApp = await createApp({ type: "none", source: "none" });
const skillApp = await createApp({ type: "none", source: "none" });
@@ -143,7 +143,7 @@ describe.sequential("cli auth routes", () => {
expect(skillRes.status, skillRes.text || JSON.stringify(skillRes.body)).toBe(401);
});
it.sequential("serves the invite-scoped paperclip skill anonymously for active invites", async () => {
it("serves the invite-scoped paperclip skill anonymously for active invites", async () => {
const invite = {
id: "invite-1",
companyId: "company-1",
@@ -174,7 +174,7 @@ describe.sequential("cli auth routes", () => {
expect(res.text).toContain("# Paperclip Skill");
});
it.sequential("marks challenge status as requiring sign-in for anonymous viewers", async () => {
it("marks challenge status as requiring sign-in for anonymous viewers", async () => {
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({
id: "12345678-1234-4123-8123-123456789abc",
status: "pending",
@@ -261,7 +261,7 @@ describe.sequential("cli auth routes", () => {
expect(mockBoardAuthService.cancelCliAuthChallenge).toHaveBeenCalledWith(id, "pcp_cli_auth_secret");
});
it.sequential("approves a CLI auth challenge for a signed-in board user", async () => {
it("approves a CLI auth challenge for a signed-in board user", async () => {
mockBoardAuthService.approveCliAuthChallenge.mockResolvedValue({
status: "approved",
challenge: {
@@ -306,7 +306,7 @@ describe.sequential("cli auth routes", () => {
);
});
it.sequential("logs approve activity for instance admins without company memberships", async () => {
it("logs approve activity for instance admins without company memberships", async () => {
mockBoardAuthService.approveCliAuthChallenge.mockResolvedValue({
status: "approved",
challenge: {
@@ -339,7 +339,7 @@ describe.sequential("cli auth routes", () => {
expect(mockLogActivity).toHaveBeenCalledTimes(2);
});
it.sequential("logs revoke activity with resolved audit company ids", async () => {
it("logs revoke activity with resolved audit company ids", async () => {
mockBoardAuthService.assertCurrentBoardKey.mockResolvedValue({
id: "board-key-3",
userId: "admin-2",
@@ -370,7 +370,7 @@ describe.sequential("cli auth routes", () => {
);
});
it.sequential("creates a named board API key and logs audit activity", async () => {
it("creates a named board API key and logs audit activity", async () => {
mockBoardAuthService.createNamedBoardApiKey.mockResolvedValue({
id: "board-key-4",
name: "external-admin",
@@ -419,7 +419,7 @@ describe.sequential("cli auth routes", () => {
);
});
it.sequential("lists and revokes named board API keys for the current board user", async () => {
it("lists and revokes named board API keys for the current board user", async () => {
const keyId = "55555555-5555-4555-8555-555555555555";
mockBoardAuthService.listBoardApiKeys.mockResolvedValue([
{
@@ -471,7 +471,7 @@ describe.sequential("cli auth routes", () => {
);
});
it.sequential("rejects malformed board API key IDs before database lookup", async () => {
it("rejects malformed board API key IDs before database lookup", async () => {
const app = await createApp({
type: "board",
userId: "user-1",
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { CloudPortfolioError } from "../services/cloud-portfolio-error.js";
const input = { phase: "fetch" as const, elapsedMs: 270.2, upstreamStatus: null };
describe("CloudPortfolioError", () => {
it("retains only an allowlisted nested network code, not the original exception", () => {
const cause = Object.assign(new Error("private upstream payload"), { code: "ECONNRESET", url: "https://private.example.test" });
const error = new CloudPortfolioError("upstream", input, new TypeError("fetch failed", { cause }));
expect(error.diagnostics).toEqual({ phase: "fetch", elapsedMs: 270, upstreamStatus: null, networkCode: "ECONNRESET" });
expect(error).not.toHaveProperty("cause");
expect(JSON.stringify(error)).not.toContain("private");
expect(Object.isFrozen(error.diagnostics)).toBe(true);
});
it.each([
new Error("ECONNRESET in a private message is not evidence"),
{ code: "private-provider-code" },
{ get code() { throw new Error("private getter"); }, get cause() { throw new Error("private getter"); } },
new Proxy({}, { getOwnPropertyDescriptor() { throw new Error("private proxy"); } }),
])("fails closed for unknown errors without invoking getters", (cause) => {
expect(new CloudPortfolioError("upstream", input, cause).diagnostics.networkCode).toBe("unknown");
});
it("bounds cause traversal and handles cycles", () => {
const cycle: { cause?: unknown } = {};
cycle.cause = cycle;
expect(new CloudPortfolioError("upstream", input, cycle).diagnostics.networkCode).toBe("unknown");
const deep = { cause: { cause: { cause: { cause: { code: "ECONNRESET" } } } } };
expect(new CloudPortfolioError("upstream", input, deep).diagnostics.networkCode).toBe("unknown");
});
it("normalizes unsafe diagnostics and keeps non-network phases distinct", () => {
const error = new CloudPortfolioError("upstream", {
phase: "private-phase" as never, elapsedMs: Infinity, upstreamStatus: 900,
}, { code: "ECONNRESET" });
expect(error.diagnostics).toEqual({ phase: "unknown", elapsedMs: null, upstreamStatus: null, networkCode: "unknown" });
for (const phase of ["http_response", "response_write"] as const) {
expect(new CloudPortfolioError("upstream", { ...input, phase, upstreamStatus: 503, deadlineExceeded: true }, { code: "ECONNRESET" }).diagnostics)
.toMatchObject({ phase, upstreamStatus: 503, networkCode: "unknown" });
}
expect(new CloudPortfolioError("upstream", { ...input, elapsedMs: 60_001 }).diagnostics.elapsedMs).toBeNull();
expect(new CloudPortfolioError("upstream", { ...input, elapsedMs: -1 }).diagnostics.elapsedMs).toBeNull();
});
});
+98 -1
View File
@@ -1,8 +1,13 @@
import express from "express";
import request from "supertest";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { cloudRoutes } from "../routes/cloud.js";
import { CloudPortfolioError } from "../services/cloud-portfolio-error.js";
import * as sentry from "../sentry.js";
import { logger } from "../middleware/logger.js";
afterEach(() => vi.restoreAllMocks());
const cloudEnv = {
PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "tenant-secret",
@@ -51,6 +56,98 @@ function jsonResponse(payload: unknown, status = 200) {
}
describe("GET /api/cloud/stacks", () => {
it.each([401, 403, 429, 503])("preserves the response contract for upstream HTTP %s without reading the body", async (status) => {
const upstream = jsonResponse({ secret: "private upstream payload" }, status);
const json = vi.spyOn(upstream, "json");
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(upstream);
const capture = vi.spyOn(sentry, "captureException").mockImplementation(() => {});
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
const response = await request(createApp({ fetchImpl })).get("/api/cloud/stacks").set("Cookie", "client-session=private-cookie");
expect(response.status).toBe(502);
expect(response.body).toEqual({ error: "Paperclip Cloud portfolio request failed", code: "cloud_portfolio_upstream_error", details: { code: "cloud_portfolio_upstream_error" } });
expect(response.headers["set-cookie"]).toBeUndefined();
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(new Headers(fetchImpl.mock.calls[0]![1]!.headers).has("cookie")).toBe(false);
expect(json).not.toHaveBeenCalled();
expect(capture).toHaveBeenCalledWith(expect.any(CloudPortfolioError));
const error = capture.mock.calls[0]![0] as CloudPortfolioError;
expect(error.diagnostics).toMatchObject({ phase: "http_response", upstreamStatus: status, networkCode: "unknown", elapsedMs: expect.any(Number) });
expect(error.stack).toContain("routes/cloud.ts:");
expect(error.stack).not.toContain("at portfolioError");
expect(warn).toHaveBeenCalledWith({ cloudPortfolio: error.diagnostics }, "Paperclip Cloud portfolio request failed");
expect(JSON.stringify(warn.mock.calls)).not.toContain("private");
});
it("classifies a fetch reset without retrying or caching the failure", async () => {
const fetchImpl = vi.fn<typeof fetch>()
.mockRejectedValueOnce(new TypeError("private fetch message", { cause: Object.assign(new Error("private cause"), { code: "ECONNRESET" }) }))
.mockResolvedValueOnce(jsonResponse({ stacks: [] }));
const capture = vi.spyOn(sentry, "captureException").mockImplementation(() => {});
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
const app = createApp({ fetchImpl });
const failure = await request(app).get("/api/cloud/stacks");
expect(failure.status).toBe(502);
expect(failure.body).toEqual({ error: "Paperclip Cloud portfolio request failed", code: "cloud_portfolio_upstream_error", details: { code: "cloud_portfolio_upstream_error" } });
expect(fetchImpl).toHaveBeenCalledTimes(1);
const error = capture.mock.calls[0]![0] as CloudPortfolioError;
expect(error.diagnostics).toMatchObject({ phase: "fetch", upstreamStatus: null, networkCode: "ECONNRESET" });
expect(JSON.stringify(warn.mock.calls)).not.toContain("private");
expect((await request(app).get("/api/cloud/stacks")).body).toEqual({ stacks: [] });
expect((await request(app).get("/api/cloud/stacks")).body).toEqual({ stacks: [] });
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
it.each(["invalid_json", "body_reset", "deadline"] as const)("keeps body-phase %s failures separate from fetch failures", async (kind) => {
const capture = vi.spyOn(sentry, "captureException").mockImplementation(() => {});
const controller = new AbortController();
const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal);
const upstream = jsonResponse({});
vi.spyOn(upstream, "json").mockImplementation(async () => {
if (kind === "deadline") controller.abort(new DOMException("private timeout", "TimeoutError"));
throw kind === "body_reset" ? Object.assign(new Error("private body"), { code: "ECONNRESET" }) : new SyntaxError("private body");
});
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(upstream);
const app = createApp({ fetchImpl });
for (let count = 1; count <= 2; count++) {
const response = await request(app).get("/api/cloud/stacks");
expect(response.status).toBe(502);
expect(response.body).toEqual({ error: "Paperclip Cloud portfolio returned invalid JSON", code: "cloud_portfolio_invalid_response", details: { code: "cloud_portfolio_invalid_response" } });
expect(fetchImpl).toHaveBeenCalledTimes(count);
}
expect(timeout).toHaveBeenCalledWith(10_000);
expect((capture.mock.calls[0]![0] as CloudPortfolioError).diagnostics).toMatchObject({
phase: "response_body", upstreamStatus: 200,
networkCode: kind === "deadline" ? "DEADLINE_EXCEEDED" : kind === "body_reset" ? "ECONNRESET" : "unknown",
});
});
it("records the owned fetch deadline without changing the ten-second signal", async () => {
const signal = AbortSignal.abort(new DOMException("private timeout", "TimeoutError"));
const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(signal);
const fetchImpl = vi.fn<typeof fetch>().mockRejectedValue(signal.reason);
const capture = vi.spyOn(sentry, "captureException").mockImplementation(() => {});
const response = await request(createApp({ fetchImpl })).get("/api/cloud/stacks");
expect(response.body).toEqual({ error: "Paperclip Cloud portfolio request failed", code: "cloud_portfolio_upstream_error", details: { code: "cloud_portfolio_upstream_error" } });
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(fetchImpl.mock.calls[0]![1]?.signal).toBe(signal);
expect(timeout).toHaveBeenCalledWith(10_000);
expect((capture.mock.calls[0]![0] as CloudPortfolioError).diagnostics).toMatchObject({ phase: "fetch", networkCode: "DEADLINE_EXCEEDED" });
});
it("does not mislabel a response serialization failure as an upstream fetch error", async () => {
const payload: { cycle?: unknown } = {};
payload.cycle = payload;
const upstream = jsonResponse({});
vi.spyOn(upstream, "json").mockResolvedValue(payload);
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(upstream);
const capture = vi.spyOn(sentry, "captureException").mockImplementation(() => {});
const response = await request(createApp({ fetchImpl })).get("/api/cloud/stacks");
expect(response.status).toBe(502);
expect(response.body).toEqual({ error: "Paperclip Cloud portfolio request failed", code: "cloud_portfolio_upstream_error", details: { code: "cloud_portfolio_upstream_error" } });
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect((capture.mock.calls[0]![0] as CloudPortfolioError).diagnostics).toMatchObject({ phase: "response_write", upstreamStatus: 200, networkCode: "unknown" });
});
it("returns the actor's portfolio without forwarding client-supplied identity", async () => {
const portfolio = { stacks: [{ slug: "current", displayName: "Current" }] };
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(portfolio));
@@ -1,7 +1,9 @@
import { createCloudWarmStandby } from "../services/cloud-warm-standby.js";
import { cloudWarmStandbyMiddleware } from "../middleware/cloud-warm-standby.js";
import { generateKeyPairSync, sign } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createDb, instanceSettings } from "@paperclipai/db";
import {
applyCloudRuntimeIdentityAssertion,
@@ -106,6 +108,7 @@ describeEmbeddedPostgres("Cloud runtime identity", () => {
});
afterEach(() => {
vi.restoreAllMocks();
for (const key of [
"PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN",
"PAPERCLIP_CLOUD_STACK_ID",
@@ -183,6 +186,39 @@ describeEmbeddedPostgres("Cloud runtime identity", () => {
});
});
it("keeps probes idle until a verified durable claim and stays active across restart", async () => {
const env = { ...process.env, PAPERCLIP_CLOUD_WARM_STANDBY: "1" };
const isStandby = await createCloudWarmStandby(db, env);
const execute = vi.spyOn(db, "execute");
const health = healthRoutes(db, {
deploymentMode: "authenticated", deploymentExposure: "public",
authReady: true, companyDeletionEnabled: false, isWarmStandby: isStandby,
});
const app = express();
app.use(cloudRuntimeIdentityMiddleware(db));
app.use(cloudWarmStandbyMiddleware(isStandby, health));
app.use("/api/health", health);
expect((await request(app).get("/api/health")).body.warmStandby).toBe(true);
expect(execute).not.toHaveBeenCalled();
const requestTime = Math.floor(Date.now() / 1000);
const invalid = await request(app).get("/api/health").set("x-paperclip-cloud-runtime-identity", assertion({
claims: { sub: "another-stack", iat: requestTime, exp: requestTime + 300 },
}));
expect(invalid.status).toBe(401);
expect(isStandby()).toBe(true);
const signed = assertion({ claims: { iat: requestTime, exp: requestTime + 300 } });
const claimed = await request(app).get("/api/health").set("x-paperclip-cloud-runtime-identity", signed);
expect(claimed.status).toBe(200);
expect(claimed.body.warmStandby).toBeUndefined();
expect(isStandby()).toBe(false);
expect(execute).toHaveBeenCalledOnce();
expect((await request(app).get("/api/health").set("x-paperclip-cloud-runtime-identity", signed)).status).toBe(200);
resetCloudRuntimeIdentityForTests();
process.env.PAPERCLIP_PUBLIC_URL = POOL_ORIGIN;
await initializeCloudRuntimeIdentity(db);
expect((await createCloudWarmStandby(db, env))()).toBe(false);
});
it("restores the canonical identity before consumers read stale startup variables", async () => {
await applyCloudRuntimeIdentityAssertion({ db, compactJws: assertion(), now: NOW });
resetCloudRuntimeIdentityForTests();
@@ -0,0 +1,149 @@
import { createServer, request as httpRequest } from "node:http";
import express from "express";
import request from "supertest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Db } from "@paperclipai/db";
import { cloudWarmStandbyMiddleware, cloudWarmStandbyServerOptions } from "../middleware/cloud-warm-standby.js";
import { healthRoutes } from "../routes/health.js";
import { emailChannelService } from "../services/email-channels.js";
import { createPluginJobScheduler } from "../services/plugin-job-scheduler.js";
import { createPublicMcpEvents } from "../services/public-mcp/events.js";
import type { PublicMcpOAuth } from "../services/public-mcp/oauth.js";
const healthOptions = {
deploymentMode: "authenticated" as const,
deploymentExposure: "public" as const,
authReady: true,
companyDeletionEnabled: false,
runtimeEnv: { PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "test-token" },
};
afterEach(() => vi.useRealTimers());
describe("unclaimed Cloud background work", () => {
it("keeps probes and tenant requests out of SQL and auth until claim", async () => {
let standby = true;
const execute = vi.fn().mockResolvedValue([]);
const db = { execute } as unknown as Db;
const health = healthRoutes(db, { ...healthOptions, isWarmStandby: () => standby });
const app = express();
const auth = vi.fn((_req, _res, next) => next());
const ui = express.Router();
ui.get("/", (_req, res) => res.type("html").send("ready"));
ui.get("/assets/app.js", (_req, res) => res.type("js").send("ready"));
app.use(cloudWarmStandbyMiddleware(() => standby, health, ui));
app.use(auth);
app.use("/api/health", health);
app.get("/api/companies", (_req, res) => res.json([]));
for (let i = 0; i < 3; i++) {
const probe = await request(app).get("/api/health").set("authorization", "Bearer ignored-in-standby");
expect(probe.status).toBe(200);
expect(probe.body.warmStandby).toBe(true);
expect(probe.body.serverInfo).toBeUndefined();
}
expect((await request(app).head("/api/health")).status).toBe(200);
expect((await request(app).get("/api/companies")).status).toBe(503);
expect((await request(app).post("/api/health/dev-server/restart")).status).toBe(503);
for (const path of ["/", "/assets/app.js"]) {
const page = await request(app).get(path)
.set("authorization", "Bearer test-bearer")
.set("x-paperclip-cloud-tenant-token", "test-tenant")
.set("cookie", "paperclip.session_token=test-session");
expect(page.status).toBe(200);
}
expect((await request(app).post("/mcp/gateways/gw_test")).status).toBe(503);
expect((await request(app).get("/unknown-dynamic-handler")).status).toBe(503);
expect(auth).not.toHaveBeenCalled();
expect(execute).not.toHaveBeenCalled();
standby = false;
expect((await request(app).get("/api/companies")).status).toBe(200);
const claimed = await request(app).get("/api/health");
expect(claimed.status).toBe(200);
expect(claimed.body.warmStandby).toBeUndefined();
expect(execute).toHaveBeenCalledOnce();
execute.mockRejectedValueOnce(new Error("offline"));
expect((await request(app).get("/api/health")).status).toBe(503);
});
it("rejects every WebSocket upgrade before auth and admits upgrades after claim", async () => {
let standby = true;
const app = express();
const health = healthRoutes({} as Db, { ...healthOptions, isWarmStandby: () => standby });
const authenticate = vi.fn();
app.use(cloudWarmStandbyMiddleware(() => standby, health));
const server = createServer(cloudWarmStandbyServerOptions(() => standby), app);
server.on("upgrade", (_req, socket) => {
authenticate();
socket.end("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n");
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as { port: number };
const upgrade = (path: string) => new Promise<number>((resolve, reject) => {
const req = httpRequest({
host: "127.0.0.1", port: address.port, path,
headers: { connection: "Upgrade", upgrade: "websocket", authorization: "Bearer test-bearer" },
});
req.on("response", (res) => { res.resume(); resolve(res.statusCode!); });
req.on("upgrade", (res, socket) => { socket.destroy(); resolve(res.statusCode!); });
req.on("error", reject);
req.end();
});
try {
for (const path of ["/api/companies/company/events/ws?token=anything", "/api/health", "/prp", "/"]) {
expect(await upgrade(path)).toBe(503);
}
expect(authenticate).not.toHaveBeenCalled();
standby = false;
expect(await upgrade("/api/companies/company/events/ws?token=anything")).toBe(101);
expect(authenticate).toHaveBeenCalledOnce();
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
});
it("MCP event timers do not read the persisted setting until claim", async () => {
vi.useFakeTimers();
let standby = true;
const isEnabled = vi.fn().mockResolvedValue(false);
const events = createPublicMcpEvents({} as Db, { isEnabled } as PublicMcpOAuth, vi.fn(), {
isBackgroundWorkEnabled: () => !standby,
});
try {
events.start();
await vi.advanceTimersByTimeAsync(10 * 60_000);
await events.tick();
expect(isEnabled).not.toHaveBeenCalled();
standby = false;
await vi.advanceTimersByTimeAsync(2000);
expect(isEnabled).toHaveBeenCalledOnce();
} finally {
await events.stop();
}
});
it("email and plugin timers leave SQL idle, then resume without restarting", async () => {
vi.useFakeTimers();
let enabled = false;
const select = vi.fn(() => { throw new Error("SQL probe"); });
const db = { select } as unknown as Db;
const email = emailChannelService(db, { heartbeat: { wakeup: vi.fn() }, isBackgroundWorkEnabled: () => enabled });
const scheduler = createPluginJobScheduler({
db,
jobStore: {} as never,
workerManager: {} as never,
tickIntervalMs: 1000,
isBackgroundWorkEnabled: () => enabled,
});
try {
email.start();
scheduler.start();
await vi.advanceTimersByTimeAsync(10 * 60_000);
expect(select).not.toHaveBeenCalled();
enabled = true;
await vi.advanceTimersByTimeAsync(1000);
expect(select.mock.calls.length).toBeGreaterThanOrEqual(2);
} finally {
scheduler.stop();
await email.shutdown();
}
});
});
@@ -7,13 +7,18 @@ import { testEnvironment } from "@paperclipai/adapter-codex-local/server";
const itWindows = process.platform === "win32" ? it : it.skip;
const itPosix = process.platform === "win32" ? it.skip : it;
async function runProbeFixture(options: { failCleanup?: boolean; error?: string } = {}) {
async function runProbeFixture(options: { failCleanup?: boolean; error?: string; managedProvider?: boolean } = {}) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-probe-result-"));
const capture = path.join(root, "capture.json");
const command = path.join(root, "codex");
const managedHome = path.join(root, "managed");
if (options.managedProvider) {
await fs.mkdir(managedHome);
await fs.writeFile(path.join(managedHome, "config.toml"), 'model_provider = "paperclip"\n');
}
await fs.writeFile(command, `#!${process.execPath}
const fs = require('node:fs');
fs.writeFileSync(process.env.PROBE_CAPTURE, JSON.stringify({ args: process.argv.slice(2), home: process.env.CODEX_HOME }));
fs.writeFileSync(process.env.PROBE_CAPTURE, JSON.stringify({ args: process.argv.slice(2), home: process.env.CODEX_HOME, nativeKey: process.env.OPENAI_API_KEY, providerKey: process.env.PAPERCLIP_AI_PROVIDER_KEY, config: fs.existsSync(process.env.CODEX_HOME + "/config.toml") ? fs.readFileSync(process.env.CODEX_HOME + "/config.toml", "utf8") : null }));
console.error('WARN codex_core_plugins::manager: remote installed plugin bundle sync failed error=chatgpt authentication required for remote plugin catalog');
const error = process.env.PROBE_ERROR;
if (error) { console.error(error); process.exit(1); }
@@ -27,12 +32,13 @@ console.log(JSON.stringify({type:'turn.completed',usage:{input_tokens:1,output_t
try {
const result = await testEnvironment({
companyId: "company-1", adapterType: "codex_local",
config: { engine: "cli", command, cwd: root, env: {
OPENAI_API_KEY: "fixture-key", PROBE_CAPTURE: capture,
config: { engine: "cli", command, cwd: root, ...(options.managedProvider ? { managedAiConnection: { identity: "fixture" }, managedAiRouting: { kind: "openrouter" } } : {}), env: {
...(options.managedProvider ? { CODEX_HOME: managedHome, PAPERCLIP_AI_PROVIDER_KEY: "gateway-fixture-key" } : {}),
OPENAI_API_KEY: options.managedProvider ? "" : "fixture-key", PROBE_CAPTURE: capture,
PROBE_ERROR: options.error ?? "", PATH: `${root}${path.delimiter}${process.env.PATH ?? ""}`,
} },
});
return { result, capture: JSON.parse(await fs.readFile(capture, "utf8")) as { args: string[]; home: string } };
return { result, capture: JSON.parse(await fs.readFile(capture, "utf8")) as { args: string[]; home: string; nativeKey: string; providerKey: string; config: string | null } };
} finally {
const recorded = await fs.readFile(capture, "utf8").then(JSON.parse).catch(() => null);
if (recorded?.home) await fs.rm(recorded.home, { recursive: true, force: true });
@@ -47,6 +53,16 @@ describe("codex_local environment diagnostics", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
itPosix("tests the managed provider without falling back to the host OpenAI key", async () => {
vi.stubEnv("OPENAI_API_KEY", "unrelated-host-key");
const { result, capture } = await runProbeFixture({ managedProvider: true });
expect(result.status).toBe("pass");
expect(capture.nativeKey).toBe("");
expect(capture.providerKey).toBe("gateway-fixture-key");
expect(capture.config).toContain('model_provider = "paperclip"');
expect(result.checks).not.toContainEqual(expect.objectContaining({ code: "codex_openai_api_key_present" }));
});
itPosix("preserves a successful hello when probe cleanup races a background writer", async () => {
const { result } = await runProbeFixture({ failCleanup: true });
expect(result.status).toBe("pass");
@@ -1443,9 +1443,8 @@ process.exit(1);
expect(invocationPrompt).toContain("baseRevisionId set to that latestRevisionId");
expect(capture.prompt).not.toContain("Execution contract:");
expect(capture.prompt).not.toContain("Use child issues");
} else {
expect(capture.prompt).toContain("Execution contract:");
}
expect(capture.prompt).not.toContain("Execution contract:");
expect(capture.prompt).not.toContain("Follow the paperclip heartbeat.");
if (resumedSession) {
expect(capture.prompt).not.toContain("You are managed instructions.");
@@ -215,7 +215,7 @@ function boardActor(input: {
};
}
describe.sequential("company route cross-company authorization", () => {
describe("company route cross-company authorization", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/authz.js");
@@ -500,6 +500,32 @@ describeEmbeddedPostgres("companyArtifactsService", () => {
expect(afterPrimaryCursor.artifacts.map((artifact) => artifact.title)).toEqual(["notes.txt"]);
});
it("filters every artifact source by the agent it is attributed to", async () => {
const { companyId } = await seedArtifacts();
const writerId = "21212121-2121-4121-8121-212121212121";
await db.insert(agents).values({ id: writerId, companyId, name: "Writer", role: "engineer" });
await db.update(documents).set({ createdByAgentId: writerId })
.where(eq(documents.id, "cccccccc-cccc-4ccc-8ccc-cccccccccccc"));
const storage = createStorageService({ "notes.txt": Buffer.from("notes") });
const writer = await companyArtifactsService(db, storage).list(companyId, { agentId: writerId, limit: 20 });
expect(writer.artifacts.map((artifact) => artifact.title)).toEqual(["Review Notes"]);
const original = await companyArtifactsService(db, storage).list(companyId, {
agentId: "33333333-3333-4333-8333-333333333333",
limit: 20,
});
expect(original.artifacts.map((artifact) => artifact.title)).toEqual(["direct-video.mp4", "Primary Cut", "notes.txt"]);
expect(new Set(original.artifacts.map((artifact) => artifact.createdByAgent?.id)))
.toEqual(new Set(["33333333-3333-4333-8333-333333333333"]));
const foreign = await companyArtifactsService(db, storage).list(companyId, {
agentId: "44444444-4444-4444-8444-444444444444",
limit: 20,
});
expect(foreign.artifacts).toEqual([]);
});
it("deduplicates work product attachments beyond the work product fetch window", async () => {
const { companyId, projectId, issueId } = await seedArtifacts();
const dedupedAttachmentId = "abababab-abab-4bab-8bab-abababababab";
@@ -89,7 +89,7 @@ vi.mock("../services/company-import-transfers.js", async (importOriginal) => {
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported
? describe.sequential
? describe
: describe.skip;
const companyId = "11111111-1111-4111-8111-111111111111";
@@ -334,7 +334,7 @@ const importMeta = {
collisionStrategy: importRequest.collisionStrategy,
};
describe.sequential("company portability routes", () => {
describe("company portability routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAgentService.getById.mockImplementation(async (id: string) => ({
@@ -361,7 +361,7 @@ describe.sequential("company portability routes", () => {
});
});
it.sequential("rejects non-CEO agents from CEO-safe export preview routes", async () => {
it("rejects non-CEO agents from CEO-safe export preview routes", async () => {
const app = await createApp({
type: "agent",
agentId: engineerAgentId,
@@ -379,7 +379,7 @@ describe.sequential("company portability routes", () => {
expect(mockCompanyPortabilityService.previewExport).not.toHaveBeenCalled();
});
it.sequential("rejects non-CEO export preview callers before validating request shape", async () => {
it("rejects non-CEO export preview callers before validating request shape", async () => {
const app = await createApp({
type: "agent",
agentId: engineerAgentId,
@@ -397,7 +397,7 @@ describe.sequential("company portability routes", () => {
expect(mockCompanyPortabilityService.previewExport).not.toHaveBeenCalled();
});
it.sequential("rejects non-CEO agents from legacy and CEO-safe export bundle routes", async () => {
it("rejects non-CEO agents from legacy and CEO-safe export bundle routes", async () => {
const app = await createApp({
type: "agent",
agentId: engineerAgentId,
@@ -415,7 +415,7 @@ describe.sequential("company portability routes", () => {
expect(mockCompanyPortabilityService.exportBundle).not.toHaveBeenCalled();
});
it.sequential("allows CEO agents to use company-scoped export preview routes", async () => {
it("allows CEO agents to use company-scoped export preview routes", async () => {
mockCompanyPortabilityService.previewExport.mockResolvedValue({
rootPath: "paperclip",
manifest: { agents: [], skills: [], projects: [], issues: [], envInputs: [], includes: { company: true, agents: true, projects: true, issues: false, skills: false }, company: null, schemaVersion: 1, generatedAt: new Date().toISOString(), source: null },
@@ -441,7 +441,7 @@ describe.sequential("company portability routes", () => {
expect(res.body.rootPath).toBe("paperclip");
});
it.sequential("allows CEO agents to export through legacy and CEO-safe bundle routes", async () => {
it("allows CEO agents to export through legacy and CEO-safe bundle routes", async () => {
mockCompanyPortabilityService.exportBundle.mockResolvedValue(createExportResult());
const app = await createApp({
type: "agent",
@@ -472,7 +472,7 @@ describe.sequential("company portability routes", () => {
);
});
it.sequential("allows board users to export through legacy and CEO-safe bundle routes", async () => {
it("allows board users to export through legacy and CEO-safe bundle routes", async () => {
mockCompanyPortabilityService.exportBundle.mockResolvedValue(createExportResult());
const app = await createApp({
type: "board",
@@ -491,7 +491,7 @@ describe.sequential("company portability routes", () => {
expect(mockCompanyPortabilityService.exportBundle).toHaveBeenCalledTimes(2);
});
it.sequential("requires instance-admin access when a company export includes external instructions", async () => {
it("requires instance-admin access when a company export includes external instructions", async () => {
mockAgentService.list.mockResolvedValue([{
id: "external-agent",
companyId,
@@ -535,7 +535,7 @@ describe.sequential("company portability routes", () => {
expect(mockCompanyPortabilityService.previewExport).not.toHaveBeenCalled();
});
it.sequential("allows an instance admin to export companies with external instructions", async () => {
it("allows an instance admin to export companies with external instructions", async () => {
mockAgentService.list.mockResolvedValue([{
id: "external-agent",
companyId,
@@ -563,7 +563,7 @@ describe.sequential("company portability routes", () => {
);
});
it.sequential("uses the export selector resolver before checking external instructions", async () => {
it("uses the export selector resolver before checking external instructions", async () => {
mockAgentService.list.mockResolvedValue([
{
id: "external-agent",
@@ -612,7 +612,7 @@ describe.sequential("company portability routes", () => {
);
});
it.sequential("keeps non-agent exports open when external instructions are excluded", async () => {
it("keeps non-agent exports open when external instructions are excluded", async () => {
mockAgentService.list.mockResolvedValue([{
id: "external-agent",
companyId,
@@ -651,7 +651,7 @@ describe.sequential("company portability routes", () => {
);
});
it.sequential("rejects CEO agents from exporting another company before services run", async () => {
it("rejects CEO agents from exporting another company before services run", async () => {
const app = await createApp({
type: "agent",
agentId: ceoAgentId,
@@ -675,7 +675,7 @@ describe.sequential("company portability routes", () => {
expect(mockLogActivity).not.toHaveBeenCalled();
});
it.sequential("rejects replace collision strategy on CEO-safe import routes", async () => {
it("rejects replace collision strategy on CEO-safe import routes", async () => {
const app = await createApp({
type: "agent",
agentId: ceoAgentId,
@@ -698,7 +698,7 @@ describe.sequential("company portability routes", () => {
expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled();
});
it.sequential("rejects CEO agents from previewing or applying imports against another route company", async () => {
it("rejects CEO agents from previewing or applying imports against another route company", async () => {
const app = await createApp({
type: "agent",
agentId: ceoAgentId,
@@ -724,7 +724,7 @@ describe.sequential("company portability routes", () => {
expect(mockLogActivity).not.toHaveBeenCalled();
});
it.sequential("rejects CEO-safe import bodies that target a different company than the route", async () => {
it("rejects CEO-safe import bodies that target a different company than the route", async () => {
const app = await createApp({
type: "agent",
agentId: ceoAgentId,
@@ -750,7 +750,7 @@ describe.sequential("company portability routes", () => {
expect(mockLogActivity).not.toHaveBeenCalled();
});
it.sequential("keeps global import preview routes board-only", async () => {
it("keeps global import preview routes board-only", async () => {
const app = await createApp({
type: "agent",
agentId: engineerAgentId,
@@ -772,7 +772,7 @@ describe.sequential("company portability routes", () => {
expect(res.body.error).toContain("Board access required");
});
it.sequential("keeps global import preview board-only before validating request shape", async () => {
it("keeps global import preview board-only before validating request shape", async () => {
const app = await createApp({
type: "agent",
agentId: engineerAgentId,
@@ -790,7 +790,7 @@ describe.sequential("company portability routes", () => {
expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled();
});
it.sequential("requires instance admin for new-company import preview", async () => {
it("requires instance admin for new-company import preview", async () => {
const app = await createApp({
type: "board",
userId: "user-1",
@@ -813,7 +813,7 @@ describe.sequential("company portability routes", () => {
expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled();
});
it.sequential("rejects replace collision strategy on CEO-safe import apply routes", async () => {
it("rejects replace collision strategy on CEO-safe import apply routes", async () => {
const app = await createApp({
type: "agent",
agentId: ceoAgentId,
@@ -836,7 +836,7 @@ describe.sequential("company portability routes", () => {
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
});
it.sequential("rejects non-CEO agents from CEO-safe import preview routes", async () => {
it("rejects non-CEO agents from CEO-safe import preview routes", async () => {
const app = await createApp({
type: "agent",
agentId: engineerAgentId,
@@ -859,7 +859,7 @@ describe.sequential("company portability routes", () => {
expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled();
});
it.sequential("rejects non-CEO import preview callers before validating request shape", async () => {
it("rejects non-CEO import preview callers before validating request shape", async () => {
const app = await createApp({
type: "agent",
agentId: engineerAgentId,
@@ -877,7 +877,7 @@ describe.sequential("company portability routes", () => {
expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled();
});
it.sequential("rejects non-CEO agents from CEO-safe import apply routes", async () => {
it("rejects non-CEO agents from CEO-safe import apply routes", async () => {
const app = await createApp({
type: "agent",
agentId: engineerAgentId,
@@ -900,7 +900,7 @@ describe.sequential("company portability routes", () => {
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
});
it.sequential("requires instance admin for new-company import apply", async () => {
it("requires instance admin for new-company import apply", async () => {
const app = await createApp({
type: "board",
userId: "user-1",
@@ -923,7 +923,7 @@ describe.sequential("company portability routes", () => {
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
});
it.sequential("accepts trusted Cloud async import jobs and reports success by job id", async () => {
it("accepts trusted Cloud async import jobs and reports success by job id", async () => {
let resolveImport: (value: ReturnType<typeof createImportResult>) => void = () => undefined;
const pendingImport = new Promise<ReturnType<typeof createImportResult>>((resolve) => {
resolveImport = resolve;
@@ -988,7 +988,7 @@ describe.sequential("company portability routes", () => {
}
});
it.sequential("reports trusted Cloud async import job failures with the tenant error message", async () => {
it("reports trusted Cloud async import job failures with the tenant error message", async () => {
mockCompanyPortabilityService.importBundle.mockRejectedValueOnce(new Error("tenant import exploded"));
const app = await createApp(cloudTenantActor());
@@ -1009,7 +1009,7 @@ describe.sequential("company portability routes", () => {
expect(mockLogActivity).not.toHaveBeenCalled();
});
it.sequential("accepts trusted Cloud async import jobs before validating the full import payload", async () => {
it("accepts trusted Cloud async import jobs before validating the full import payload", async () => {
const app = await createApp(cloudTenantActor());
const accepted = await request(app)
@@ -1031,7 +1031,7 @@ describe.sequential("company portability routes", () => {
expect(mockLogActivity).not.toHaveBeenCalled();
});
it.sequential("floors global import apply on a cloud-managed instance, even for the trusted tenant actor", async () => {
it("floors global import apply on a cloud-managed instance, even for the trusted tenant actor", async () => {
// The trusted-tenant tests above run without the cloud env signal on
// purpose: the import floor keys on isCloudManagedInstance(), not on the
// actor. With the signal present, even the trusted tenant actor is
@@ -1054,7 +1054,7 @@ describe.sequential("company portability routes", () => {
}
});
it.sequential("forwards pauseAutomations from the global import body to the portability service", async () => {
it("forwards pauseAutomations from the global import body to the portability service", async () => {
mockCompanyPortabilityService.importBundle.mockResolvedValueOnce(createImportResult("created"));
const app = await createApp(cloudTenantActor());
@@ -1071,7 +1071,7 @@ describe.sequential("company portability routes", () => {
);
});
it.sequential("runs board-session async imports as jobs and reports the full result by job id", async () => {
it("runs board-session async imports as jobs and reports the full result by job id", async () => {
let resolveImport: (value: ReturnType<typeof createImportResult>) => void = () => undefined;
const pendingImport = new Promise<ReturnType<typeof createImportResult>>((resolve) => {
resolveImport = resolve;
@@ -1111,7 +1111,7 @@ describe.sequential("company portability routes", () => {
}));
});
it.sequential("hides a board import job from other board users", async () => {
it("hides a board import job from other board users", async () => {
mockCompanyPortabilityService.importBundle.mockReturnValueOnce(new Promise(() => undefined));
const app = await createBoardApp();
@@ -1132,7 +1132,7 @@ describe.sequential("company portability routes", () => {
expect(owner.body.job.status).toBe("running");
});
it.sequential("returns 409 with the running job when a board user resubmits an import", async () => {
it("returns 409 with the running job when a board user resubmits an import", async () => {
mockCompanyPortabilityService.importBundle.mockReturnValueOnce(new Promise(() => undefined));
const app = await createBoardApp();
@@ -1168,7 +1168,7 @@ describe.sequential("company portability routes", () => {
expect(otherUser.body.job.id).not.toBe(first.body.job.id);
});
it.sequential(
it(
"rejects a concurrent different import without adopting the running job",
async () => {
mockCompanyPortabilityService.importBundle.mockReturnValueOnce(new Promise(() => undefined));
@@ -1200,7 +1200,7 @@ describe.sequential("company portability routes", () => {
},
);
it.sequential("fails a board async import job when the bundle import throws", async () => {
it("fails a board async import job when the bundle import throws", async () => {
mockCompanyPortabilityService.importBundle.mockRejectedValueOnce(new Error("import payload is incomplete"));
const app = await createBoardApp();
@@ -1224,7 +1224,7 @@ describe.sequential("company portability routes", () => {
expect(mockLogActivity).not.toHaveBeenCalled();
});
it.sequential("keeps the import job status route board-only", async () => {
it("keeps the import job status route board-only", async () => {
const app = await createApp({
type: "agent",
agentId: engineerAgentId,
@@ -1239,7 +1239,7 @@ describe.sequential("company portability routes", () => {
expect(res.body.error).toContain("Board access required");
});
it.sequential("returns 404 for an unknown import job id", async () => {
it("returns 404 for an unknown import job id", async () => {
const app = await createBoardApp();
const res = await request(app)
@@ -1250,7 +1250,7 @@ describe.sequential("company portability routes", () => {
expect(res.body.error).toBe("Import job not found");
});
it.sequential("forwards pauseAutomations from CEO-safe import apply bodies to the portability service", async () => {
it("forwards pauseAutomations from CEO-safe import apply bodies to the portability service", async () => {
mockCompanyPortabilityService.importBundle.mockResolvedValueOnce(createImportResult("created"));
const app = await createApp({
type: "agent",
@@ -1272,7 +1272,7 @@ describe.sequential("company portability routes", () => {
);
});
it.sequential("imports a company from a multipart zip upload, unzipping into the same inline bundle", async () => {
it("imports a company from a multipart zip upload, unzipping into the same inline bundle", async () => {
const app = await createBoardApp();
const files = { "COMPANY.md": "---\nname: Test\n---\n", "agents/ceo/AGENTS.md": "---\nname: CEO\n---\n" };
const zip = buildStoreZip(files, "paperclip");
@@ -1293,7 +1293,7 @@ describe.sequential("company portability routes", () => {
expect(call[2]).toEqual({ pauseAutomations: false });
});
it.sequential("previews a company from a multipart zip upload", async () => {
it("previews a company from a multipart zip upload", async () => {
const app = await createBoardApp();
const files = { "COMPANY.md": "---\nname: Test\n---\n" };
const zip = buildStoreZip(files, "paperclip");
@@ -1310,7 +1310,7 @@ describe.sequential("company portability routes", () => {
expect(call[0]).toEqual({ ...importMeta, source: { type: "inline", rootPath: "paperclip", files } });
});
it.sequential("runs a multipart zip import as an async board job via ?async=1", async () => {
it("runs a multipart zip import as an async board job via ?async=1", async () => {
let resolveImport: (value: ReturnType<typeof createImportResult>) => void = () => undefined;
const pendingImport = new Promise<ReturnType<typeof createImportResult>>((resolve) => {
resolveImport = resolve;
@@ -1347,7 +1347,7 @@ describe.sequential("company portability routes", () => {
expect(succeeded.body.job.importResult).toEqual(fullResult);
});
it.sequential("engages the async path for board sessions via ?async=1, not the stripped cloud header", async () => {
it("engages the async path for board sessions via ?async=1, not the stripped cloud header", async () => {
mockCompanyPortabilityService.importBundle.mockReturnValueOnce(new Promise(() => undefined));
const app = await createBoardApp();
@@ -1363,7 +1363,7 @@ describe.sequential("company portability routes", () => {
expect(accepted.body.statusUrl).toMatch(/^\/api\/companies\/import\/jobs\/import-/);
});
it.sequential("keeps a board import synchronous when neither async signal is present", async () => {
it("keeps a board import synchronous when neither async signal is present", async () => {
const app = await createBoardApp();
const res = await request(app)
@@ -1375,7 +1375,7 @@ describe.sequential("company portability routes", () => {
expect(res.body.company.id).toBe(companyId);
});
it.sequential("still engages the async path for cloud tenants via the x-paperclip-cloud-async-import header", async () => {
it("still engages the async path for cloud tenants via the x-paperclip-cloud-async-import header", async () => {
mockCompanyPortabilityService.importBundle.mockReturnValueOnce(new Promise(() => undefined));
const app = await createApp(cloudTenantActor());
@@ -1389,7 +1389,7 @@ describe.sequential("company portability routes", () => {
expect(accepted.body.statusUrl).toMatch(/^\/api\/companies\/import\/jobs\/tenant-import-/);
});
it.sequential("rejects a truncated zip upload without importing anything", async () => {
it("rejects a truncated zip upload without importing anything", async () => {
const app = await createBoardApp();
const zip = buildStoreZip({ "COMPANY.md": "---\nname: Test\n---\n" }, "paperclip");
const truncated = zip.subarray(0, 40);
@@ -26,7 +26,7 @@ import { errorHandler } from "../middleware/error-handler.js";
import { companySkillRoutes } from "../routes/company-skills.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
@@ -0,0 +1,337 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import express from "express";
import request from "supertest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, companies, companyMemberships, connectionGrants, createDb, issues, toolApplications, toolCatalogEntries, toolConnections, toolConnectionAppSnapshots } from "@paperclipai/db";
import { composioAppSetupSchema } from "@paperclipai/shared";
import { composioAppAccounts, composioAppSetupResult } from "../services/composio-app-setup.js";
import { toolAccessService } from "../services/tool-access.js";
import { toolAccessPolicyService } from "../services/tool-access-policy.js";
import { toolAccessRoutes } from "../routes/tool-access.js";
import { errorHandler } from "../middleware/error-handler.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
describe("Composio app authorization evidence", () => {
it("completes app setup without an agent selection", () => {
expect(composioAppSetupSchema.parse({ action: "complete" })).toEqual({ action: "complete" });
expect(composioAppSetupSchema.safeParse({ action: "complete", agentId: randomUUID() }).success).toBe(false);
});
it("keeps only the requested app's account identities and never treats missing or malformed evidence as an empty list", () => {
const response = { results: { gmail: { accounts: [{ id: "gmail", status: "ACTIVE" }] }, circleback_mcp: { accounts: [{ id: "ca_1", alias: "Work", status: "active", is_default: true, access_token: "secret" }] } } };
expect(composioAppAccounts(response, "circleback_mcp")).toEqual([{ id: "ca_1", alias: "Work", status: "ACTIVE", isDefault: true }]);
expect(composioAppAccounts(response, "missing")).toBeUndefined();
expect(composioAppAccounts({ circleback_mcp: { accounts: [] } }, "circleback_mcp")).toEqual([]);
expect(composioAppAccounts({ circleback_mcp: { accounts: [{ status: "active" }] } }, "circleback_mcp")).toBeUndefined();
expect(() => composioAppAccounts({ circleback_mcp: { error: "expired", accounts: [] } }, "circleback_mcp")).toThrow("could not check");
});
it("accepts a hosted link and only verifies the requested toolkit", () => {
expect(composioAppSetupResult({ results: { circleback_mcp: { redirect_url: "https://connect.composio.dev/link/test" } } }, "circleback_mcp"))
.toEqual({ status: "authorization_required", authorizationUrl: "https://connect.composio.dev/link/test" });
expect(composioAppSetupResult({ results: { gmail: { status: "ACTIVE" }, circleback_mcp: { accounts: [{ status: "INITIATED" }] } } }, "circleback_mcp"))
.toEqual({ status: "not_connected" });
expect(composioAppSetupResult({ result: { content: [{ type: "text", text: JSON.stringify({ results: { circleback_mcp: { accounts: [{ status: "ACTIVE" }] } } }) }] } }, "circleback_mcp"))
.toEqual({ status: "connected" });
});
it("rejects provider failures and untrusted handoff URLs", () => {
expect(() => composioAppSetupResult({ isError: true }, "circleback_mcp")).toThrow("could not configure");
expect(composioAppSetupResult({ redirect_url: "https://untrusted.example/link/test" }, "circleback_mcp")).toEqual({ status: "not_connected" });
});
it("recognizes the lowercase account status returned by the live Composio gateway", () => {
const response = { result: { content: [{ type: "text", text: JSON.stringify({
data: { results: { circleback_mcp: { toolkit: "circleback_mcp", status: "active", accounts: [{ status: "active", is_default: true }] } } },
error: null, successful: true,
}) }], isError: false } };
expect(composioAppSetupResult(response, "circleback_mcp")).toEqual({ status: "connected" });
expect(composioAppSetupResult(response, "gmail")).toEqual({ status: "not_connected" });
});
});
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("direct Composio setup", () => {
let db: ReturnType<typeof createDb>;
let cleanup: (() => Promise<void>) | undefined;
beforeAll(async () => {
const database = await startEmbeddedPostgresTestDatabase("paperclip-composio-setup-");
db = createDb(database.connectionString);
cleanup = database.cleanup;
}, 20_000);
afterAll(async () => { await cleanup?.(); });
async function fixture(syncToolkits: string[] = ["circleback_mcp", "notion", "hubspot"]) {
const [company] = await db.insert(companies).values({ name: "Composio direct setup", issuePrefix: randomUUID().slice(0, 6).toUpperCase() }).returning();
const userId = randomUUID();
await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: userId, membershipRole: "admin", status: "active" });
const [agent, otherAgent] = await db.insert(agents).values(["Default agent", "Other agent"].map(name => ({ companyId: company.id, name, role: "general" as const, status: "idle" as const, adapterType: "codex_local" }))).returning();
const [app] = await db.insert(toolApplications).values({ companyId: company.id, applicationKey: randomUUID(), name: "Composio", type: "mcp_http", metadata: { sourceTemplateKey: "composio" } }).returning();
const [connection] = await db.insert(toolConnections).values({ companyId: company.id, applicationId: app.id, uid: randomUUID(), name: "Composio account", transport: "mcp_remote", authKind: "none", credentialPolicy: "shared", status: "active", enabled: true, config: { sourceTemplateKey: "composio", url: "https://connect.composio.dev/mcp" } }).returning();
await db.insert(connectionGrants).values({ companyId: company.id, connectionId: connection.id, kind: "organization", isDefault: true, status: "active" });
const catalog = await db.insert(toolCatalogEntries).values([
["COMPOSIO_SEARCH_TOOLS", "read"], ["COMPOSIO_GET_TOOL_SCHEMAS", "read"], ["COMPOSIO_MULTI_EXECUTE_TOOL", "destructive"], ["COMPOSIO_MANAGE_CONNECTIONS", "destructive"],
].map(([toolName, riskLevel]) => ({ companyId: company.id, connectionId: connection.id, name: toolName, toolName, versionHash: "v1", status: "active" as const, riskLevel: riskLevel as "read" | "destructive" }))).returning();
const accountId = randomUUID();
let accounts = [{ id: accountId, alias: "Work", status: "initiated", is_default: true, access_token: "never-store-this-token" }];
let onList = async () => {};
let listResult: unknown = undefined;
let failedToolkit: string | undefined;
let mutationStatus = 200;
let applyMutation = true;
const operations: string[] = [];
const batches: { name: string; action: string; account_id?: string; alias?: string }[][] = [];
const access = toolAccessService(db, { composioAppToolkits: syncToolkits, remoteHttpRequest: async (_url, init) => {
const request = JSON.parse(String(init.body));
const toolkits = request.params.arguments.toolkits;
expect(request.params.name).toBe("COMPOSIO_MANAGE_CONNECTIONS");
batches.push(toolkits);
const results: Record<string, unknown> = {};
for (const toolkit of toolkits) {
operations.push(toolkit.action);
if (toolkit.action === "list") await onList();
else if (mutationStatus !== 200) return new Response("unconfirmed", { status: mutationStatus });
if (applyMutation && toolkit.name === "circleback_mcp") {
if (toolkit.action === "rename") accounts = accounts.map(account => account.id === toolkit.account_id ? { ...account, alias: toolkit.alias } : account);
if (toolkit.action === "remove") accounts = accounts.filter(account => account.id !== toolkit.account_id);
}
results[toolkit.name] = toolkit.name === failedToolkit ? { toolkit: toolkit.name, accounts: [{ status: "ACTIVE" }] }
: toolkit.action === "add" ? { redirect_url: "https://connect.composio.dev/link/test" }
: listResult ?? { toolkit: toolkit.name, accounts: toolkit.name === "circleback_mcp" ? accounts : [] };
}
return Response.json({ jsonrpc: "2.0", id: request.id, result: { structuredContent: { data: { results } } } });
} });
return { company, agent, otherAgent, connection, catalog, access, operations, batches, accountId, actor: { actorType: "user" as const, actorId: userId },
activate: () => { accounts = accounts.map(account => ({ ...account, status: "active" })); },
disconnect: () => { accounts = []; },
setListResult: (result: unknown) => { listResult = result; },
failToolkit: (toolkit: string) => { failedToolkit = toolkit; },
setMutationStatus: (status: number) => { mutationStatus = status; },
refuseMutation: () => { applyMutation = false; },
onList: (callback: () => Promise<void>) => { onList = callback; } };
}
async function configureAccess(f: Awaited<ReturnType<typeof fixture>>, agentId?: string) {
await f.access.putConnectionInstalls(f.connection.id, { installs: agentId
? [{ targetType: "agent", targetId: agentId }] : [{ targetType: "company", targetId: f.company.id }] }, f.actor);
await f.access.finishGalleryAppConnection(f.company.id, f.connection.id, {
enabledCatalogEntryIds: f.catalog.map(tool => tool.id),
askFirstCatalogEntryIds: f.catalog.filter(tool => ["COMPOSIO_MULTI_EXECUTE_TOOL", "COMPOSIO_MANAGE_CONNECTIONS"].includes(tool.toolName)).map(tool => tool.id),
access: agentId ? { agentIds: [agentId] } : "all_agents",
}, f.actor);
}
it("generates and verifies app sign-in without a task or new access grants, preserving company-wide gateway defaults", async () => {
const f = await fixture();
await configureAccess(f);
const grants = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connection.id));
const installs = await f.access.listConnectionInstalls(f.connection.id, f.company.id);
const before = (await f.access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools;
expect(await f.access.setupComposioApp(f.connection.id, "circleback_mcp", { action: "start" }, f.actor)).toMatchObject({ status: "authorization_required" });
expect(f.operations).toEqual(["list", "add"]);
expect(await db.select().from(issues).where(eq(issues.companyId, f.company.id))).toHaveLength(0);
await expect(f.access.setupComposioApp(f.connection.id, "circleback_mcp", { action: "complete" }, f.actor)).rejects.toThrow("Finish connecting");
expect((await f.access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toEqual(before);
f.activate();
await f.access.setupComposioApp(f.connection.id, "circleback_mcp", { action: "complete" }, f.actor);
expect((await f.access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toEqual(before);
expect((await f.access.getEffectiveProfilesForAgent(f.company.id, f.otherAgent.id)).allowedTools.map(t => t.toolName).sort()).toEqual(f.catalog.map(t => t.toolName).sort());
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connection.id))).toEqual(grants);
expect(await f.access.listConnectionInstalls(f.connection.id, f.company.id)).toEqual(installs);
const tool = f.catalog.find(t => t.toolName === "COMPOSIO_MULTI_EXECUTE_TOOL")!;
const decision = await toolAccessPolicyService(db).decide({ companyId: f.company.id, actor: { actorType: "agent", actorId: f.agent.id, agentId: f.agent.id }, request: { connectionId: f.connection.id, catalogEntryId: tool.id, toolName: tool.toolName, arguments: {} } });
expect(decision.decision).toBe("require_approval");
expect(f.operations.filter(action => action === "add")).toHaveLength(1);
});
it("does not create another link for an active account and rejects unknown toolkits", async () => {
const f = await fixture();
f.activate();
expect(await f.access.setupComposioApp(f.connection.id, "circleback_mcp", { action: "start" }, f.actor)).toEqual({ status: "connected" });
expect(f.operations).toEqual(["list"]);
await expect(f.access.setupComposioApp(f.connection.id, "invented", { action: "start" }, f.actor)).rejects.toThrow("not in the catalog");
});
it("keeps meta-tool execution behind approval even if its catalog risk is read", async () => {
const f = await fixture();
const tool = f.catalog.find(t => t.toolName === "COMPOSIO_MULTI_EXECUTE_TOOL")!;
await db.update(toolCatalogEntries).set({ riskLevel: "read" }).where(eq(toolCatalogEntries.id, tool.id));
await configureAccess(f);
f.activate();
await f.access.setupComposioApp(f.connection.id, "circleback_mcp", { action: "complete" }, f.actor);
const decision = await toolAccessPolicyService(db).decide({ companyId: f.company.id, actor: { actorType: "agent", actorId: f.agent.id, agentId: f.agent.id }, request: { connectionId: f.connection.id, catalogEntryId: tool.id, toolName: tool.toolName, arguments: {} } });
expect(decision.decision).toBe("require_approval");
});
it("does not complete app setup if the gateway is disabled while checking Composio", async () => {
const f = await fixture();
f.activate();
f.onList(async () => { await db.update(toolConnections).set({ enabled: false }).where(eq(toolConnections.id, f.connection.id)); });
await expect(f.access.setupComposioApp(f.connection.id, "circleback_mcp", { action: "complete" }, f.actor)).rejects.toThrow("no longer active");
expect((await f.access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toHaveLength(0);
});
it("preserves a saved gateway's restricted access when another app is connected", async () => {
const f = await fixture();
await configureAccess(f, f.agent.id);
const before = (await f.access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools;
f.activate();
await f.access.setupComposioApp(f.connection.id, "circleback_mcp", { action: "complete" }, f.actor);
expect((await f.access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toEqual(before);
expect((await f.access.getEffectiveProfilesForAgent(f.company.id, f.otherAgent.id)).allowedTools).toHaveLength(0);
});
it("rejects agents, non-managers, and other companies before calling Composio", async () => {
const f = await fixture();
const userId = randomUUID();
await db.insert(companyMemberships).values({ companyId: f.company.id, principalType: "user", principalId: userId, membershipRole: "member", status: "active" });
let providerCalls = 0;
const member: Express.Request["actor"] = { type: "board", userId, source: "session", isInstanceAdmin: false, companyIds: [f.company.id], memberships: [{ companyId: f.company.id, membershipRole: "member", status: "active" }] };
const actors: [Express.Request["actor"], number][] = [
[member, 403],
[{ ...member, companyIds: [randomUUID()], memberships: [] }, 404],
[{ type: "agent", agentId: f.agent.id, companyId: f.company.id, source: "agent_key" }, 403],
];
for (const [actor, status] of actors) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.actor = actor; next(); });
app.use("/api", toolAccessRoutes(db, { remoteHttpRequest: async () => { providerCalls++; throw new Error("Must not reach provider"); } }));
app.use(errorHandler);
await request(app).post(`/api/tool-connections/${f.connection.id}/composio/apps/circleback_mcp/setup`).send({ action: "start" }).expect(status);
await request(app).get(`/api/tool-connections/${f.connection.id}/composio/apps`).expect(status);
await request(app).post(`/api/tool-connections/${f.connection.id}/composio/apps/sync`).send({ force: true }).expect(status);
await request(app).post(`/api/tool-connections/${f.connection.id}/composio/apps/refresh`).send({ toolkits: ["circleback_mcp"] }).expect(status);
await request(app).post(`/api/tool-connections/${f.connection.id}/composio/apps/circleback_mcp/accounts`).send({ action: "remove", accountId: f.accountId }).expect(status);
}
expect(providerCalls).toBe(0);
});
it("persists safe observations per user and gateway, and refreshes known apps without creating accounts", async () => {
const f = await fixture();
f.activate();
const result = await f.access.refreshComposioApps(f.connection.id, ["circleback_mcp", "hubspot"], f.actor);
expect(result.apps.find(app => app.toolkit === "circleback_mcp")).toMatchObject({ connectionId: f.connection.id, status: "connected", accounts: [{ id: f.accountId, alias: "Work", status: "ACTIVE" }] });
expect(JSON.stringify(await db.select().from(toolConnectionAppSnapshots).where(eq(toolConnectionAppSnapshots.connectionId, f.connection.id)))).not.toContain("never-store-this-token");
expect((await f.access.listComposioApps(f.connection.id, { ...f.actor, actorId: randomUUID() })).apps).toEqual([]);
f.operations.length = 0;
f.disconnect();
const refreshed = await f.access.refreshComposioApps(f.connection.id, [], f.actor);
expect(f.operations).toEqual(["list"]);
expect(refreshed.apps.find(app => app.toolkit === "circleback_mcp")).toMatchObject({ status: "not_connected", accounts: [] });
expect((await f.access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toEqual([]);
});
it("rechecks apps from earlier direct setup and hides observations after a gateway identity changes", async () => {
const f = await fixture();
f.activate();
await f.access.setupComposioApp(f.connection.id, "circleback_mcp", { action: "start" }, f.actor);
await db.delete(toolConnectionAppSnapshots).where(eq(toolConnectionAppSnapshots.connectionId, f.connection.id));
expect((await f.access.refreshComposioApps(f.connection.id, [], f.actor)).apps).toHaveLength(1);
await db.update(toolConnections).set({ config: { ...f.connection.config, url: "https://connect.composio.dev/other-mcp" } }).where(eq(toolConnections.id, f.connection.id));
expect((await f.access.listComposioApps(f.connection.id, f.actor)).apps).toEqual([]);
});
async function finishSync(f: Awaited<ReturnType<typeof fixture>>) {
await vi.waitFor(async () => expect((await f.access.listComposioApps(f.connection.id, f.actor)).sync.status).not.toBe("syncing"), { timeout: 10_000 });
return f.access.listComposioApps(f.connection.id, f.actor);
}
it("discovers already connected apps across the catalog without a visible-page filter or access changes", async () => {
const f = await fixture(); f.activate();
const grants = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connection.id));
await f.access.syncComposioApps(f.connection.id, false, f.actor);
const result = await finishSync(f);
expect(result.sync).toMatchObject({ status: "ready", checked: 3, total: 3, failed: 0, coverage: "supported_catalog" });
expect(result.apps.find(app => app.toolkit === "circleback_mcp")).toMatchObject({ status: "connected", errorAt: null });
expect(f.batches.flat().map(tool => tool.name)).toEqual(["circleback_mcp", "notion", "hubspot"]);
expect(f.operations.every(action => action === "list")).toBe(true);
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connection.id))).toEqual(grants);
expect(await db.select().from(issues).where(eq(issues.companyId, f.company.id))).toHaveLength(0);
await f.access.syncComposioApps(f.connection.id, false, f.actor);
expect(f.operations).toHaveLength(3); // Fresh inventory does not start another scan.
f.disconnect();
await f.access.syncComposioApps(f.connection.id, true, f.actor);
expect((await finishSync(f)).apps.find(app => app.toolkit === "circleback_mcp")).toMatchObject({ status: "not_connected", accounts: [] });
});
it("shares a lease between concurrent syncs and bounds provider batches", async () => {
const { COMPOSIO_APP_TOOLKITS } = await import("@paperclipai/shared/aggregator-app-catalog");
const names = ["circleback_mcp", ...COMPOSIO_APP_TOOLKITS.filter(name => name !== "circleback_mcp").slice(0, 35)];
const f = await fixture(names); f.activate();
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
f.onList(() => held);
await Promise.all([f.access.syncComposioApps(f.connection.id, true, f.actor), f.access.syncComposioApps(f.connection.id, true, f.actor)]);
expect(f.batches).toHaveLength(1);
release();
const result = await finishSync(f);
expect(result.sync).toMatchObject({ status: "ready", total: 36, checked: 36 });
expect(f.batches.map(batch => batch.length)).toEqual([32, 4]);
expect(new Set(f.batches.flat().map(tool => tool.name)).size).toBe(36);
});
it("keeps failed account observations unverified and restores them after a successful sync", async () => {
const f = await fixture(); f.activate();
await f.access.syncComposioApps(f.connection.id, true, f.actor); await finishSync(f);
f.setListResult({ accounts: [{ status: "ACTIVE" }] });
await f.access.syncComposioApps(f.connection.id, true, f.actor);
const failed = await finishSync(f);
expect(failed.sync.status).toBe("error");
expect(failed.apps.find(app => app.toolkit === "circleback_mcp")).toMatchObject({ status: "connected", accounts: [{ id: f.accountId }] });
expect(failed.apps.find(app => app.toolkit === "circleback_mcp")?.errorAt).toBeTruthy();
f.setListResult(undefined);
await f.access.syncComposioApps(f.connection.id, true, f.actor);
expect((await finishSync(f)).apps.find(app => app.toolkit === "circleback_mcp")?.errorAt).toBeNull();
});
it("reports an incomplete sync when a toolkit without prior accounts fails", async () => {
const f = await fixture(); f.activate(); f.failToolkit("hubspot");
await f.access.syncComposioApps(f.connection.id, true, f.actor);
const result = await finishSync(f);
expect(result.sync).toMatchObject({ status: "error", checked: 3, total: 3, failed: 1 });
expect(result.sync.error).toBeTruthy();
expect(result.apps.find(app => app.toolkit === "circleback_mcp")?.accounts[0].id).toBe(f.accountId);
expect(result.apps.find(app => app.toolkit === "hubspot")).toBeUndefined();
});
it("rejects observations when credentials change while a provider request is in flight", async () => {
const f = await fixture(); f.activate();
f.onList(async () => { await db.update(toolConnections).set({ config: { ...f.connection.config, url: "https://connect.composio.dev/replaced" } }).where(eq(toolConnections.id, f.connection.id)); });
await f.access.syncComposioApps(f.connection.id, true, f.actor);
await vi.waitFor(() => expect(f.operations.length).toBeGreaterThan(0));
await vi.waitFor(async () => expect((await f.access.listComposioApps(f.connection.id, f.actor)).sync.status).toBe("idle"));
expect((await f.access.listComposioApps(f.connection.id, f.actor)).apps).toEqual([]);
});
it("renames and removes only a verified account in the selected toolkit, and confirms both changes", async () => {
const f = await fixture(); f.activate();
const grants = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connection.id));
await expect(f.access.manageComposioAppAccount(f.connection.id, "hubspot", { action: "remove", accountId: f.accountId }, f.actor)).rejects.toThrow("no longer available");
expect(f.operations).toEqual(["list"]);
const renamed = await f.access.manageComposioAppAccount(f.connection.id, "circleback_mcp", { action: "rename", accountId: f.accountId, alias: "Meetings" }, f.actor);
expect(renamed.apps.find(app => app.toolkit === "circleback_mcp")?.accounts[0].alias).toBe("Meetings");
expect(f.batches.some(batch => batch.some(op => op.action === "rename" && op.account_id === f.accountId && op.alias === "Meetings"))).toBe(true);
const removed = await f.access.manageComposioAppAccount(f.connection.id, "circleback_mcp", { action: "remove", accountId: f.accountId }, f.actor);
expect(removed.apps.find(app => app.toolkit === "circleback_mcp")).toMatchObject({ status: "not_connected", accounts: [] });
expect((await db.select().from(toolConnections).where(eq(toolConnections.id, f.connection.id)))[0]).toMatchObject({ status: "active", enabled: true });
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connection.id))).toEqual(grants);
});
it("adds another account without a task or an agent grant, even when an account is already active", async () => {
const f = await fixture(); f.activate();
const result = await f.access.manageComposioAppAccount(f.connection.id, "circleback_mcp", { action: "add" }, f.actor);
expect(result.authorizationUrl).toBe("https://connect.composio.dev/link/test");
expect(f.operations).toEqual(["add"]);
expect(await db.select().from(issues).where(eq(issues.companyId, f.company.id))).toHaveLength(0);
expect((await f.access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toEqual([]);
});
it("retains previous observations when a list result is incomplete or fails", async () => {
const f = await fixture(); f.activate();
await f.access.refreshComposioApps(f.connection.id, ["circleback_mcp"], f.actor);
f.setListResult({ toolkit: "circleback_mcp", accounts: [{ status: "ACTIVE" }] });
await expect(f.access.refreshComposioApps(f.connection.id, ["circleback_mcp"], f.actor)).rejects.toThrow("complete account list");
expect((await f.access.listComposioApps(f.connection.id, f.actor)).apps[0].accounts[0].id).toBe(f.accountId);
f.setListResult({ toolkit: "circleback_mcp", error: "expired", accounts: [] });
await expect(f.access.refreshComposioApps(f.connection.id, ["circleback_mcp"], f.actor)).rejects.toThrow("could not check");
expect((await f.access.listComposioApps(f.connection.id, f.actor)).apps[0].status).toBe("connected");
});
it("never retries an uncertain mutation or reports an unconfirmed rename as success", async () => {
const f = await fixture(); f.activate();
f.setMutationStatus(400);
await expect(f.access.manageComposioAppAccount(f.connection.id, "circleback_mcp", { action: "remove", accountId: f.accountId }, f.actor)).rejects.toThrow("has not confirmed");
expect(f.operations.filter(op => op === "remove")).toHaveLength(1);
f.setMutationStatus(200); f.refuseMutation();
await expect(f.access.manageComposioAppAccount(f.connection.id, "circleback_mcp", { action: "rename", accountId: f.accountId, alias: "Meetings" }, f.actor)).rejects.toThrow("has not confirmed");
expect((await f.access.listComposioApps(f.connection.id, f.actor)).apps[0].accounts[0].alias).toBe("Work");
});
});
@@ -0,0 +1,262 @@
import { tmpdir } from "node:os";
import { join } from "node:path";
import { nativeSystemInstructions } from "../../../packages/paperclip-runner/src/backends/runtime-context.js";
import type { NativeExecutionInput } from "../vendor/paperclip-runner/index.js";
import { randomUUID } from "node:crypto";
import { access, mkdtemp, writeFile, rm, readFile } from "node:fs/promises";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { agents, companies, companyMemberships, connectionGrants, createDb, heartbeatRuns, issues, toolApplications, toolCatalogEntries, toolConnections, toolPolicies, toolProfileBindings, toolActionRequests } from "@paperclipai/db";
import { createToolGatewayService } from "../services/tool-gateway.js";
import { toolDiscoveryScheduler } from "../services/tool-discovery-scheduler.js";
import { toolAccessService, projectedConnectionToolArguments, projectedConnectionToolInputSchema } from "../services/tool-access.js";
import { composeConnectionInstructions, prepareConnectionInstructionDelivery } from "../services/connection-instructions.js";
import { renderPaperclipWakePrompt } from "@paperclipai/adapter-utils/server-utils";
import { canonicalNativeRuntimeContextDigest, parseNativeRuntimeContext } from "../vendor/paperclip-runner/index.js";
import { nativeRuntimeContextFixture } from "../services/native-runtime/runtime-context.test-fixture.js";
import { execute as executeProcess } from "../adapters/process/execute.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
const support = await getEmbeddedPostgresTestSupport();
const paragraph = "Use the release handbook before changing a deployment. Cite the relevant release checklist.";
async function createRunFixture(db: ReturnType<typeof createDb>) {
const company = await db.insert(companies).values({
name: `Gateway ${randomUUID()}`,
issuePrefix: `TG${randomUUID().slice(0, 6).toUpperCase()}`,
}).returning().then((rows) => rows[0]!);
const agent = await db.insert(agents).values({
companyId: company.id,
name: `Gateway Agent ${randomUUID()}`,
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
}).returning().then((rows) => rows[0]!);
const issue = await db.insert(issues).values({
companyId: company.id,
title: "Gateway approval work",
status: "in_progress",
assigneeAgentId: agent.id,
}).returning().then((rows) => rows[0]!);
const run = await db.insert(heartbeatRuns).values({
companyId: company.id,
agentId: agent.id,
invocationSource: "assignment",
status: "running",
contextSnapshot: { issueId: issue.id },
}).returning().then((rows) => rows[0]!);
return { company, agent, issue, run };
}
async function createRemoteMcpToolFixture(db: ReturnType<typeof createDb>, companyId: string) {
const application = await db.insert(toolApplications).values({
companyId,
applicationKey: `remote-${randomUUID().slice(0, 8)}`,
name: "Remote MCP",
type: "mcp_http",
status: "active",
}).returning().then((rows) => rows[0]!);
const connection = await db.insert(toolConnections).values({
companyId,
applicationId: application.id,
name: "Remote connection",
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: true,
healthStatus: "ok",
// Use a public IP literal so protocol tests remain independent of DNS while
// still exercising the production egress guard and their global fetch stub.
credentialPolicy: "shared",
config: { url: "https://8.8.8.8/mcp" },
}).returning().then((rows) => rows[0]!);
await db.insert(connectionGrants).values({
companyId,
connectionId: connection.id,
kind: "organization",
credentialSecretRefs: [],
status: "active",
isDefault: true,
});
const catalogEntry = await db.insert(toolCatalogEntries).values({
companyId,
applicationId: application.id,
connectionId: connection.id,
entryKind: "tool",
name: "needs_input",
toolName: "needs_input",
title: "Needs input",
riskLevel: "read",
isReadOnly: true,
status: "active",
versionHash: randomUUID(),
schemaHash: randomUUID(),
}).returning().then((rows) => rows[0]!);
return { application, connection, catalogEntry };
}
(support.supported ? describe : describe.skip)("generic connection instruction delivery", () => {
let db: ReturnType<typeof createDb>;
let temp: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
beforeAll(async () => { temp = await startEmbeddedPostgresTestDatabase("paperclip-connection-instructions-"); db = createDb(temp.connectionString); }, 30000);
afterAll(async () => { await temp?.cleanup(); });
async function fixture() {
const run = await createRunFixture(db);
const connection = await createRemoteMcpToolFixture(db, run.company.id);
const service = toolAccessService(db);
await service.updateConnection(connection.connection.id, { agentInstructions: { enabled: true, text: paragraph } });
await service.finishGalleryAppConnection(run.company.id, connection.connection.id, {
enabledCatalogEntryIds: [connection.catalogEntry.id], askFirstCatalogEntryIds: [], access: { agentIds: [run.agent.id] },
});
const gateway = createToolGatewayService(db);
const binding = { companyId: run.company.id, agentId: run.agent.id, runId: run.run.id };
return { ...run, ...connection, gateway, service, binding, resolve: () => gateway.resolveConnectionInstructionsForRun(binding) };
}
it("keeps guidance stable and checks revocation even when the discovery queue is saturated", async () => {
const f = await fixture();
const snapshot = await f.resolve();
expect(snapshot?.text).toContain(paragraph);
let release!: () => void;
const barrier = new Promise<void>((resolve) => { release = resolve; });
const listings = Array.from({ length: 34 }, () => toolDiscoveryScheduler.run(() => barrier));
try {
expect(await f.resolve()).toEqual(snapshot);
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.connectionId, f.connection.id));
expect(await f.resolve()).toBeNull();
} finally {
release();
await Promise.all(listings);
}
});
it("persists a non-memory paragraph and captures it in actual process input and both prompt composers", async () => {
const f = await fixture();
expect((await f.service.getConnection(f.connection.id)).agentInstructions).toEqual({ enabled: true, text: paragraph });
const context: Record<string, unknown> = { connectionInstructions: { text: "forged" }, paperclipWake: { connectionInstructions: { text: "forged wake" } } };
const config = await prepareConnectionInstructionDelivery({ resolve: f.resolve, context, config: { paperclipConnectionInstructions: { text: "forged config" } }, native: false });
const snapshot = config.paperclipConnectionInstructions as { text: string; digest: string };
expect(snapshot.text).toContain(paragraph);
expect(snapshot.text).not.toContain("forged");
expect(renderPaperclipWakePrompt(context.paperclipWake)).toContain(paragraph);
const runtimeContext = { ...nativeRuntimeContextFixture(), connectionInstructions: snapshot };
runtimeContext.aggregateDigest = canonicalNativeRuntimeContextDigest(runtimeContext);
const parsed = parseNativeRuntimeContext(runtimeContext);
const bundle = await mkdtemp(join(tmpdir(), "connection-instruction-input-"));
try {
await writeFile(join(bundle, "AGENTS.md"), "Agent entry instructions.");
parsed.instructions.bundle.rootPath = bundle;
expect(nativeSystemInstructions({ runtimeContext: parsed } as NativeExecutionInput)).toContain(paragraph);
} finally { await rm(bundle, { recursive: true, force: true }); }
expect(() => parseNativeRuntimeContext({ ...runtimeContext, connectionInstructions: { ...snapshot, text: "forged" } })).toThrow();
expect(parseNativeRuntimeContext(nativeRuntimeContextFixture()).connectionInstructions).toBeUndefined();
const captured: string[] = [];
const result = await executeProcess({ runId: f.run.id, agent: { ...f.agent, adapterType: "process" }, runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, context,
config: { command: process.execPath, args: ["-e", "const fs=require('node:fs');const file=process.env.PAPERCLIP_CONNECTION_INSTRUCTIONS_FILE;console.log(JSON.stringify({file,mode:fs.statSync(file).mode&511,snapshot:JSON.parse(fs.readFileSync(file,'utf8'))}))"], env: { PAPERCLIP_CONNECTION_INSTRUCTIONS_FILE: "/forged" } },
onLog: async (_stream, text) => { captured.push(text); },
});
expect(result.exitCode).toBe(0);
const received = JSON.parse(captured.join("").trim());
expect(received.snapshot).toEqual(snapshot);
expect(received.mode).toBe(0o600);
await expect(access(received.file)).rejects.toThrow();
});
it("backfills existing template connections without replacing custom text or opt-outs", async () => {
const f = await fixture();
await db.update(toolConnections).set({ agentInstructions: null, config: { ...f.connection.config, sourceTemplateKey: "mem0" } }).where(eq(toolConnections.id, f.connection.id));
const migration = await readFile(new URL("../../../packages/db/src/migrations/0298_connection_agent_instructions.sql", import.meta.url), "utf8");
await db.$client.unsafe(migration);
const populated = await f.service.getConnection(f.connection.id);
expect(populated.agentInstructions).toMatchObject({ enabled: true, template: { id: "mem0.usage", version: 1 } });
await f.service.updateConnection(f.connection.id, { agentInstructions: { enabled: false, text: paragraph } });
await db.$client.unsafe(migration);
expect((await f.service.getConnection(f.connection.id)).agentInstructions).toEqual({ enabled: false, text: paragraph });
});
it("freezes active turns and removes instructions on the next turn without removing tools", async () => {
const f = await fixture();
const first = await f.resolve();
await f.service.updateConnection(f.connection.id, { agentInstructions: { enabled: true, text: "New release procedure." } });
expect((await f.resolve())?.digest).not.toBe(first?.digest);
expect(first?.text).toContain(paragraph);
await f.service.updateConnection(f.connection.id, { agentInstructions: { enabled: false, text: paragraph } });
expect(await f.resolve()).toBeNull();
const session = await f.gateway.createSession(f.binding);
expect((await f.gateway.listToolsForSession(session.token)).some(tool => tool.connectionId === f.connection.id)).toBe(true);
const context: Record<string, unknown> = { connectionInstructions: first, paperclipWake: { connectionInstructions: first } };
const config = await prepareConnectionInstructionDelivery({ resolve: f.resolve, context, config: { paperclipConnectionInstructions: first }, native: false });
expect(config.paperclipConnectionInstructions).toBeNull();
expect(renderPaperclipWakePrompt(context.paperclipWake)).not.toContain(paragraph);
});
it("follows assignment, action policy, grant revocation, and company boundaries", async () => {
const f = await fixture();
expect(await f.resolve()).not.toBeNull();
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.connectionId, f.connection.id));
expect(await f.resolve()).toBeNull();
await db.update(connectionGrants).set({ status: "active" }).where(eq(connectionGrants.connectionId, f.connection.id));
const [block] = await db.insert(toolPolicies).values({ companyId: f.company.id, name: "Block handbook", policyType: "block", priority: 1000, selectors: { connectionId: f.connection.id } }).returning();
expect(await f.resolve()).toBeNull();
await db.delete(toolPolicies).where(eq(toolPolicies.id, block.id));
await db.delete(toolProfileBindings).where(eq(toolProfileBindings.targetId, f.agent.id));
expect(await f.resolve()).toBeNull();
const other = await fixture();
await expect(f.gateway.resolveConnectionInstructionsForRun({ ...f.binding, companyId: other.company.id })).rejects.toMatchObject({ status: 403 });
await expect(f.gateway.resolveConnectionInstructionsForRun({ ...f.binding, agentId: other.agent.id })).rejects.toMatchObject({ status: 403 });
});
it("invalidates a pending approval when the configured Honcho workspace changes", async () => {
const f = await fixture();
const config = { ...f.connection.config, sourceTemplateKey: "honcho", connectionMethodKey: "mcp-api-key", methodConfig: { workspaceId: "before" } };
await f.service.updateConnection(f.connection.id, { config });
await db.update(toolCatalogEntries).set({ inputSchema: { type: "object", properties: { workspace_id: { type: "string" }, query: { type: "string" } }, required: ["workspace_id"] } }).where(eq(toolCatalogEntries.id, f.catalogEntry.id));
await f.service.finishGalleryAppConnection(f.company.id, f.connection.id, { enabledCatalogEntryIds: [f.catalogEntry.id], askFirstCatalogEntryIds: [f.catalogEntry.id], access: { agentIds: [f.agent.id] } });
let providerCalls = 0;
const gateway = createToolGatewayService(db, { toolActionSigningSecret: "instruction-workspace-test", remoteHttpRequest: async () => { providerCalls++; throw new Error("Stale approval must not reach Honcho"); } });
const session = await gateway.createSession(f.binding);
const tool = (await gateway.listToolsForSession(session.token)).find(tool => tool.connectionId === f.connection.id)!;
expect(JSON.stringify(tool.parametersSchema)).not.toContain("workspace_id");
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: { query: "release", workspace_id: "forged" } })).rejects.toMatchObject({ reasonCode: "approval_required" });
const [approval] = await db.select().from(toolActionRequests).where(eq(toolActionRequests.issueId, f.issue.id));
await f.service.updateConnection(f.connection.id, { config: { ...config, methodConfig: { workspaceId: "after" } } });
await expect(gateway.approveActionRequest({ companyId: f.company.id, actionRequestId: approval.id, actor: { userId: "reviewer" } })).rejects.toMatchObject({ reasonCode: "approved_tool_target_changed" });
expect(providerCalls).toBe(0);
});
it("requires the responsible person's active membership and personal identity", async () => {
const f = await fixture();
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, f.connection.id));
await db.update(connectionGrants).set({ kind: "user", subjectUserId: "instruction-owner", isDefault: false }).where(eq(connectionGrants.connectionId, f.connection.id));
expect(await f.resolve()).toBeNull();
await db.insert(companyMemberships).values({ companyId: f.company.id, principalType: "user", principalId: "instruction-owner", membershipRole: "member", status: "active" });
await db.update(heartbeatRuns).set({ responsibleUserId: "instruction-owner" }).where(eq(heartbeatRuns.id, f.run.id));
expect(await f.resolve()).not.toBeNull();
await db.update(companyMemberships).set({ status: "inactive" }).where(eq(companyMemberships.companyId, f.company.id));
expect(await f.resolve()).toBeNull();
});
});
describe("Honcho workspace configuration", () => {
const schema = { type: "object", properties: { workspace_id: { type: "string" }, query: { type: "string" } }, required: ["workspace_id", "query"] };
const connection = { config: { sourceTemplateKey: "honcho", connectionMethodKey: "mcp-api-key", methodConfig: { workspaceId: "team-workspace" } } } as typeof toolConnections.$inferSelect;
it("projects the workspace and hides its argument while keeping old unconfigured tools usable", () => {
expect(projectedConnectionToolArguments(connection, { workspace_id: "forged", query: "decisions" }, "query", schema)).toEqual({ workspace_id: "team-workspace", query: "decisions" });
expect(projectedConnectionToolInputSchema(connection, schema, "query")).toEqual({ type: "object", properties: { query: { type: "string" } }, required: ["query"] });
const old = { ...connection, config: { ...connection.config, methodConfig: {} } };
expect(projectedConnectionToolArguments(old, { workspace_id: "chosen" }, "query", schema)).toEqual({ workspace_id: "chosen" });
const source = { connection: { ...old, id: "honcho", name: "Honcho", agentInstructions: { enabled: true, text: paragraph } }, grantId: "grant" };
expect(composeConnectionInstructions([source])).toBeNull();
const configured = composeConnectionInstructions([{ ...source, connection: { ...source.connection, config: connection.config } }]);
expect(configured?.text).toContain('"workspaceId":"team-workspace"');
const changed = composeConnectionInstructions([{ ...source, connection: { ...source.connection, config: { ...connection.config, methodConfig: { workspaceId: "other" } } } }]);
expect(changed?.digest).not.toBe(configured?.digest);
const legacy = { ...source, connection: { ...source.connection, config: {}, transportConfig: connection.config } };
expect(composeConnectionInstructions([legacy])).toEqual(configured);
expect(projectedConnectionToolArguments({ ...connection, config: {}, transportConfig: connection.config }, { workspace_id: "forged" }, "query", schema)).toEqual({ workspace_id: "team-workspace" });
expect(composeConnectionInstructions([{ ...legacy, connection: { ...legacy.connection, transportConfig: old.config } }])).toBeNull();
const methodChanged = { ...legacy, connection: { ...legacy.connection, config: { connectionMethodKey: "new-method" } } };
expect(composeConnectionInstructions([methodChanged])?.digest).not.toBe(configured?.digest);
});
});
@@ -24,6 +24,8 @@ import {
toolConnections,
toolProfileBindings,
toolProfiles,
toolPolicies,
toolProfileEntries,
userSecretDefinitions,
} from "@paperclipai/db";
import type { RuntimeToolsTokenClaims } from "../runtime-tools-token.js";
@@ -32,6 +34,8 @@ import { connectionIntentDeliveryService } from "../services/connection-intent-d
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
import { materializeNativeInteractionResponses } from "../services/native-runtime/native-interaction-bridge.js";
import { toolAccessService } from "../services/tool-access.js";
import { toolAccessPolicyService } from "../services/tool-access-policy.js";
import { connectionIntentService } from "../services/connection-intents.js";
import {
getEmbeddedPostgresTestSupport,
@@ -42,6 +46,15 @@ const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describe("wakeConnectionIntentAfterResolution", () => {
it("preserves the fresh-session fence for repaired provider authentication", async () => {
const wakeup = vi.fn().mockResolvedValue(null);
await wakeConnectionIntentAfterResolution({ wakeup } as Parameters<typeof wakeConnectionIntentAfterResolution>[0], {
loaded: { issue: { id: "issue-1", assigneeAgentId: "agent-1", status: "in_progress" }, interaction: { id: "interaction-1", payload: { purpose: "ai" } } },
status: "accepted", actorId: "user-1",
});
expect(wakeup.mock.calls[0][1].contextSnapshot).toMatchObject({ forceFreshSession: true });
expect(wakeup.mock.calls[0][1].contextSnapshot.refreshTools).toBeUndefined();
});
it("preserves resolved interaction evidence in the queued run snapshot", async () => {
const wakeup = vi.fn().mockResolvedValue(null);
await wakeConnectionIntentAfterResolution(
@@ -67,8 +80,10 @@ describe("wakeConnectionIntentAfterResolution", () => {
interactionResolvedAt: "2026-08-28T13:30:00.000Z",
mutation: "interaction",
wakeReason: "issue_commented",
refreshTools: true,
}),
}));
expect(wakeup.mock.calls[0][1].contextSnapshot.forceFreshSession).toBeUndefined();
});
});
@@ -858,4 +873,105 @@ describeEmbeddedPostgres("connectionIntentService", () => {
});
async function accessFixture() {
const [company] = await db.insert(companies).values({ name: "Grant fixture", issuePrefix: `G${randomUUID().slice(0, 7)}` }).returning();
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Default agent", role: "general", adapterType: "process" }).returning();
const [otherAgent] = await db.insert(agents).values({ companyId: company.id, name: "Other agent", role: "general", adapterType: "process" }).returning();
await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: "grant-user", membershipRole: "owner", status: "active" });
const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Connect Circleback", status: "in_progress", assigneeAgentId: agent.id }).returning();
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, status: "running", responsibleUserId: "grant-user", contextSnapshot: { issueId: issue.id } }).returning();
const [application] = await db.insert(toolApplications).values({ companyId: company.id, applicationKey: randomUUID(), name: "Composio", type: "mcp_http", metadata: { sourceTemplateKey: "composio" } }).returning();
const [connection] = await db.insert(toolConnections).values({ companyId: company.id, applicationId: application.id, uid: randomUUID(), name: "Saved Composio", transport: "mcp_remote", authKind: "none", credentialPolicy: "shared", enabled: true, status: "active", healthStatus: "ok", config: { sourceTemplateKey: "composio" } }).returning();
await db.insert(connectionGrants).values({ companyId: company.id, connectionId: connection.id, kind: "organization", status: "active" });
await db.insert(toolConnectionInstalls).values({ companyId: company.id, connectionId: connection.id, targetType: "company", targetId: company.id });
const catalog = await db.insert(toolCatalogEntries).values([
{ toolName: "COMPOSIO_SEARCH_TOOLS", riskLevel: "read" as const },
{ toolName: "COMPOSIO_MANAGE_CONNECTIONS", riskLevel: "destructive" as const },
{ toolName: "COMPOSIO_REMOTE_BASH_TOOL", riskLevel: "destructive" as const },
].map(tool => ({ ...tool, companyId: company.id, connectionId: connection.id, name: tool.toolName, versionHash: "v1", entryKind: "tool" as const, status: "active" as const }))).returning();
return { company, agent, otherAgent, issue, connection, catalog, service: connectionIntentService(db), claims: { ...claims, sub: agent.id, company_id: company.id, run_id: run.id, responsible_user_id: "grant-user" } };
}
it("creates a scoped access card and atomically accepts repeated clicks without granting other agents", async () => {
const f = await accessFixture();
const request = await f.service.request(f.claims, "composio", { connectionId: f.connection.id, toolNames: ["COMPOSIO_SEARCH_TOOLS", "COMPOSIO_MANAGE_CONNECTIONS"] });
expect((await f.service.search(f.claims, "composio")).results[0].state).toBe("needs_user_action");
const loaded = await f.service.loadIntent(request.interactionId!);
expect(loaded.interaction).toMatchObject({ title: "Grant Composio access to Default agent?", payload: { accessRequest: { connectionId: f.connection.id, tools: [ { toolName: "COMPOSIO_SEARCH_TOOLS", permission: "allowed" }, { toolName: "COMPOSIO_MANAGE_CONNECTIONS", permission: "ask_first" } ] } } });
const repeated = await f.service.request(f.claims, "composio", { connectionId: f.connection.id, toolNames: ["COMPOSIO_MANAGE_CONNECTIONS", "COMPOSIO_SEARCH_TOOLS"] });
expect(repeated.interactionId).toBe(request.interactionId);
await expect(f.service.complete(request.interactionId!, f.connection.id, "grant-user")).rejects.toThrow("connection-management authority");
await expect(f.service.complete(request.interactionId!, f.connection.id, "other-user", { canManageOrganizationGrant: true })).rejects.toThrow("addressed user");
const results = await Promise.all([1, 2].map(() => f.service.complete(request.interactionId!, f.connection.id, "grant-user", { canManageOrganizationGrant: true })));
expect(results.every(result => result.status === "accepted")).toBe(true);
const effective = await toolAccessService(db).getEffectiveProfilesForAgent(f.company.id, f.agent.id);
expect(effective.allowedTools.map(tool => tool.toolName).sort()).toEqual(["COMPOSIO_MANAGE_CONNECTIONS", "COMPOSIO_SEARCH_TOOLS"]);
expect((await toolAccessService(db).getEffectiveProfilesForAgent(f.company.id, f.otherAgent.id)).allowedTools).toEqual([]);
for (const [name, outcome] of [["COMPOSIO_SEARCH_TOOLS", "allow"], ["COMPOSIO_MANAGE_CONNECTIONS", "require_approval"], ["COMPOSIO_REMOTE_BASH_TOOL", "deny"]]) {
const tool = f.catalog.find(tool => tool.toolName === name)!;
const decision = await toolAccessPolicyService(db).decide({ companyId: f.company.id, actor: { actorType: "agent", actorId: f.agent.id, agentId: f.agent.id }, request: { connectionId: f.connection.id, catalogEntryId: tool.id, toolName: tool.toolName, arguments: {} } });
expect(decision.decision).toBe(outcome);
}
expect(await db.select().from(toolProfileEntries).where(eq(toolProfileEntries.profileId, effective.profiles.find(profile => profile.profileKey.startsWith("connection-intent:"))!.id))).toHaveLength(2);
const managedTool = f.catalog[1];
await db.insert(toolPolicies).values({ companyId: f.company.id, name: "New block after approval", policyType: "block", selectors: { catalogEntryId: managedTool.id }, priority: 100 });
const blocked = await toolAccessPolicyService(db).decide({ companyId: f.company.id, actor: { actorType: "agent", actorId: f.agent.id, agentId: f.agent.id }, request: { connectionId: f.connection.id, catalogEntryId: managedTool.id, toolName: managedTool.toolName, arguments: {} } });
expect(blocked.decision).toBe("deny");
});
it("rejects changed tools and rolls back installs, grants, and acceptance", async () => {
const f = await accessFixture();
const request = await f.service.request(f.claims, "composio", { connectionId: f.connection.id });
await db.update(toolCatalogEntries).set({ versionHash: "v2" }).where(eq(toolCatalogEntries.id, f.catalog[0].id));
await expect(f.service.complete(request.interactionId!, f.connection.id, "grant-user", { canManageOrganizationGrant: true })).rejects.toThrow("tools changed");
expect((await f.service.loadIntent(request.interactionId!)).interaction.status).toBe("pending");
expect((await toolAccessService(db).listConnectionInstalls(f.connection.id, f.company.id)).some(install => install.targetType === "agent")).toBe(false);
expect((await toolAccessService(db).getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toEqual([]);
const renewed = await f.service.request(f.claims, "composio", { connectionId: f.connection.id });
expect(renewed.interactionId).not.toBe(request.interactionId);
expect((await f.service.loadIntent(request.interactionId!)).interaction.status).toBe("expired");
expect((await f.service.loadIntent(renewed.interactionId!)).interaction.payload.accessRequest?.tools[0].versionHash).toBe("v2");
});
it("revokes task-granted tools when the agent profile is removed, including Ask-first tools", async () => {
const f = await accessFixture();
const request = await f.service.request(f.claims, "composio", { connectionId: f.connection.id });
await f.service.complete(request.interactionId!, f.connection.id, "grant-user", { canManageOrganizationGrant: true });
const access = toolAccessService(db);
const effective = await access.getEffectiveProfilesForAgent(f.company.id, f.agent.id);
const profile = effective.profiles.find(profile => profile.profileKey.startsWith("connection-intent:"))!;
await access.deleteProfile(profile.id, {});
expect((await access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toEqual([]);
for (const tool of f.catalog.slice(0, 2)) {
const decision = await toolAccessPolicyService(db).decide({
companyId: f.company.id, actor: { actorType: "agent", actorId: f.agent.id, agentId: f.agent.id },
request: { connectionId: f.connection.id, catalogEntryId: tool.id, toolName: tool.toolName, arguments: {} },
});
expect(decision.decision).toBe("deny");
}
});
it("preserves explicit blocks and rejects unrelated or invented requested tools", async () => {
const f = await accessFixture();
await expect(f.service.request(f.claims, "composio", { connectionId: randomUUID() })).rejects.toThrow("not eligible");
await expect(f.service.request(f.claims, "composio", { connectionId: f.connection.id, toolNames: ["invented"] })).rejects.toThrow("active catalog");
const request = await f.service.request(f.claims, "composio", { connectionId: f.connection.id });
await db.insert(toolPolicies).values({ companyId: f.company.id, name: "Explicit block", policyType: "block", selectors: { catalogEntryId: f.catalog[1].id } });
await expect(f.service.complete(request.interactionId!, f.connection.id, "grant-user", { canManageOrganizationGrant: true })).rejects.toThrow("existing policy blocks");
await expect(f.service.request({ ...f.claims, company_id: randomUUID() }, "composio", { connectionId: f.connection.id })).rejects.toThrow();
});
it("declines access without changing permissions and expires requests after reassignment", async () => {
const f = await accessFixture();
const request = await f.service.request(f.claims, "composio", { connectionId: f.connection.id });
await f.service.decline(request.interactionId!, "grant-user");
expect((await toolAccessService(db).getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toEqual([]);
const other = await accessFixture();
const pending = await other.service.request(other.claims, "composio", { connectionId: other.connection.id });
await db.update(issues).set({ assigneeAgentId: other.otherAgent.id }).where(eq(issues.id, other.issue.id));
await expect(other.service.complete(pending.interactionId!, other.connection.id, "grant-user", { canManageOrganizationGrant: true })).rejects.toThrow();
expect((await toolAccessService(db).getEffectiveProfilesForAgent(other.company.id, other.agent.id)).allowedTools).toEqual([]);
});
});
@@ -66,7 +66,7 @@ describe("cursor local skill sync", () => {
},
}, []);
expect(snapshot.desiredSkills).toEqual([paperclipKey]);
expect(snapshot.desiredSkills).toEqual([paperclipKey, "paperclipai/paperclip/complain", "paperclipai/paperclip/suggestion-box"]);
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
@@ -18,9 +18,9 @@ function disconnected(code = "CONNECTION_CLOSED") {
}
function fixture(failures: Error[] = [], options: {
laterError?: Error;
missingCompany?: boolean;
readFailures?: Record<string, Error[]>;
activityFailures?: Error[];
} = {}) {
const reads: string[] = [];
const companyParameters: unknown[][] = [];
@@ -47,7 +47,6 @@ function fixture(failures: Error[] = [], options: {
if (failure) throw failure;
return options.missingCompany ? [] : [{ id: companyId, budgetMonthlyCents: 10_000 }];
}
if (options.laterError) throw options.laterError;
if (name === "agents") return [{ status: "idle", count: 2 }];
if (name === "issues") return [{ status: "in_progress", count: 1 }];
if (name === "approvals") return [{ count: 3 }];
@@ -59,9 +58,15 @@ function fixture(failures: Error[] = [], options: {
return query;
},
}));
const execute = vi.fn().mockResolvedValue([]);
const activityQueries: SQL[] = [];
const execute = vi.fn(async (query: SQL) => {
const failure = options.activityFailures?.[activityQueries.length];
activityQueries.push(query);
if (failure) throw failure;
return [{ date: new Date().toISOString().slice(0, 10), status: "succeeded", count: 2 }];
});
const db = { select, execute } as unknown as Db;
return { service: dashboardService(db), reads, companyParameters, parameters, select, execute };
return { service: dashboardService(db), reads, companyParameters, parameters, select, execute, activityQueries };
}
beforeEach(() => {
@@ -121,14 +126,6 @@ describe("dashboard read connection recovery", () => {
expect(overview).not.toHaveBeenCalled();
});
it("does not replay later summary reads when they lose a connection", async () => {
const error = disconnected();
const test = fixture([], { laterError: error });
await expect(test.service.summary(companyId)).rejects.toBe(error);
expect(test.reads).toEqual(["companies", "agents"]);
expect(overview).not.toHaveBeenCalled();
});
it("does not replay the budget workflow when it loses a connection", async () => {
const error = disconnected();
overview.mockRejectedValue(error);
@@ -139,12 +136,13 @@ describe("dashboard read connection recovery", () => {
});
const tables = ["companies", "agents", "issues", "approvals", "cost_events"];
it.each(["issues", "approvals", "cost_events"])("retries only the failed %s read", async (table) => {
it.each(["agents", "issues", "approvals", "cost_events"])("retries only the failed %s read", async (table) => {
const test = fixture([], { readFailures: { [table]: [disconnected()] } });
await expect(test.service.summary(companyId)).resolves.toMatchObject({ companyId });
const prefix = tables.slice(0, tables.indexOf(table) + 1);
const suffix = tables.slice(tables.indexOf(table) + 1);
expect(test.reads).toEqual([...prefix, table, ...suffix]);
expect(test.select).toHaveBeenCalledTimes(tables.length + 1);
const attempts = test.parameters.get(table)!;
expect(attempts).toHaveLength(2);
expect(attempts[0]).toContain(companyId);
@@ -153,7 +151,7 @@ describe("dashboard read connection recovery", () => {
expect(overview).toHaveBeenCalledExactlyOnceWith(companyId);
});
it.each(["issues", "approvals", "cost_events"])("stops after the %s read exhausts its budget", async (table) => {
it.each(["agents", "issues", "approvals", "cost_events"])("stops after the %s read exhausts its budget", async (table) => {
const failures = [disconnected(), disconnected(), disconnected()];
const test = fixture([], { readFailures: { [table]: failures } });
await expect(test.service.summary(companyId)).rejects.toBe(failures[2]);
@@ -162,4 +160,40 @@ describe("dashboard read connection recovery", () => {
expect(test.execute).not.toHaveBeenCalled();
expect(overview).not.toHaveBeenCalled();
});
it("rebuilds only the run activity query through two transient disconnects", async () => {
const test = fixture([], { activityFailures: [disconnected(), disconnected()] });
const result = await test.service.summary(companyId);
expect(result.runActivity.at(-1)).toMatchObject({ succeeded: 2, total: 2 });
expect(test.reads).toEqual(tables);
expect(test.select).toHaveBeenCalledTimes(tables.length);
expect(test.execute).toHaveBeenCalledTimes(3);
expect(new Set(test.activityQueries).size).toBe(3);
const queries = test.activityQueries.map((query) => dialect.sqlToQuery(query));
expect(queries[0].params).toContain(companyId);
expect(queries[1]).toEqual(queries[0]);
expect(queries[2]).toEqual(queries[0]);
expect(overview).toHaveBeenCalledExactlyOnceWith(companyId);
});
it("propagates the last run activity failure without entering the budget workflow", async () => {
const failures = [disconnected(), disconnected(), disconnected()];
const test = fixture([], { activityFailures: failures });
await expect(test.service.summary(companyId)).rejects.toBe(failures[2]);
expect(test.reads).toEqual(tables);
expect(test.execute).toHaveBeenCalledTimes(3);
expect(overview).not.toHaveBeenCalled();
});
it.each(["agents", "activity"])("does not retry a non-transient %s error", async (read) => {
const error = disconnected("42501");
const test = fixture([], read === "agents"
? { readFailures: { agents: [error] } }
: { activityFailures: [error] });
await expect(test.service.summary(companyId)).rejects.toBe(error);
expect(test.reads.filter((table) => table === "agents")).toHaveLength(1);
expect(test.execute).toHaveBeenCalledTimes(read === "activity" ? 1 : 0);
expect(overview).not.toHaveBeenCalled();
});
});
+98 -3
View File
@@ -2,15 +2,19 @@ import { createHash, createHmac, randomBytes, randomUUID } from "node:crypto";
import { createServer } from "node:http";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import express, { type Request } from "express";
import request from "supertest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, authUsers, companies, companyMemberships, createDb, dotMailboxItems, dotRunnerAssignments, dotRunnerOperations,
heartbeatRuns, issues, nativeRunResults, nativeRunFinalizations, completionContracts, mcpEventDeliveries, workspaceOperations } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { createPublicMcpOAuth } from "../services/public-mcp/oauth.js";
import { createPublicMcpOAuth, DEVICE_GRANT } from "../services/public-mcp/oauth.js";
import { createPublicMcpExecutor } from "../services/public-mcp/capabilities.js";
import { publicMcpIngressRoutes, publicMcpManagementRoutes } from "../routes/public-mcp.js";
import { createPublicMcpEvents, type EventFetch } from "../services/public-mcp/events.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { canonicalNativeRuntimeContextDigest, type StrictCompletionContractInput } from "../vendor/paperclip-runner/index.js";
@@ -91,6 +95,97 @@ describe("durable Dot Runner integration", () => {
return { company: company!, agent: agent!, userId, broker, principal, oauth, events, subscription, received, snapshot };
}
function gateway(oauth: ReturnType<typeof createPublicMcpOAuth>, actor: Request["actor"]) {
const personal = createPublicMcpOAuth(db, { ...oauth.config, resource: oauth.config.origin + "/mcp/paperclip" });
const dispatch = async () => { throw new Error("Personal API dispatch forbidden"); };
const app = express();
app.use(express.json());
app.use(publicMcpIngressRoutes(personal, createPublicMcpExecutor(db, personal, dispatch)));
app.use(publicMcpIngressRoutes(oauth, createPublicMcpExecutor(db, oauth, dispatch)));
app.use((req, _res, next) => { req.actor = actor; next(); });
app.use("/api", publicMcpManagementRoutes(personal, oauth));
return { app, personal };
}
it("reapplies the Dot-only migration after the merged gateway schema", async () => {
const migration = await readFile(new URL("../../../packages/db/src/migrations/0311_known_inertia.sql", import.meta.url), "utf8");
for (const statement of migration.split("--> statement-breakpoint")) {
if (statement.trim()) await db.execute(sql.raw(statement));
}
});
it("uses merged client metadata and scoped browser consent with a distinct Dot issuer", async () => {
const f = await fixture();
const clientId = `https://dot-${randomUUID()}.example/oauth.json`;
const redirectUri = "https://chatgpt.com/connector_platform/oauth/callback";
const oauth = createPublicMcpOAuth(db, f.oauth.config, { metadataFetch: async () => Response.json({
client_id: clientId, client_name: "Dot metadata client", redirect_uris: [redirectUri],
grant_types: ["authorization_code", "refresh_token", DEVICE_GRANT],
}) });
const { app, personal } = gateway(oauth, { type: "board", source: "session", userId: f.userId });
const metadata = await request(app).get("/.well-known/oauth-protected-resource/mcp/runner");
expect(metadata.body.authorization_servers).toEqual([oauth.config.origin + "/mcp/runner/oauth"]);
const issuer = await request(app).get("/.well-known/oauth-authorization-server/mcp/runner/oauth");
expect(issuer.body).toMatchObject({ issuer: oauth.config.origin + "/mcp/runner/oauth", client_id_metadata_document_supported: true,
scopes_supported: ["paperclip:agent", "offline_access"], device_authorization_endpoint: oauth.config.origin + "/mcp/runner/oauth/device_authorization" });
expect((await request(app).get("/.well-known/oauth-authorization-server")).body.issuer).toBe(oauth.config.origin);
const verifier = randomBytes(32).toString("base64url");
const input = { client_id: clientId, redirect_uri: redirectUri, response_type: "code", resource: oauth.config.resource,
company_id: f.company.id, code_challenge: createHash("sha256").update(verifier).digest("base64url"), code_challenge_method: "S256" };
const id = (await oauth.authorize(input)).split("/").at(-1)!;
await expect(personal.describeRequest(id, { type: "board", source: "session", userId: f.userId }, null)).rejects.toThrow();
const description = await request(app).get("/api/mcp/requests/" + id);
expect(description.body).toMatchObject({ agentConnection: true, requestedCompanyId: f.company.id, clientOrigin: new URL(clientId).origin });
expect(description.body.companies.map((c: { id: string }) => c.id)).toEqual([f.company.id]);
expect((await request(app).post(`/api/mcp/requests/${id}/consent`).set("Origin", oauth.config.origin)
.send({ decision: "approve", companyId: randomUUID(), allowWrites: false })).status).toBe(403);
const consent = await request(app).post(`/api/mcp/requests/${id}/consent`).set("Origin", oauth.config.origin)
.send({ decision: "approve", companyId: f.company.id, allowWrites: false });
expect(consent.status).toBe(200);
const redirect = new URL(consent.body.redirectUrl);
expect(redirect.searchParams.get("iss")).toBe(issuer.body.issuer);
const tokens = await oauth.token({ grant_type: "authorization_code", client_id: clientId, redirect_uri: redirectUri,
resource: oauth.config.resource, code: redirect.searchParams.get("code"), code_verifier: verifier });
expect((await oauth.authenticate(tokens.access_token)).grant).toMatchObject({ purpose: "agent", agentId: null, scopes: ["paperclip:agent"] });
await expect(personal.authenticate(tokens.access_token)).rejects.toThrow();
await expect(oauth.authorize({ ...input, scope: "paperclip:read" })).rejects.toMatchObject({ code: "invalid_scope" });
await f.events.unsubscribe(f.principal, f.subscription);
}, 30000);
it("routes merged device consent to Dot without granting personal or cross-company access", async () => {
const f = await fixture();
const { app, personal } = gateway(f.oauth, { type: "board", source: "session", userId: f.userId });
const client = await f.oauth.register({ client_name: "Dot device", redirect_uris: [], response_types: [], grant_types: [DEVICE_GRANT, "refresh_token"] }, randomUUID());
const started = await request(app).post("/mcp/runner/oauth/device_authorization")
.send({ client_id: client.client_id, resource: f.oauth.config.resource, scope: "paperclip:agent offline_access", company_id: f.company.id });
expect(started.status).toBe(200);
const codes = started.body;
expect(await f.oauth.ownsDevice(codes.user_code)).toBe(true);
await expect(personal.describeDevice(codes.user_code, { type: "none" })).rejects.toThrow();
const description = await request(app).get("/api/mcp/device").query({ user_code: codes.user_code });
expect(description.body).toMatchObject({ agentConnection: true, requestedCompanyId: f.company.id });
await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(eq(companyMemberships.principalId, f.userId));
expect((await request(app).post("/api/mcp/device/consent").set("Origin", f.oauth.config.origin)
.send({ userCode: codes.user_code, decision: "approve", companyId: f.company.id, allowWrites: false })).status).toBe(403);
await db.update(companyMemberships).set({ membershipRole: "owner" }).where(eq(companyMemberships.principalId, f.userId));
const approved = await request(app).post("/api/mcp/device/consent").set("Origin", f.oauth.config.origin)
.send({ userCode: codes.user_code, decision: "approve", companyId: f.company.id, allowWrites: false });
expect(approved.body).toEqual({ status: "approved" });
const exchange = { grant_type: DEVICE_GRANT, client_id: client.client_id, resource: f.oauth.config.resource, device_code: codes.device_code };
const tokens = (await request(app).post("/mcp/runner/oauth/token").send(exchange)).body;
expect((await f.oauth.authenticate(tokens.access_token)).actor.type).toBe("none");
await expect(personal.authenticate(tokens.access_token)).rejects.toThrow();
const renewed = await f.oauth.token({ grant_type: "refresh_token", client_id: client.client_id, resource: f.oauth.config.resource, refresh_token: tokens.refresh_token });
expect((await f.oauth.authenticate(renewed.access_token)).grant.purpose).toBe("agent");
await expect(f.oauth.token(exchange)).rejects.toMatchObject({ code: "invalid_grant" });
vi.stubEnv("PAPERCLIP_ENABLE_OPENAI_DOT", "0");
try {
expect((await request(app).get("/.well-known/oauth-protected-resource/mcp/runner")).status).toBe(503);
expect((await request(app).get("/.well-known/oauth-protected-resource/mcp/paperclip")).status).toBe(200);
} finally { vi.stubEnv("PAPERCLIP_ENABLE_OPENAI_DOT", "1"); }
await f.events.unsubscribe(f.principal, f.subscription);
}, 30000);
it("signed readiness, normal native authority, one document write and finalization through real Rust", async () => {
const f = await fixture();
const [issue] = await db.insert(issues).values({ companyId: f.company.id, title: "Save the arithmetic report", description: "Save 17 + 25 = 42 as a task document.",
@@ -164,7 +164,7 @@ async function closeServer(server: Server | null) {
});
}
describe.sequential("execution environment route guards", () => {
describe("execution environment route guards", () => {
afterAll(async () => {
await closeServer(projectServer);
await closeServer(issueServer);
@@ -236,7 +236,7 @@ function mockVerifiedReseed(
* stable 409 while a control is genuinely live, and a start that fails must leave the workspace
* stopped and retryable instead of "desired running" with residue.
*/
describe.sequential("execution workspace runtime control conflict and failure reconciliation", () => {
describe("execution workspace runtime control conflict and failure reconciliation", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSpawn.mockReset();
@@ -88,7 +88,7 @@ function createApp(actor: Record<string, unknown> = {
return app;
}
describe.sequential("execution workspace routes", () => {
describe("execution workspace routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAccessService.decide.mockResolvedValue({
@@ -162,17 +162,14 @@ describe("external object routes", () => {
// first import transforms a large module graph. Under the loaded serial shard
// (maxWorkers=1) that cold cost crossed the 5s testTimeout of the first test.
// The hook has a 30s budget, so it absorbs the transform cost and every later
// createApp() call hits the cached modules.
// createApp() call hits the cached modules. Install mocks before warming the
// graph and reset their state between tests instead of reloading the modules.
beforeAll(async () => {
registerRouteMocks();
await createApp(boardActor());
});
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/external-objects.js");
registerRouteMocks();
vi.resetAllMocks();
mockIssueService.getById.mockResolvedValue(makeIssue());
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
@@ -99,9 +99,9 @@ describe(`durable file delivery (${liveDaytona ? "Daytona" : "local processes"})
enableSandboxDuplexBridge: duplex,
});
expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe(duplex ? "http2_v1" : "queue_v1");
const env = { ...bridge!.env, PAPERCLIP_RUN_ID: fixture.runId,
const env = { ...bridge!.env, TMPDIR: path.join(remote.workspace, ".unavailable-controller-temp"), PAPERCLIP_RUN_ID: fixture.runId,
PAPERCLIP_COMPANY_ID: fixture.companyId, PAPERCLIP_TASK_ID: fixture.issueId,
PAPERCLIP_HELPER_STATE_DIR: path.join(remote.workspace, ".helper-state") };
PAPERCLIP_WORKSPACE_CWD: remote.workspace };
const headers = { authorization: `Bearer ${bridge!.env.PAPERCLIP_API_KEY}` };
const receipts: Array<{ attachment: { id: string; contentPath: string; downloadPath: string } }> = [];
try {
@@ -950,6 +950,14 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
iss: callback.searchParams.get("iss")!, redirectUri: input.redirectUri, actor,
});
expect(completed.connection.config.oauth).toMatchObject({ scopes: ["mcp:read", "offline_access"] });
const grants = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, connected.connectionId));
expect(grants).toHaveLength(1);
expect(grants[0].providerTenant?.oauth).toMatchObject({
requestedScopes: ["mcp:read", "offline_access"],
scopes: ["mcp:read", "offline_access"],
scopeSource: "provider",
unrequestedScopes: [],
});
const reconnect = await service.startOAuth(company.id, connected.connectionId, { redirectUri: input.redirectUri, actor });
expect(new URL(reconnect.authorizationUrl).searchParams.get("scope")).toBe("mcp:read offline_access");
} finally { await fixture.close(); }
@@ -22,7 +22,7 @@ import {
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres.sequential("GitHub connection event delivery", () => {
describeEmbeddedPostgres("GitHub connection event delivery", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
@@ -1008,7 +1008,7 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
expect(adapterInput.context.paperclipTaskMarkdown).not.toContain("Create child issues from the approved plan only");
}, 20_000);
it("preserves accepted-plan continuation resume state when the wake issue owns the in-flight claim", async () => {
it("preserves accepted-plan instructions but replaces a pre-identity session when the wake issue owns the in-flight claim", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
@@ -1138,7 +1138,8 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
runtime: { sessionId: string | null; sessionParams: Record<string, unknown> | null };
context: Record<string, unknown>;
};
expect(adapterInput.runtime.sessionId).toBe("accepted-plan-retry-session");
// The old process predates identity injection and must be replaced.
expect(adapterInput.runtime.sessionId).toBeNull();
expect(adapterInput.context.acceptedPlanWakeRouting).toBeUndefined();
expect(adapterInput.context.paperclipTaskMarkdown).toContain(
"Implement the accepted plan on this issue when the work is small and cohesive.",
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { and, eq, sql } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
@@ -19,6 +19,7 @@ import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { truncateTablesWithDeadlockRetry } from "./helpers/truncate-with-deadlock-retry.js";
import { appendHeartbeatRunEvent } from "../services/heartbeat-run-events.js";
import {
ACTIVE_RUN_OUTPUT_CONTINUE_REARM_MS,
@@ -43,29 +44,6 @@ if (!embeddedPostgresSupport.supported) {
);
}
function errorHasPostgresCode(error: unknown, code: string): boolean {
let current: unknown = error;
for (let depth = 0; depth < 4; depth += 1) {
if (!current || typeof current !== "object") return false;
const record = current as { code?: unknown; cause?: unknown };
if (record.code === code) return true;
current = record.cause;
}
return false;
}
async function truncateCompaniesWithDeadlockRetry(db: ReturnType<typeof createDb>) {
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
await db.execute(sql.raw(`TRUNCATE TABLE "companies" CASCADE`));
return;
} catch (error) {
if (!errorHasPostgresCode(error, "40P01") || attempt === 4) throw error;
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
}
}
}
describeEmbeddedPostgres("active-run output watchdog", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let db: ReturnType<typeof createDb>;
@@ -77,7 +55,10 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
afterEach(async () => {
mockedAppendHeartbeatRunEvent.mockClear();
await truncateCompaniesWithDeadlockRetry(db);
await truncateTablesWithDeadlockRetry(db, `TRUNCATE TABLE "companies" CASCADE`, {
attempts: 5,
delayMs: (attempt) => 50 * (attempt + 1),
});
});
afterAll(async () => {
@@ -230,6 +211,27 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
expect(manager?.status).toBe("idle");
}
it("warns after five silent minutes and escalates after fifteen without changing active work", async () => {
const now = new Date("2026-04-22T20:00:00.000Z");
const seeded = await seedRunningRun({ now, ageMs: 0 });
const summaryAt = (elapsedMs: number) => buildSummary(seeded.runId, new Date(now.getTime() + elapsedMs));
await expect(summaryAt(5 * 60_000 - 1)).resolves.toMatchObject({ level: "ok" });
await expect(summaryAt(5 * 60_000)).resolves.toMatchObject({ level: "suspicious" });
await expect(summaryAt(15 * 60_000 - 1)).resolves.toMatchObject({ level: "suspicious" });
await expect(summaryAt(15 * 60_000)).resolves.toMatchObject({ level: "critical" });
const { recovery, enqueueWakeup } = createRecovery();
await recovery.scanSilentActiveRuns({ now: new Date(now.getTime() + 35 * 60_000), companyId: seeded.companyId });
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seeded.runId));
expect(run?.status).toBe("running");
expect(enqueueWakeup).not.toHaveBeenCalled();
await expectNoReviewArtifacts(seeded);
// Fresh output clears the warning even on a long-running task.
await db.update(heartbeatRuns).set({ lastOutputAt: new Date(now.getTime() + 35 * 60_000) })
.where(eq(heartbeatRuns.id, seeded.runId));
await expect(summaryAt(36 * 60_000)).resolves.toMatchObject({ level: "ok" });
});
it.each(["stale_active_run_evaluation", "issue_productivity_review"])("keeps blocked and %s sources artifact-free", async (originKind) => {
const now = new Date("2026-04-22T20:00:00.000Z");
const blocked = await seedRunningRun({
@@ -170,6 +170,88 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
await tempDb?.cleanup();
});
it("delivers a revised native child completion after the running parent blocks", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const childId = randomUUID();
const decisionId = randomUUID();
let finishParent!: () => void;
const parentFinished = new Promise<void>((resolve) => { finishParent = resolve; });
mockAdapterExecute.mockImplementationOnce(async () => {
await parentFinished;
await db.update(issues).set({ status: "blocked" }).where(eq(issues.id, issueId));
return { exitCode: 0, signal: null, timedOut: false, errorMessage: null,
summary: "Waiting for the child revision.", provider: "test", model: "test-model" };
});
mockAdapterExecute.mockImplementationOnce(async () => {
const [run] = await db.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.status, "running"),
));
await db.insert(issueComments).values({ companyId, issueId, authorAgentId: agentId,
authorType: "agent", createdByRunId: run!.id, body: "Reviewed and delivered the revised ZIP." });
await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId));
return { exitCode: 0, signal: null, timedOut: false, errorMessage: null,
summary: "Reviewed and delivered the revised ZIP.", provider: "test", model: "test-model" };
});
await db.insert(companies).values({ id: companyId, name: "Native handoff",
issuePrefix: `N${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false, defaultResponsibleUserId: "responsible-user" });
await db.insert(agents).values({ id: agentId, companyId, name: "Parent", role: "engineer",
status: "active", adapterType: "codex_local", adapterConfig: {}, permissions: {},
runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } } });
await db.insert(issues).values([
{ id: issueId, companyId, title: "Deliver the revised ZIP", status: "todo",
assigneeAgentId: agentId, responsibleUserId: "responsible-user" },
{ id: childId, companyId, parentId: issueId, title: "Revise the ZIP", status: "done",
responsibleUserId: "responsible-user" },
]);
try {
const parentRun = await heartbeat.wakeup(agentId, { source: "assignment", triggerDetail: "system",
reason: "issue_assigned", payload: { issueId }, contextSnapshot: { issueId, wakeReason: "issue_assigned" } });
expect(parentRun).not.toBeNull();
await db.insert(issueComments).values({ companyId, issueId, authorAgentId: agentId,
authorType: "agent", createdByRunId: parentRun!.id, body: "Waiting for the child revision." });
expect(await waitForCondition(async () => mockAdapterExecute.mock.calls.length === 1, 30_000)).toBe(true);
const [intent] = await db.insert(agentWakeupRequests).values({ companyId, agentId,
source: "automation", triggerDetail: "system", reason: "issue_children_completed",
requestedByActorType: "system", requestedByActorId: "native-status-committer",
idempotencyKey: `issue_children_completed:${issueId}:${childId}:${decisionId}`,
payload: { issueId, taskId: issueId, _paperclipWakeContext: {
issueId, taskId: issueId, source: "native_status_decision",
nativeChildCompletionDecisionId: decisionId, completedChildIssueId: childId,
childIssueIds: [childId], childIssueSummaries: [{ id: childId, summary: "Revised ZIP: 11 tests." }],
} },
}).returning();
await heartbeat.dispatchPendingNativeStatusWakeups({ companyId });
await heartbeat.dispatchPendingNativeStatusWakeups({ companyId });
const deferred = await db.select().from(agentWakeupRequests).where(and(
eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.status, "deferred_issue_execution"),
));
expect(deferred).toHaveLength(1);
expect(deferred[0]!.runId).toBeNull();
expect(mockAdapterExecute).toHaveBeenCalledTimes(1);
finishParent();
expect(await waitForCondition(async () => mockAdapterExecute.mock.calls.length === 2, 10_000)).toBe(true);
await heartbeat.drainActiveRunExecutions();
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId));
expect(runs.map((run) => ({ id: run.id, status: run.status, context: run.contextSnapshot, error: run.error }))).toHaveLength(2);
const continuation = runs.find((run) => run.id !== parentRun!.id)!;
expect(continuation.contextSnapshot).toMatchObject({
wakeReason: "issue_children_completed", nativeChildCompletionDecisionId: decisionId,
nativeStatusWakeIntentId: intent!.id, completedChildIssueId: childId,
childIssueSummaries: [{ id: childId, summary: "Revised ZIP: 11 tests." }],
});
expect(continuation.status).toBe("succeeded");
const first = runs.find((run) => run.id === parentRun!.id)!;
expect(continuation.startedAt!.getTime()).toBeGreaterThanOrEqual(first.finishedAt!.getTime());
await heartbeat.dispatchPendingNativeStatusWakeups({ companyId });
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId))).toHaveLength(2);
} finally {
finishParent();
}
}, 45_000);
it("dispatches and coalesces durable native status wake intents into one heartbeat run", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
@@ -1,8 +1,12 @@
import * as executionContinuation from "../services/execution-continuation.js";
import * as environmentOrchestrator from "../services/environment-run-orchestrator.js";
import { remoteTerminationReceipt } from "../services/remote-execution-termination.js";
import { legacyDispositionFingerprint, LEGACY_DISPOSITION_REPAIR_INSTRUCTION } from "../services/recovery/legacy-continuation.js";
import * as controllerLeases from "../services/legacy-controller-lease.js";
import * as instructionWorkingCopies from "../services/agent-instruction-working-copies.js";
import * as runEvents from "../services/heartbeat-run-events.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { terminalizeLegacyExecution } from "../services/legacy-execution-recovery.js";
import { issueService } from "../services/issues.js";
import { getExecutionBlocker } from "../services/execution-blocker.js";
@@ -1487,6 +1491,207 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
return { companyId, agentId, runId, wakeupRequestId, issueId };
}
it.each(["timeout", "upstream", "cleanup_pending", "cleanup_pending_edited", "cleanup_pending_exhausted", "edited", "exhausted", "new_message", "no_claim", "reassigned", "superseded", "stopped"])(
"settles explicit retry admission through real executor cleanup: %s", async scenario => {
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
const sourceRunId = randomUUID(), commentId = randomUUID(), nextCommentId = randomUUID();
await db.insert(heartbeatRuns).values({ id: sourceRunId, companyId, agentId, status: "failed",
createdAt: new Date("2020-01-01T00:00:00Z"), finishedAt: new Date("2020-01-01T00:00:01Z"),
contextSnapshot: { issueId } });
const [comment] = await db.insert(issueComments).values({ id: commentId, companyId, issueId,
authorType: "user", authorUserId: "responsible-user", body: "Continue the task." }).returning();
await db.insert(issueRecoveryActions).values({ companyId, sourceIssueId: issueId,
kind: "active_run_watchdog", cause: "legacy_execution_requires_reconciliation", fingerprint: sourceRunId,
status: "resolved", outcome: "cancelled", nextAction: "User continued.", evidence: { explicitUserContinuation: {
runId, previousRunId: sourceRunId, commentId, actorId: "responsible-user", recordedAt: new Date().toISOString(),
commentUpdatedAt: comment!.updatedAt.toISOString(), commentBodyHash: createHash("sha256").update(comment!.body).digest("hex"),
} } });
await db.update(heartbeatRuns).set({ createdAt: new Date(), contextSnapshot: {
issueId, taskId: issueId, wakeReason: "issue_commented", wakeCommentId: commentId,
previousRunId: sourceRunId, explicitUserContinuation: { previousRunId: sourceRunId, commentId },
} }).where(eq(heartbeatRuns.id, runId));
const identity = { id: randomUUID(), companyId, heartbeatRunId: runId, provider: "daytona", providerLeaseId: "fixture-explicit-sandbox" };
const supersedingRunId = randomUUID();
let heartbeat: ReturnType<typeof heartbeatService>;
let cancellation: ReturnType<typeof heartbeat.cancelRun> | undefined;
let cleanupObserved = false;
let cleanupPending = scenario.startsWith("cleanup_pending");
const originalFactory = environmentOrchestrator.environmentRunOrchestrator;
const release = vi.fn(async () => {
const [lease] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, identity.id));
if (!lease) return { released: [], errors: [] };
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, runId))).toHaveLength(0);
if (!cleanupObserved) {
cleanupObserved = true;
const denied = await db.select().from(heartbeatRunEvents).where(and(eq(heartbeatRunEvents.runId, runId),
eq(heartbeatRunEvents.message, "The automatic retry could not revalidate the original user continuation.")));
// Upstream failure first tries the direct scheduler, then the release
// tail. Both must defer admission until the executor releases ownership.
if (scenario === "upstream") expect(denied.length).toBeGreaterThanOrEqual(2);
if (scenario === "timeout") expect(denied.length).toBeGreaterThanOrEqual(1);
if (scenario === "new_message") {
await db.insert(issueComments).values({ id: nextCommentId, companyId, issueId, authorType: "user",
authorUserId: "responsible-user", body: "Use these new instructions." });
await heartbeat.wakeup(agentId, { source: "automation", reason: "issue_commented",
requestedByActorType: "user", requestedByActorId: "responsible-user",
payload: { issueId, commentId: nextCommentId },
contextSnapshot: { issueId, commentId: nextCommentId, wakeReason: "issue_commented" } });
}
if (scenario === "reassigned") {
const [other] = await db.insert(agents).values({ companyId, name: "New assignee", role: "engineer",
adapterType: "codex_local" }).returning();
await db.update(issues).set({ assigneeAgentId: other!.id }).where(eq(issues.id, issueId));
}
if (scenario === "superseded") {
await db.insert(heartbeatRuns).values({ id: supersedingRunId, companyId, agentId, status: "running",
contextSnapshot: { issueId } });
await db.update(issues).set({ executionRunId: supersedingRunId }).where(eq(issues.id, issueId));
}
}
await db.update(environmentLeases).set({ status: "released", cleanupStatus: "success", releasedAt: new Date() })
.where(and(eq(environmentLeases.companyId, companyId), eq(environmentLeases.heartbeatRunId, runId),
eq(environmentLeases.provider, "local")));
await db.update(environmentLeases).set(cleanupPending
? { status: "pending_cleanup", cleanupStatus: "failed", releasedAt: new Date() }
: { status: "released", cleanupStatus: "success", releasedAt: new Date(), metadata: {
remoteExecutionTermination: remoteTerminationReceipt(identity, { providerLeaseId: identity.providerLeaseId, state: "destroyed" }),
} }).where(eq(environmentLeases.id, identity.id));
return { released: [], errors: [] };
});
const factory = vi.spyOn(environmentOrchestrator, "environmentRunOrchestrator").mockImplementation((...args) => ({
...originalFactory(...args), releaseForRun: release,
}));
heartbeat = heartbeatService(db);
mockAdapterExecute.mockImplementationOnce(async (input?: unknown) => {
const invocation = input as { onCancellationReady: () => Promise<void>; onDispatch: () => void };
await invocation.onCancellationReady();
invocation.onDispatch();
expect(adapterExecutionControls.has(runId)).toBe(true);
await db.insert(environmentLeases).values({ ...identity, status: "active", leasePolicy: "ephemeral" });
if (scenario === "edited") await db.update(issueComments).set({ body: "Changed after dispatch" }).where(eq(issueComments.id, commentId));
if (scenario === "no_claim") await db.update(issues).set({ executionRunId: null }).where(eq(issues.id, issueId));
if (scenario === "exhausted") await db.update(heartbeatRuns).set({ scheduledRetryAttempt: 2,
scheduledRetryReason: "transient_failure" }).where(eq(heartbeatRuns.id, runId));
if (scenario === "stopped") {
cancellation = heartbeat.cancelRun(runId, "Operator Stop");
void cancellation.catch(() => undefined);
await waitForValue(async () => adapterExecutionControls.get(runId)?.controller.signal.aborted);
}
return { exitCode: 1, signal: scenario === "stopped" ? "SIGTERM" : null,
timedOut: scenario !== "upstream" && scenario !== "stopped",
errorMessage: scenario === "upstream" ? "Upstream temporarily unavailable" : "Execution timed out",
errorCode: scenario === "upstream" ? "upstream_unavailable" : "timeout",
resultJson: scenario === "upstream" ? { errorFamily: "transient_upstream" }
: scenario === "stopped" ? { executionCancellation: { state: "acknowledged" } } : {},
summary: "Stopped turn", provider: "test", model: "test-model" };
});
mockAdapterExecute.mockImplementation(async () => {
await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId));
return { exitCode: 0, signal: null, timedOut: false, errorMessage: null,
summary: "New message handled.", provider: "test", model: "test-model" };
});
try {
await heartbeat.resumeQueuedRuns();
await heartbeat.drainActiveRunExecutions();
if (cancellation) expect((await cancellation)?.status).toBe("cancelled");
expect(release).toHaveBeenCalled();
expect(adapterExecutionControls.has(runId)).toBe(false);
const children = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, runId));
const receipts = await db.select().from(issueRecoveryActions).where(and(eq(issueRecoveryActions.sourceIssueId, issueId),
eq(issueRecoveryActions.cause, "explicit_user_continuation_retry")));
const [task] = await db.select().from(issues).where(eq(issues.id, issueId));
if (scenario === "timeout" || scenario === "upstream") {
expect(children).toHaveLength(1);
expect(receipts).toHaveLength(1);
expect(task!.executionRunId).toBe(children[0]!.id);
await expect(executionContinuation.buildExecutionContinuation({ db, companyId, issueId, agentId,
runId: children[0]!.id, context: children[0]!.contextSnapshot!, summary: null, exposeLowTrustRaw: false }))
.resolves.toMatchObject({ interruptedRunId: runId });
expect(await heartbeat.scheduleBoundedRetry(runId)).toMatchObject({ outcome: "scheduled", run: { id: children[0]!.id } });
} else {
expect(children).toHaveLength(0);
expect(receipts).toHaveLength(0);
expect(task!.executionRunId).toBe(cleanupPending ? runId : scenario === "superseded" ? supersedingRunId : null);
}
if (scenario === "new_message") expect(await db.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, companyId), sql`${heartbeatRuns.contextSnapshot}->>'wakeCommentId' = ${nextCommentId}`)))
.toHaveLength(1);
if (cleanupPending) {
if (scenario === "cleanup_pending") {
const svc = issueService(db);
const [other] = await db.insert(agents).values({ companyId, name: "Other worker", role: "engineer",
adapterType: "codex_local" }).returning();
const actorRunId = randomUUID();
await db.insert(heartbeatRuns).values({ id: actorRunId, companyId, agentId, status: "running", contextSnapshot: {} });
// Neither a rejected checkout nor stale checkout adoption may erase
// the exact pending retry claim, including mixed checkout owners.
for (const checkoutRunId of [null, runId, sourceRunId]) {
await db.update(issues).set({ checkoutRunId }).where(eq(issues.id, issueId));
expect(await svc.clearExecutionRunIfTerminal(issueId)).toBe(false);
expect(await svc.clearCheckoutRunIfTerminal(issueId)).toBe(false);
await expect(svc.checkout(issueId, other!.id, ["in_progress"], actorRunId)).rejects.toMatchObject({ status: 409 });
await expect(svc.assertCheckoutOwner(issueId, other!.id, actorRunId)).rejects.toMatchObject({ status: 409 });
await expect(svc.checkout(issueId, agentId, ["in_progress"], actorRunId)).rejects.toMatchObject({ status: 409 });
await expect(svc.assertCheckoutOwner(issueId, agentId, actorRunId)).rejects.toMatchObject({ status: 409 });
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]!.executionRunId).toBe(runId);
}
await db.update(issues).set({ checkoutRunId: null }).where(eq(issues.id, issueId));
await db.update(heartbeatRuns).set({ status: "failed", finishedAt: new Date() }).where(eq(heartbeatRuns.id, actorRunId));
}
await heartbeat.sweepStaleIssueLocks();
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]!.executionRunId).toBe(runId);
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, runId))).toHaveLength(0);
expect(await db.select().from(issueRecoveryActions).where(and(eq(issueRecoveryActions.sourceIssueId, issueId),
eq(issueRecoveryActions.cause, "explicit_user_continuation_retry")))).toHaveLength(0);
if (scenario === "cleanup_pending_edited") await db.update(issueComments).set({ body: "Changed while cleanup waited" })
.where(eq(issueComments.id, commentId));
if (scenario === "cleanup_pending_exhausted") await db.update(heartbeatRuns).set({ scheduledRetryAttempt: 2,
scheduledRetryReason: "transient_failure" }).where(eq(heartbeatRuns.id, runId));
cleanupPending = false;
await heartbeat.releaseEnvironmentLeasesForRun({ runId, companyId, agentId, status: "timed_out" });
await heartbeat.sweepStaleIssueLocks();
await heartbeat.sweepStaleIssueLocks();
const afterCleanup = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, runId));
expect(afterCleanup).toHaveLength(scenario === "cleanup_pending" ? 1 : 0);
expect(await db.select().from(issueRecoveryActions).where(and(eq(issueRecoveryActions.sourceIssueId, issueId),
eq(issueRecoveryActions.cause, "explicit_user_continuation_retry")))).toHaveLength(afterCleanup.length);
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]!.executionRunId).toBe(afterCleanup[0]?.id ?? null);
}
} finally { factory.mockRestore(); }
},
);
it("does not reuse a completed explicit turn's receipt for a missing-comment follow-up", async () => {
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
const sourceRunId = randomUUID(), commentId = randomUUID();
await db.insert(heartbeatRuns).values({ id: sourceRunId, companyId, agentId, status: "failed",
finishedAt: new Date(), contextSnapshot: { issueId } });
await db.insert(issueComments).values({ id: commentId, companyId, issueId,
authorType: "user", authorUserId: "responsible-user", body: "Continue the task." });
await db.insert(issueRecoveryActions).values({ companyId, sourceIssueId: issueId,
kind: "active_run_watchdog", cause: "legacy_execution_requires_reconciliation", fingerprint: sourceRunId,
status: "resolved", outcome: "cancelled", nextAction: "User continued.",
evidence: { explicitUserContinuation: { runId, previousRunId: sourceRunId, commentId,
actorId: "responsible-user", recordedAt: new Date().toISOString() } } });
await db.update(heartbeatRuns).set({ contextSnapshot: {
issueId, taskId: issueId, wakeReason: "issue_assigned",
explicitUserContinuation: { previousRunId: sourceRunId, commentId },
} }).where(eq(heartbeatRuns.id, runId));
mockAdapterExecute.mockImplementationOnce(async () => {
await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId));
return { exitCode: 0, signal: null, timedOut: false, errorMessage: null,
summary: "Task completed.", provider: "test", model: "test-model" };
});
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
await waitForRunToSettle(heartbeat, runId);
await heartbeat.waitForRunExecutionDrain(runId);
expect(await heartbeat.getRun(runId)).toMatchObject({ status: "succeeded", issueCommentStatus: "not_applicable" });
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, runId))).toHaveLength(0);
expect(await db.select().from(agentWakeupRequests).where(and(eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.reason, "missing_issue_comment")))).toHaveLength(0);
});
it("persists the normalized failure without permanently blocking the conversation", async () => {
mockAdapterExecute.mockResolvedValueOnce({
exitCode: 1,
@@ -1796,7 +2001,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(result.continuationRequeued).toBe(0);
});
it.each(["settled", "rejected", "late_callback"] as const)(
it.each(["settled", "rejected", "late_callback", "provider_transport_failed"] as const)(
"joins startup and reap retained cleanup, keeps recovery live, and drains its %s operation",
async (outcome) => {
await withTempPaperclipHome(async () => {
@@ -1880,7 +2085,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
.set({
resultJson: sql`${heartbeatRuns.resultJson} || ${JSON.stringify({
recoveredExecutionFailure: {
errorCode: "adapter_failed",
errorCode: outcome === "provider_transport_failed" ? "provider_transport_failed" : "adapter_failed",
error:
"provider_transport_failed: runner did not durably suspend before checkpoint",
},
@@ -6219,14 +6424,28 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]).toMatchObject({ status: "blocked", assigneeAgentId: agentId });
});
it("converts a continuation parked for review into a dependency wait on its open sub-tasks", async () => {
const { companyId, agentId, issueId } = await seedStrandedIssueFixture({
it.each([false, "receipt", "launch", "provider", "cleanup"])("converts a continuation parked for review into a dependency wait on its open sub-tasks (dispatch receipt: %s)", async dispatchReceipt => {
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "cancelled",
retryReason: "issue_continuation_needed",
runErrorCode: "issue_continuation_waiting_on_review",
runError: "Continuation parked: issue is waiting on review/approval",
});
if (dispatchReceipt) {
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId));
await db.update(heartbeatRuns).set({ startedAt: null,
resultJson: { stopReason: "issue_continuation_waiting_on_review", timeoutSource: "stale_queued_run_gate" },
}).where(eq(heartbeatRuns.id, run.id));
}
if (dispatchReceipt === "launch" || dispatchReceipt === "provider") {
await db.insert(heartbeatRunEvents).values({ companyId, runId, agentId, seq: 1,
eventType: dispatchReceipt === "launch" ? "native.process_start_requested" : "provider.event",
stream: "system", level: "info", message: "Contradictory execution evidence" });
} else if (dispatchReceipt === "cleanup") {
await db.insert(environmentLeases).values({ companyId, heartbeatRunId: runId,
provider: "local", status: "pending_cleanup", cleanupStatus: "failed" });
}
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const openChildTodoId = randomUUID();
const openChildInProgressId = randomUUID();
@@ -6268,6 +6487,14 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
if (dispatchReceipt && dispatchReceipt !== "receipt") {
expect(result.waitingOnReviewResolved).toBe(0);
expect(result.escalated).toBe(1);
expect(await getExecutionBlocker(db, companyId, issueId)).toMatchObject({
cause: "legacy_execution_requires_reconciliation", runId, canRetry: false,
});
return;
}
expect(result.waitingOnReviewResolved).toBe(1);
expect(result.escalated).toBe(0);
expect(result.issueIds).toEqual([issueId]);
@@ -7147,6 +7374,72 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(repairWakeups).toHaveLength(0);
});
it.each([
{ providerOutcome: "succeeded", cleanupAt: "inner", warningFails: false },
{ providerOutcome: "failed", cleanupAt: "inner", warningFails: false },
{ providerOutcome: "throws", cleanupAt: "inner", warningFails: false },
{ providerOutcome: "succeeded", cleanupAt: "outer", warningFails: false },
{ providerOutcome: "succeeded", cleanupAt: "inner", warningFails: true },
] as const)("preserves a $providerOutcome provider outcome when instruction cleanup times out ($cleanupAt, warning failure: $warningFails)", async ({ providerOutcome, cleanupAt, warningFails }) => {
const { companyId, agentId, runId, issueId } = await seedRunFixture({ runtimeMode: "legacy", agentStatus: "idle", runStatus: "queued" });
const interactionId = randomUUID();
const release = vi.fn().mockRejectedValue(Object.assign(new Error("Timed out waiting for workspace restore lock at /private/agent-files.lock"), {
code: "ERR_WORKSPACE_RESTORE_LOCK_TIMEOUT",
}));
if (cleanupAt === "outer") release.mockResolvedValueOnce(undefined);
const originalFactory = instructionWorkingCopies.agentInstructionWorkingCopyService;
const factory = vi.spyOn(instructionWorkingCopies, "agentInstructionWorkingCopyService").mockImplementation((...args) => ({
...originalFactory(...args), release,
}));
const originalAppend = runEvents.appendHeartbeatRunEvent;
const append = vi.spyOn(runEvents, "appendHeartbeatRunEvent").mockImplementation((db, event) => {
if (warningFails && event.eventType === "instruction_cleanup") throw new Error("Fixture warning persistence failed");
return originalAppend(db, event);
});
mockAdapterExecute.mockImplementationOnce((async () => {
await db.insert(issueThreadInteractions).values({ id: interactionId, companyId, issueId, sourceRunId: runId,
kind: "request_confirmation", title: "Approve the saved plan", payload: { version: 1, prompt: "Accept the plan?" } });
await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, issueId));
await db.insert(issueComments).values({ companyId, issueId, authorAgentId: agentId, createdByRunId: runId, body: "Plan saved; awaiting approval." });
if (providerOutcome === "throws") throw new Error("Original provider failure");
return { exitCode: providerOutcome === "succeeded" ? 0 : 1, signal: null, timedOut: false,
errorCode: providerOutcome === "succeeded" ? null : "provider_failed",
errorMessage: providerOutcome === "succeeded" ? null : "Original provider failure",
summary: "Plan saved; awaiting approval.", provider: "test", model: "test-model",
usage: { inputTokens: 100, outputTokens: 25 }, costUsd: 0.05 };
}) as typeof mockAdapterExecute);
const heartbeat = heartbeatService(db);
try {
await heartbeat.resumeQueuedRuns();
await heartbeat.drainActiveRunExecutions();
const settled = await heartbeat.getRun(runId);
expect(settled).toMatchObject({ status: providerOutcome === "succeeded" ? "succeeded" : "failed",
error: providerOutcome === "succeeded" ? null : "Original provider failure" });
if (providerOutcome !== "throws") {
expect(settled?.resultJson?.summary).toBe("Plan saved; awaiting approval.");
expect(settled?.usageJson).toMatchObject({ inputTokens: 100, outputTokens: 25 });
const costs = await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, runId));
expect(costs).toHaveLength(1);
expect(costs[0]?.costCents).toBe(5);
}
expect(release).toHaveBeenCalledTimes(cleanupAt === "outer" ? 2 : 1);
expect(adapterExecutionControls.has(runId)).toBe(false);
const warnings = await db.select().from(heartbeatRunEvents).where(and(eq(heartbeatRunEvents.runId, runId), eq(heartbeatRunEvents.eventType, "instruction_cleanup")));
expect(warnings).toHaveLength(warningFails ? 0 : 1);
if (!warningFails) expect(warnings[0]).toMatchObject({ level: "warn", payload: { state: "deferred" } });
expect(JSON.stringify(warnings)).not.toContain("/private/");
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, issueId))).toEqual(expect.arrayContaining([
expect.objectContaining({ body: "Plan saved; awaiting approval." }),
]));
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId)))[0]?.status).toBe("pending");
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]?.status).toBe("in_review");
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, runId))).toHaveLength(0);
} finally {
factory.mockRestore();
append.mockRestore();
}
});
it("stops controller renewal and releases execution controls when teardown deadline cleanup throws", async () => {
const { runId, issueId } = await seedRunFixture({ runtimeMode: "legacy", agentStatus: "idle", runStatus: "queued" });
mockAdapterExecute.mockImplementationOnce(async () => {
@@ -14017,7 +14310,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
await commitNativeStatusDecision({ db, companyId: fixture.companyId, issueId: fixture.issueId, runId: fixture.runId,
assessmentId: coordinator!.assessmentId!, priorStatus: issue!.status, priorStatusVersion: issue!.statusVersion,
priorDecisionId: issue!.lastStatusDecisionId,
decision: { policyVersion: "phase6-v7", statusAction: "in_progress", toStatus: "in_progress",
decision: { policyVersion: "phase6-v9", statusAction: "in_progress", toStatus: "in_progress",
reasonCode: "board_response_waiting", unblockDescriptor: null, effects: [] },
requireBoardResponseWaitSource: source!.source, requireBoardResponseWaitOrigin: origin!,
});
@@ -14641,7 +14934,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
priorStatusVersion: issue!.statusVersion,
priorDecisionId: issue!.lastStatusDecisionId,
decision: {
policyVersion: "phase6-v7",
policyVersion: "phase6-v9",
statusAction: reasonCode === "prior_status_preserved_no_live_path" ? "preserve" : "in_progress",
toStatus: "in_progress",
reasonCode,
@@ -22,6 +22,8 @@ import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.js";
import { truncateTablesWithDeadlockRetry } from "./helpers/truncate-with-deadlock-retry.js";
import {
MAX_TURN_CONTINUATION_RETRY_REASON,
MAX_TURN_CONTINUATION_WAKE_REASON,
@@ -89,42 +91,30 @@ async function waitForCondition(fn: () => Promise<boolean>, timeoutMs = 3_000) {
}
async function cleanupHeartbeatInvalidationFixture(db: ReturnType<typeof createDb>) {
for (let attempt = 0; attempt < 10; attempt += 1) {
try {
await db.execute(sql.raw(`
TRUNCATE TABLE
"company_skills",
"issue_comments",
"issue_documents",
"document_revisions",
"documents",
"issue_relations",
"issue_tree_holds",
"issues",
"heartbeat_run_events",
"cost_events",
"activity_log",
"heartbeat_runs",
"agent_wakeup_requests",
"agent_runtime_state",
"agents",
"companies"
RESTART IDENTITY CASCADE
`));
return;
} catch (error) {
const isLateCommentRace =
error instanceof Error &&
error.message.includes("issue_comments_issue_id_issues_id_fk");
if (!isLateCommentRace || attempt === 9) {
throw error;
}
// Heartbeat completion can write issue-thread comments shortly after the
// run leaves queued/running. Retry the dependent deletes once those land.
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
await truncateTablesWithDeadlockRetry(
db,
`
TRUNCATE TABLE
"company_skills",
"issue_comments",
"issue_documents",
"document_revisions",
"documents",
"issue_relations",
"issue_tree_holds",
"issues",
"heartbeat_run_events",
"cost_events",
"activity_log",
"heartbeat_runs",
"agent_wakeup_requests",
"agent_runtime_state",
"agents",
"companies"
RESTART IDENTITY CASCADE
`,
{ attempts: 10, delayMs: () => 100 },
);
}
type SeedOptions = {
@@ -182,21 +172,12 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
model: "test-model",
}));
runningProcesses.clear();
let idlePolls = 0;
for (let attempt = 0; attempt < 100; attempt += 1) {
const runs = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns);
const hasActiveRun = runs.some((run) => run.status === "queued" || run.status === "running");
if (!hasActiveRun) {
idlePolls += 1;
if (idlePolls >= 3) break;
} else {
idlePolls = 0;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
await new Promise((resolve) => setTimeout(resolve, 50));
// A table poll for "queued" or "running" rows cannot see a wakeup promise
// that has not yet written its run row. The shared drain helper awaits
// both in-flight wakeup promises and in-flight run executions first, then
// re-checks the run table, so a late writer cannot still be in flight when
// the TRUNCATE below runs.
await drainHeartbeatRunsToQuiescence(db, heartbeat);
await cleanupHeartbeatInvalidationFixture(db);
});
@@ -523,8 +523,8 @@ describeEmbeddedPostgres("shared-workspace run serialization", () => {
expect(retryRuns).toHaveLength(0);
});
it("defers a run whose issue targets a busy shared workspace and schedules a bounded retry", async () => {
const fixture = await seedWorkspaceFixture();
it.each([0, 35 * 60_000])("defers a run in a busy shared workspace after %i ms of holder silence", async (silenceMs) => {
const fixture = await seedWorkspaceFixture({ holderActivityAt: new Date(Date.now() - silenceMs) });
const run = await heartbeat.invoke(
fixture.agentId,
@@ -2211,6 +2211,14 @@ function sessionParamsWithConfigMetadata(
}
describe("effective run session config freshness", () => {
it("resets legacy sessions after connection instructions change or disappear", async () => {
const first = await buildSessionConfigMetadata({ effectiveAdapterConfig: { paperclipConnectionInstructions: { text: "Use the handbook.", digest: "before" } } });
for (const instructions of [null, { text: "Use the updated handbook.", digest: "after" }]) {
const next = await buildSessionConfigMetadata({ effectiveAdapterConfig: { paperclipConnectionInstructions: instructions } });
expect(resolveTaskSessionConfigFreshness({ hasTaskSession: true, configuredModel: "gpt-5.4-mini", taskSessionParams: sessionParamsWithConfigMetadata(first), configMetadata: next }).reset).toBe(true);
}
});
it("reuses managed AI sessions across temporary credential homes while preserving configuration boundaries", async () => {
const config = (home: string) => ({
model: "gpt-5.4-mini",
@@ -2741,6 +2749,15 @@ describe("comment wake batching", () => {
expect(merged.forceFreshSession).toBe(true);
});
it("keeps connection tool refresh intent while allowing harness session recovery", () => {
const merged = mergeCoalescedContextSnapshot(
{ issueId: "issue-1", wakeReason: "issue_commented", refreshTools: true },
{ issueId: "issue-1", wakeReason: "issue_commented", refreshTools: false },
);
expect(merged.refreshTools).toBe(true);
expect(shouldResetTaskSessionForWake(merged)).toBe(false);
});
});
describe("buildExplicitResumeSessionOverride", () => {
@@ -0,0 +1,96 @@
import { describe, expect, it, vi } from "vitest";
import { errorHasPostgresCode, truncateTablesWithDeadlockRetry } from "./truncate-with-deadlock-retry.js";
function deadlockError(): Error & { code: string } {
return Object.assign(new Error("deadlock detected"), { code: "40P01" });
}
function lateCommentForeignKeyError(): Error {
return new Error(
'insert or update on table "issue_comments" violates foreign key constraint "issue_comments_issue_id_issues_id_fk"',
);
}
function unrelatedError(): Error & { code: string } {
return Object.assign(new Error("relation does not exist"), { code: "42P01" });
}
function fakeDb(execute: ReturnType<typeof vi.fn>) {
return { execute } as unknown as Parameters<typeof truncateTablesWithDeadlockRetry>[0];
}
describe("truncateTablesWithDeadlockRetry", () => {
it("retries a deadlock (40P01) and succeeds once the lock clears", async () => {
const execute = vi.fn()
.mockRejectedValueOnce(deadlockError())
.mockRejectedValueOnce(deadlockError())
.mockResolvedValueOnce(undefined);
await truncateTablesWithDeadlockRetry(fakeDb(execute), "TRUNCATE TABLE \"companies\" CASCADE", {
attempts: 5,
delayMs: () => 0,
});
expect(execute).toHaveBeenCalledTimes(3);
});
it("retries the late-comment foreign key violation and succeeds once the dependent row lands", async () => {
const execute = vi.fn()
.mockRejectedValueOnce(lateCommentForeignKeyError())
.mockResolvedValueOnce(undefined);
await truncateTablesWithDeadlockRetry(fakeDb(execute), "TRUNCATE TABLE \"issue_comments\" CASCADE", {
attempts: 5,
delayMs: () => 0,
});
expect(execute).toHaveBeenCalledTimes(2);
});
it("rethrows once attempts are exhausted on a persistent deadlock", async () => {
const error = deadlockError();
const execute = vi.fn().mockRejectedValue(error);
await expect(
truncateTablesWithDeadlockRetry(fakeDb(execute), "TRUNCATE TABLE \"companies\" CASCADE", {
attempts: 3,
delayMs: () => 0,
}),
).rejects.toBe(error);
expect(execute).toHaveBeenCalledTimes(3);
});
it("rethrows immediately on an error that is neither a deadlock nor the late-comment race", async () => {
const error = unrelatedError();
const execute = vi.fn().mockRejectedValue(error);
await expect(
truncateTablesWithDeadlockRetry(fakeDb(execute), "TRUNCATE TABLE \"companies\" CASCADE", {
attempts: 5,
delayMs: () => 0,
}),
).rejects.toBe(error);
expect(execute).toHaveBeenCalledTimes(1);
});
});
describe("errorHasPostgresCode", () => {
it("finds the code on the error itself", () => {
expect(errorHasPostgresCode(deadlockError(), "40P01")).toBe(true);
});
it("finds the code on a wrapped cause", () => {
const wrapped = new Error("wrapper", { cause: deadlockError() });
expect(errorHasPostgresCode(wrapped, "40P01")).toBe(true);
});
it("returns false when the code never matches", () => {
expect(errorHasPostgresCode(unrelatedError(), "40P01")).toBe(false);
});
it("returns false for a non-object error", () => {
expect(errorHasPostgresCode("plain string failure", "40P01")).toBe(false);
});
});
@@ -0,0 +1,49 @@
import { sql } from "drizzle-orm";
import type { createDb } from "@paperclipai/db";
type Db = ReturnType<typeof createDb>;
const LATE_COMMENT_FOREIGN_KEY = "issue_comments_issue_id_issues_id_fk";
const DEADLOCK_DETECTED_CODE = "40P01";
export function errorHasPostgresCode(error: unknown, code: string): boolean {
let current: unknown = error;
for (let depth = 0; depth < 4; depth += 1) {
if (!current || typeof current !== "object") return false;
const record = current as { code?: unknown; cause?: unknown };
if (record.code === code) return true;
current = record.cause;
}
return false;
}
function isLateWriterRace(error: unknown): boolean {
if (errorHasPostgresCode(error, DEADLOCK_DETECTED_CODE)) return true;
return error instanceof Error && error.message.includes(LATE_COMMENT_FOREIGN_KEY);
}
// A fixture cleanup can race a background write that the test's drain step
// cannot see yet. Two forms of that race show up in this suite: a PostgreSQL
// deadlock (code 40P01) when a late writer holds a row lock that the
// TRUNCATE's lock ordering also needs, and a foreign-key violation when a
// dependent row lands between two deletes. Retry a fixed number of times with
// a backoff delay before the caller gives up and rethrows.
export async function truncateTablesWithDeadlockRetry(
db: Db,
truncateStatement: string,
opts: { attempts?: number; delayMs?: (attempt: number) => number } = {},
): Promise<void> {
const attempts = opts.attempts ?? 10;
const delayMs = opts.delayMs ?? ((attempt: number) => 50 * (attempt + 1));
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
await db.execute(sql.raw(truncateStatement));
return;
} catch (error) {
if (!isLateWriterRace(error) || attempt === attempts - 1) {
throw error;
}
await new Promise((resolve) => setTimeout(resolve, delayMs(attempt)));
}
}
}
@@ -28,6 +28,37 @@ describe("HTTP logger redaction", () => {
expect(chunks.join("")).not.toMatch(/code-canary|pkce-canary|refresh-canary|redirect-canary|state-canary/);
});
it.each([[400, "/api/companies/company/agent-commentary"], [503, "/API/COMPANIES/company/AGENT-COMMENTARY"], [503, "http://localhost/api/companies/company/agent-commentary"]] as const)("keeps rejected commentary content out of %i diagnostics for %s", async (status, url) => {
const canary = "private-agent-commentary-canary";
const chunks: string[] = [];
const stream = new Writable({ write(chunk, _encoding, callback) { chunks.push(chunk.toString()); callback(); } });
const app = express();
app.use(createHttpLogger(pino({ redact: [...HTTP_LOG_REDACT_PATHS] }, stream)));
app.use(express.json());
app.post("/api/companies/:companyId/agent-commentary", (_req, res) => {
if (status === 503) (res as any).err = new Error(`Driver echoed ${canary}`);
res.status(status).end();
});
const server = createServer(app);
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
try {
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing test listener");
await new Promise<void>((resolve, reject) => {
const client = httpRequest({ hostname: "127.0.0.1", port: address.port, method: "POST", path: url, headers: { "content-type": "application/json" } }, res => {
expect(res.statusCode).toBe(status);
res.resume(); res.on("end", resolve);
});
client.on("error", reject);
client.end(JSON.stringify({ body: canary, unexpected: canary }));
});
} finally {
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
}
const output = chunks.join("");
expect(output).not.toContain(canary);
expect(JSON.parse(output.trim()).reqBody).toBe("[REDACTED]");
});
it.each([
{ method: "POST", path: "/api/routine-triggers/public/private-url-canary/fire" },
{ method: "PUT", path: "/api/routine-triggers/public/private-url-canary/fire" },
@@ -20,6 +20,7 @@ describe("instance settings service", () => {
expect(normalizeExperimentalSettings({
enableEnvironments: true,
enableNativeRunner: false,
enableAiConnectionRouters: false,
enableManagedSandboxOnly: false,
enableIsolatedWorkspaces: true,
enableIssuePlanDecompositions: true,
@@ -35,6 +36,7 @@ describe("instance settings service", () => {
})).toEqual({
enableEnvironments: true,
enableNativeRunner: false,
enableAiConnectionRouters: false,
enableManagedSandboxOnly: false,
enableIsolatedWorkspaces: true,
enableIsolatedWorkspacesByDefault: false,
@@ -44,6 +46,7 @@ describe("instance settings service", () => {
enableMcpAggregators: true,
enableAgentChat: false,
enablePublicMcp: false,
enableCombinedInboxTasks: false,
enableChatConnectors: false,
enableMemoryConnectors: false,
enableConferenceRoomChat: false,
@@ -3,12 +3,14 @@ import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const logActivityMock = vi.fn();
const canUserMock = vi.fn();
const insertMock = vi.fn();
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => ({
isInstanceAdmin: vi.fn(),
canUser: vi.fn(),
canUser: (...args: unknown[]) => canUserMock(...args),
hasPermission: vi.fn(),
}),
agentService: () => ({
@@ -44,6 +46,7 @@ function createDbStub() {
return {
insert() {
insertMock();
return {
values() {
return {
@@ -75,7 +78,7 @@ function createDbStub() {
};
}
async function createApp() {
async function createApp(source: "local_implicit" | "session" = "local_implicit") {
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
import("../routes/access.js"),
import("../middleware/index.js"),
@@ -85,9 +88,10 @@ async function createApp() {
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
source: "local_implicit",
userId: null,
source,
userId: source === "session" ? "inviter-1" : null,
companyIds: ["company-1"],
memberships: [{ companyId: "company-1", membershipRole: "operator", status: "active" }],
};
next();
});
@@ -114,6 +118,9 @@ describe("POST /companies/:companyId/invites", () => {
registerModuleMocks();
vi.clearAllMocks();
logActivityMock.mockReset();
canUserMock.mockReset();
insertMock.mockReset();
canUserMock.mockImplementation(async (_companyId, _userId, permissionKey) => permissionKey === "users:invite");
});
it("returns an absolute invite URL using the request base URL", async () => {
@@ -133,4 +140,67 @@ describe("POST /companies/:companyId/invites", () => {
expect(res.body.invitePath).toMatch(/^\/invite\/pcp_invite_/);
expect(res.body.inviteUrl).toMatch(/^https:\/\/paperclip\.example\/invite\/pcp_invite_/);
});
it.each(["operator", "viewer"])("allows Operators to invite %s members", async (humanRole) => {
const res = await request(await createApp("session"))
.post("/api/companies/company-1/invites")
.send({ allowedJoinTypes: "human", humanRole });
expect(res.status).toBe(201);
expect(insertMock).toHaveBeenCalledOnce();
});
it("allows the default Operator role for mixed human and agent invitations", async () => {
const res = await request(await createApp("session"))
.post("/api/companies/company-1/invites")
.send({ allowedJoinTypes: "both" });
expect(res.status).toBe(201);
expect(canUserMock).not.toHaveBeenCalledWith("company-1", "inviter-1", "joins:approve");
expect(canUserMock).not.toHaveBeenCalledWith("company-1", "inviter-1", "users:manage_permissions");
});
it("does not require human membership permissions for agent-only invitations", async () => {
const res = await request(await createApp("session"))
.post("/api/companies/company-1/invites")
.send({ allowedJoinTypes: "agent", humanRole: "owner" });
expect(res.status).toBe(201);
expect(canUserMock).not.toHaveBeenCalledWith("company-1", "inviter-1", "joins:approve");
expect(canUserMock).not.toHaveBeenCalledWith("company-1", "inviter-1", "users:manage_permissions");
});
it.each(["admin", "owner"])("prevents Operators from inviting %s members", async (humanRole) => {
const res = await request(await createApp("session"))
.post("/api/companies/company-1/invites")
.send({ allowedJoinTypes: "human", humanRole });
expect(res.status).toBe(403);
expect(insertMock).not.toHaveBeenCalled();
expect(logActivityMock).not.toHaveBeenCalled();
});
it("requires member-permission management to invite Owners even when join approval is allowed", async () => {
canUserMock.mockImplementation(async (_companyId, _userId, permissionKey) =>
permissionKey === "users:invite" || permissionKey === "joins:approve",
);
const res = await request(await createApp("session"))
.post("/api/companies/company-1/invites")
.send({ allowedJoinTypes: "human", humanRole: "owner" });
expect(res.status).toBe(403);
expect(canUserMock).toHaveBeenCalledWith("company-1", "inviter-1", "users:manage_permissions");
expect(insertMock).not.toHaveBeenCalled();
});
it("allows Owner invitations when both membership permissions are granted", async () => {
canUserMock.mockResolvedValue(true);
const res = await request(await createApp("session"))
.post("/api/companies/company-1/invites")
.send({ allowedJoinTypes: "human", humanRole: "owner" });
expect(res.status).toBe(201);
expect(canUserMock).toHaveBeenCalledWith("company-1", "inviter-1", "joins:approve");
expect(canUserMock).toHaveBeenCalledWith("company-1", "inviter-1", "users:manage_permissions");
});
});
+44 -27
View File
@@ -8,18 +8,20 @@ import {
normalizeHumanRole,
resolveHumanInviteRole,
} from "../services/company-member-roles.js";
import { NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS, newStandardAgentGrantScope } from "../services/agent-permissions.js";
const invitedAgentId = "agent-1";
const defaultAgentGrants = NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS.map((permissionKey) => ({
permissionKey,
scope: newStandardAgentGrantScope(permissionKey, invitedAgentId),
}));
describe("agentJoinGrantsFromDefaults", () => {
it("adds tasks:assign when invite defaults do not specify agent grants", () => {
expect(agentJoinGrantsFromDefaults(null)).toEqual([
{
permissionKey: "tasks:assign",
scope: null,
},
]);
it("adds all new-agent grants when invite defaults do not specify grants", () => {
expect(agentJoinGrantsFromDefaults(null, invitedAgentId)).toEqual(defaultAgentGrants);
});
it("preserves invite agent grants and appends tasks:assign", () => {
it("preserves invite agent grants and appends the missing defaults", () => {
expect(
agentJoinGrantsFromDefaults({
agent: {
@@ -30,17 +32,8 @@ describe("agentJoinGrantsFromDefaults", () => {
},
],
},
}),
).toEqual([
{
permissionKey: "agents:create",
scope: null,
},
{
permissionKey: "tasks:assign",
scope: null,
},
]);
}, invitedAgentId),
).toEqual([{ permissionKey: "agents:create", scope: null }, ...defaultAgentGrants]);
});
it("does not duplicate tasks:assign when invite defaults already include it", () => {
@@ -54,12 +47,19 @@ describe("agentJoinGrantsFromDefaults", () => {
},
],
},
}),
}, invitedAgentId),
).toEqual([
{
permissionKey: "tasks:assign",
scope: { projectId: "project-1" },
},
{ permissionKey: "tasks:assign", scope: { projectId: "project-1" } },
...defaultAgentGrants.filter((grant) => grant.permissionKey !== "tasks:assign"),
]);
});
it("preserves an explicit scoped agent configuration grant", () => {
expect(agentJoinGrantsFromDefaults({
agent: { grants: [{ permissionKey: "agents:configure", scope: { agentIds: ["agent-1"] } }] },
}, invitedAgentId)).toEqual([
{ permissionKey: "agents:configure", scope: { agentIds: ["agent-1"] } },
...defaultAgentGrants.filter((grant) => grant.permissionKey !== "agents:configure"),
]);
});
});
@@ -98,6 +98,25 @@ describe("human invite roles", () => {
]);
});
it("maps operator to company editing grants without join approval or member permissions", () => {
expect(grantsForHumanRole("operator")).toEqual([
{ permissionKey: "agents:create", scope: null },
{ permissionKey: "agents:configure", scope: null },
{ permissionKey: "skills:create", scope: null },
{ permissionKey: "environments:manage", scope: null },
{ permissionKey: "users:invite", scope: null },
{ permissionKey: "tasks:assign", scope: null },
{ permissionKey: "pipelines:write", scope: null },
{ permissionKey: "tools:manage_connections", scope: null },
{ permissionKey: "tools:manage_profiles", scope: null },
{ permissionKey: "tools:manage_runtime", scope: null },
{ permissionKey: "tools:use", scope: null },
{ permissionKey: "tools:admin", scope: null },
{ permissionKey: "tools:view_audit", scope: null },
{ permissionKey: "audit:view_agent_actions", scope: null },
]);
});
it("defaults legacy or missing roles to operator", () => {
expect(normalizeHumanRole("member")).toBe("operator");
expect(resolveHumanInviteRole(null)).toBe("operator");
@@ -114,9 +133,7 @@ describe("human invite roles", () => {
});
it("falls back to role grants when human invite defaults omit explicit grants", () => {
expect(humanJoinGrantsFromDefaults(null, "operator")).toEqual([
{ permissionKey: "tasks:assign", scope: null },
]);
expect(humanJoinGrantsFromDefaults(null, "operator")).toEqual(grantsForHumanRole("operator"));
});
it("preserves explicit human invite grants", () => {
@@ -74,7 +74,7 @@ async function createApp(
return app;
}
describe.sequential("GET /invites/:token/test-resolution", () => {
describe("GET /invites/:token/test-resolution", () => {
beforeEach(() => {
vi.clearAllMocks();
});
@@ -112,7 +112,7 @@ describe.sequential("GET /invites/:token/test-resolution", () => {
}
}, 20_000);
it.sequential("rejects hostnames that resolve to private addresses", async () => {
it("rejects hostnames that resolve to private addresses", async () => {
const lookup = vi.fn().mockResolvedValue([{ address: "10.1.2.3", family: 4 }]);
const requestHead = vi.fn();
const app = await createApp(createDbStub([createInvite()]), { lookup, requestHead });
@@ -129,7 +129,7 @@ describe.sequential("GET /invites/:token/test-resolution", () => {
expect(requestHead).not.toHaveBeenCalled();
});
it.sequential("rejects hostnames when any resolved address is private", async () => {
it("rejects hostnames when any resolved address is private", async () => {
const lookup = vi.fn().mockResolvedValue([
{ address: "127.0.0.1", family: 4 },
{ address: "93.184.216.34", family: 4 },
@@ -145,7 +145,7 @@ describe.sequential("GET /invites/:token/test-resolution", () => {
expect(requestHead).not.toHaveBeenCalled();
});
it.sequential("allows public HTTPS targets through the resolved and pinned probe path", async () => {
it("allows public HTTPS targets through the resolved and pinned probe path", async () => {
const lookup = vi.fn().mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
const requestHead = vi.fn().mockResolvedValue({ httpStatus: 204 });
const app = await createApp(createDbStub([createInvite()]), { lookup, requestHead });
@@ -174,7 +174,7 @@ describe.sequential("GET /invites/:token/test-resolution", () => {
);
});
it.sequential.each([
it.each([
["missing invite", []],
["revoked invite", [createInvite({ revokedAt: new Date("2026-03-07T00:05:00.000Z") })]],
["expired invite", [createInvite({ expiresAt: new Date("2020-03-07T00:10:00.000Z") })]],
@@ -204,7 +204,7 @@ async function assertNoBackgroundClearWithinRetryWindow() {
expect(mockExecutionWorkspaceService.clearReopenPendingConsumptionForUnconsumedReopen).not.toHaveBeenCalled();
}
describe.sequential("closed isolated workspace issue routes", () => {
describe("closed isolated workspace issue routes", () => {
const routeModules = hoistModuleGraph(registerServiceMocks, async () => {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
@@ -191,7 +191,7 @@ function makeComment(overrides: Record<string, unknown> = {}) {
};
}
describe.sequential("issue comment cancel routes", () => {
describe("issue comment cancel routes", () => {
const routeModules = hoistModuleGraph(registerModuleMocks, async () => {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
@@ -43,7 +43,7 @@ const embeddedPostgresSupport = externalTestDatabaseUrl
? { supported: true }
: await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported
? describe.sequential
? describe
: describe.skip;
if (!embeddedPostgresSupport.supported) {
@@ -317,7 +317,7 @@ async function waitForWakeup(assertion: () => void) {
await vi.waitFor(assertion);
}
describe.sequential("issue comment reopen routes", () => {
describe("issue comment reopen routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIssueService.getById.mockReset();
@@ -30,6 +30,7 @@ import { issueService } from "../services/issues.js";
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
import { remoteTerminationReceipt } from "../services/remote-execution-termination.js";
import { initializeRunIdentity, reconcileSteeredIdentity } from "../services/run-identity.js";
import { appendHeartbeatRunEvent } from "../services/heartbeat-run-events.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
@@ -44,7 +45,7 @@ vi.mock("../services/native-runtime/native-session-executor.js", async (importOr
const { NativeSessionSteeringError } = await import("../services/native-runtime/native-session-executor.js");
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
@@ -1449,6 +1450,22 @@ describeEmbeddedPostgres("issue queued-comment routes", () => {
});
});
it.each(["paperclip_runner", "codex_local"])("keeps Steer during native preparation even after agent settings change to %s", async adapterType => {
const seeded = await seedQueue();
const steeringCalls = steerNativeSessionMock.mock.calls.length;
await db.update(heartbeatRuns).set({ runtimeMode: "legacy", runtimeModeResolvedAt: null,
runnerProfileJson: { adapterDispatch: { adapterType: "paperclip_runner" } },
}).where(eq(heartbeatRuns.id, seeded.runId));
await db.update(agents).set({ adapterType }).where(eq(agents.id, seeded.agentId));
const client = app(seeded.companyId);
const initial = await request(client).get(`/api/issues/${seeded.issueId}/queued-comments`).expect(200);
expect(initial.body).toMatchObject({ protocol: "paperclip_runner_v1", steeringDisposition: "temporarily_unavailable" });
const edited = await request(client).patch(`/api/issues/${seeded.issueId}/queued-comments/${seeded.commentIds[0]}`)
.send({ queueId: seeded.wakeId, revision: initial.body.revision, body: "Updated direction" }).expect(200);
expect(edited.body).toMatchObject({ protocol: "paperclip_runner_v1", steeringDisposition: "temporarily_unavailable" });
expect(steerNativeSessionMock.mock.calls).toHaveLength(steeringCalls);
});
it("serializes discard against queued-run claim", async () => {
const seeded = await seedQueue();
await db.delete(issueComments).where(eq(issueComments.id, seeded.commentIds[1]));
@@ -1579,6 +1596,65 @@ describeEmbeddedPostgres("issue queued-comment routes", () => {
});
});
it("allows the runner to persist its acknowledgement while steering, preserving concurrent run updates", async () => {
const seeded = await seedQueue();
await seedDispatchIdentity(seeded);
const originalProfile = { adapterDispatch: { adapterType: "paperclip_runner" },
nativeExecutionInput: { provider: { kind: "codex", model: "gpt-5.6-sol", reasoningEffort: "medium" } } };
await db.update(heartbeatRuns).set({ runnerProfileJson: originalProfile }).where(eq(heartbeatRuns.id, seeded.runId));
await db.update(issues).set({ assigneeAdapterOverrides: { model: "gpt-6-sol", reasoningEffort: "xhigh" } })
.where(eq(issues.id, seeded.issueId));
steerNativeSessionMock.mockImplementationOnce(async () => {
await db.transaction(async tx => {
await tx.execute(sql`set local lock_timeout = '1000ms'`);
await appendHeartbeatRunEvent(tx as unknown as typeof db, {
companyId: seeded.companyId, agentId: seeded.agentId, runId: seeded.runId,
eventType: "item.completed", payload: { kind: "steering_acknowledgement" },
});
await tx.update(heartbeatRuns).set({ resultJson: { concurrentProviderReceipt: "preserve" } })
.where(eq(heartbeatRuns.id, seeded.runId));
});
return { turnId: "turn-before-model-change" };
});
const initial = await request(app(seeded.companyId)).get(`/api/issues/${seeded.issueId}/queued-comments`);
const steered = await request(app(seeded.companyId))
.post(`/api/issues/${seeded.issueId}/queued-comments/${seeded.commentIds[0]}/steer`)
.send({ queueId: seeded.wakeId, targetRunId: seeded.runId, revision: initial.body.revision });
expect(steered.status, JSON.stringify(steered.body)).toBe(200);
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seeded.runId));
expect(run.status).toBe("running");
expect(run.runnerProfileJson).toEqual(originalProfile);
expect(steerNativeSessionMock).toHaveBeenCalledWith(expect.objectContaining({ runId: seeded.runId,
message: "First queued message", correlationId: seeded.commentIds[0] }));
expect(run.resultJson).toMatchObject({ concurrentProviderReceipt: "preserve", queuedSteeringAcknowledgements: {
[seeded.commentIds[0]]: { status: "acknowledged", turnId: "turn-before-model-change" },
} });
expect(steered.body.entries.map((entry: any) => entry.comment.id)).toEqual([seeded.commentIds[1]]);
});
it.each(["cancelled", "failed", "succeeded"] as const)("preserves queued input when the run stops during acknowledgement (%s)", async status => {
const seeded = await seedQueue();
await seedDispatchIdentity(seeded);
steerNativeSessionMock.mockImplementationOnce(async () => {
await db.update(heartbeatRuns).set({ status, finishedAt: new Date(), resultJson: { terminalReceipt: "preserve" } })
.where(eq(heartbeatRuns.id, seeded.runId));
return { turnId: "turn-that-stopped" };
});
const initial = await request(app(seeded.companyId)).get(`/api/issues/${seeded.issueId}/queued-comments`);
const response = await request(app(seeded.companyId))
.post(`/api/issues/${seeded.issueId}/queued-comments/${seeded.commentIds[0]}/steer`)
.send({ queueId: seeded.wakeId, targetRunId: seeded.runId, revision: initial.body.revision });
expect(response.status).toBe(409);
expect(response.body.details).toMatchObject({ code: "queued_comment_stale_target" });
const [wake] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, seeded.wakeId));
expect(wake.status).toBe("deferred_issue_execution");
expect((wake.payload as any)._paperclipWakeContext.wakeCommentIds).toEqual(seeded.commentIds);
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seeded.runId));
expect(run.resultJson).toEqual({ terminalReceipt: "preserve" });
const activity = await db.select().from(activityLog).where(eq(activityLog.action, "issue.queued_comment_steered"));
expect(activity).toHaveLength(0);
});
it("keeps the identity pending after a steering timeout, then reconciles it on a later acknowledgement", async () => {
const seeded = await seedQueue();
await seedDispatchIdentity(seeded);
@@ -308,7 +308,7 @@ async function resolveMockInteraction(
return interaction;
}
describe.sequential("issue thread interaction routes", () => {
describe("issue thread interaction routes", () => {
beforeEach(async () => {
vi.resetModules();
vi.doUnmock("../routes/issues.js");
@@ -4474,6 +4474,96 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
};
}
it.each(["request_confirmation", "request_checkbox_confirmation"] as const)(
"projects %s readiness until its source workspace settles",
async (kind) => {
const { companyId, executionWorkspaceId, issueId, interactionId, sourceRunId, foreignRunId } =
await seedAcceptGateFixture({ kind, sourceRunStatus: "running" });
// A source run without workspace operations needs no sync barrier.
expect((await interactionsSvc.listForIssue(issueId))[0].acceptanceBlocker).toBeUndefined();
await db.insert(workspaceOperations).values({
companyId, executionWorkspaceId, heartbeatRunId: sourceRunId,
phase: "worktree_prepare", status: "succeeded",
});
const assertPreparing = async () => {
const expected = { id: interactionId, status: "pending", acceptanceBlocker: "workspace_sync_pending" };
expect((await interactionsSvc.listForIssue(issueId))[0]).toMatchObject(expected);
expect(await interactionsSvc.getById(interactionId)).toMatchObject(expected);
expect(await interactionsSvc.getForIssue({ id: issueId, companyId }, interactionId)).toMatchObject(expected);
};
await assertPreparing();
const [finalize] = await db.insert(workspaceOperations).values({
companyId, executionWorkspaceId, heartbeatRunId: sourceRunId,
phase: "workspace_finalize", status: "running",
}).returning();
await assertPreparing();
await db.update(workspaceOperations).set({ status: "succeeded" }).where(eq(workspaceOperations.id, finalize.id));
await db.insert(workspaceOperations).values({
companyId, executionWorkspaceId, heartbeatRunId: foreignRunId,
phase: "worktree_prepare", status: "succeeded",
});
expect((await interactionsSvc.listForIssue(issueId))[0].acceptanceBlocker).toBeUndefined();
expect((await interactionsSvc.getById(interactionId))?.acceptanceBlocker).toBeUndefined();
expect((await interactionsSvc.getForIssue({ id: issueId, companyId }, interactionId)).acceptanceBlocker).toBeUndefined();
},
);
it.each(["failed", "skipped", "stale"])("does not keep preparing after a %s finalize", async (outcome) => {
const { companyId, executionWorkspaceId, issueId, sourceRunId } =
await seedAcceptGateFixture({ sourceRunStatus: "failed" });
await db.insert(workspaceOperations).values({
companyId, executionWorkspaceId, heartbeatRunId: sourceRunId,
phase: "workspace_finalize", status: outcome === "stale" ? "running" : outcome,
});
expect((await interactionsSvc.listForIssue(issueId))[0].acceptanceBlocker).toBeUndefined();
});
it.each(["accepted", "rejected", "cancelled", "expired"])("does not project a blocker on %s history", async (status) => {
const { companyId, executionWorkspaceId, issueId, interactionId, sourceRunId } = await seedAcceptGateFixture();
await db.insert(workspaceOperations).values({
companyId, executionWorkspaceId, heartbeatRunId: sourceRunId,
phase: "worktree_prepare", status: "succeeded",
});
await db.update(issueThreadInteractions).set({ status }).where(eq(issueThreadInteractions.id, interactionId));
expect((await interactionsSvc.listForIssue(issueId))[0].acceptanceBlocker).toBeUndefined();
expect((await interactionsSvc.getById(interactionId))?.acceptanceBlocker).toBeUndefined();
});
it("does not project a blocker for confirmations expired by task closure", async () => {
const { companyId, executionWorkspaceId, issueId, sourceRunId } = await seedAcceptGateFixture();
await db.insert(workspaceOperations).values({
companyId, executionWorkspaceId, heartbeatRunId: sourceRunId,
phase: "worktree_prepare", status: "succeeded",
});
await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId));
const [interaction] = await interactionsSvc.listForIssue(issueId);
expect(interaction.status).toBe("expired");
expect(interaction.acceptanceBlocker).toBeUndefined();
});
it.each(["toolAction", "ask_user_questions"])(
"keeps live %s decisions available while the workspace is active",
async (kind) => {
const { companyId, executionWorkspaceId, issueId, interactionId, sourceRunId } =
await seedAcceptGateFixture({ sourceRunStatus: "running" });
await db.insert(workspaceOperations).values({
companyId, executionWorkspaceId, heartbeatRunId: sourceRunId,
phase: "worktree_prepare", status: "succeeded",
});
const payload = kind === "ask_user_questions"
? { version: 1, questions: [{ id: "question", prompt: "Which option?", selectionMode: "single", options: [{ id: "first", label: "First" }] }] }
: { version: 1, prompt: "Allow this action?", [kind]: { version: 1, actionRequestId: randomUUID(), invocationId: randomUUID(), toolName: "example.write",
toolDisplayName: "Write", connectionId: randomUUID(), applicationId: randomUUID(),
appDisplayName: "Example", risk: "write", previewMarkdown: "Write one row", argumentsSummaryJson: "{}",
argumentsHash: "hash", expiresAt: "2099-01-01T00:00:00.000Z" } };
await db.update(issueThreadInteractions).set({
kind: kind === "ask_user_questions" ? kind : "request_confirmation", payload,
}).where(eq(issueThreadInteractions.id, interactionId));
expect((await interactionsSvc.listForIssue(issueId))[0].acceptanceBlocker).toBeUndefined();
expect((await interactionsSvc.getById(interactionId))?.acceptanceBlocker).toBeUndefined();
},
);
it("allows request_confirmation accept when the source run finalized but a foreign run is mid-flight", async () => {
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId, foreignRunId } =
await seedAcceptGateFixture();
@@ -190,7 +190,7 @@ const projectGoal = {
updatedAt: new Date("2026-03-20T00:00:00Z"),
};
describe.sequential("issue goal context routes", () => {
describe("issue goal context routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAccessService.decide.mockResolvedValue({
@@ -0,0 +1,73 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
// Pi resolves its session directory at module load. Keep that test directory
// under the temporary root instead of the operator's real home.
vi.mock("node:os", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:os")>();
return { ...actual, default: { ...actual.default, homedir: actual.tmpdir }, homedir: actual.tmpdir };
});
vi.mock("@paperclipai/adapter-utils/execution-target", async (importOriginal) => {
const actual = await importOriginal<typeof import("@paperclipai/adapter-utils/execution-target")>();
return { ...actual, runAdapterExecutionTargetProcess: vi.fn() };
});
import { runAdapterExecutionTargetProcess } from "@paperclipai/adapter-utils/execution-target";
import { execute as gemini } from "@paperclipai/adapter-gemini-local/server";
import { execute as kimi } from "@paperclipai/adapter-kimi-local/server";
import { execute as cursor } from "@paperclipai/adapter-cursor-local/server";
import { execute as opencode } from "@paperclipai/adapter-opencode-local/server";
import { execute as pi } from "@paperclipai/adapter-pi-local/server";
const roots: string[] = [];
afterEach(async () => { vi.clearAllMocks(); await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); });
describe("legacy environment tool access on resumed conversations", () => {
it.each([
["gemini_local", gemini, "--resume"], ["kimi_local", kimi, "-r"],
["cursor", cursor, "--resume"], ["opencode_local", opencode, "--session"], ["pi_local", pi, "--session"],
] as const)("refreshes %s without dropping its conversation", async (type, execute, resumeFlag) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cli-tool-refresh-")); roots.push(root);
const command = path.join(root, "agent");
await fs.writeFile(command, "#!/bin/sh\nprintf 'provider model\\nopenai gpt-5\\n'\n", { mode: 0o755 });
const sessionId = type === "pi_local" ? path.join(root, "session.jsonl") : "existing-session";
if (type === "pi_local") await fs.writeFile(sessionId, JSON.stringify({ type: "session", cwd: root }) + "\n");
const stdout = type === "kimi_local"
? JSON.stringify({ role: "meta", type: "session.resume_hint", session_id: sessionId })
: type === "opencode_local"
? JSON.stringify({ type: "text", sessionID: sessionId, part: { text: "done" } })
: type === "pi_local"
? JSON.stringify({ type: "agent_end", messages: [{ role: "assistant", content: "done" }] })
: JSON.stringify({ type: "result", subtype: "success", session_id: sessionId, result: "done" });
const invocations: Array<{ args: string[]; env: Record<string, string> }> = [];
vi.mocked(runAdapterExecutionTargetProcess).mockImplementation(async (_runId, _target, _command, args, options) => {
invocations.push({ args, env: options.env ?? {} });
return { exitCode: 0, signal: null, timedOut: false, stdout, stderr: "", pid: null, startedAt: null };
});
const getFreshSessionHandoff = vi.fn(async () => "FRESH_HANDOFF_ONLY");
const ctx: AdapterExecutionContext = {
getFreshSessionHandoff,
runId: "refresh", agent: { id: "agent", companyId: "company", name: "Agent", adapterType: type, adapterConfig: {} },
runtime: { sessionId, sessionParams: { sessionId, cwd: root }, sessionDisplayId: sessionId, taskKey: null },
config: { command, cwd: root, model: "openai/gpt-5", engine: "cli", env: { HOME: root, XDG_CONFIG_HOME: root, OPENCODE_ALLOW_ALL_MODELS: "1" } },
context: { refreshTools: true, paperclipFreshSessionHandoffMarkdown: "FRESH_HANDOFF_ONLY" }, onLog: async () => {},
runtimeTools: { version: 1, guidance: "Tools available", mcpEndpoint: "https://example.test/current-tools/mcp",
rest: { connectionsSearch: "https://example.test/search", connectionRequest: "https://example.test/request" },
bearerToken: "current-tool-token", expiresAt: "2027-01-01T00:00:00Z", tools: ["connections_search", "connection_request"] },
};
const result = await execute(ctx);
expect(result.exitCode).toBe(0);
const invocation = invocations.at(-1)!;
expect(invocation.args[invocation.args.indexOf(resumeFlag) + 1]).toBe(sessionId);
expect(invocation.env.PAPERCLIP_RUNTIME_TOOLS_MCP_URL).toBe(ctx.runtimeTools!.mcpEndpoint);
expect(invocation.env.PAPERCLIP_RUNTIME_TOOLS_TOKEN).toBe("current-tool-token");
expect(invocation.args.join(" ")).not.toContain("FRESH_HANDOFF_ONLY");
expect(result.sessionParams?.sessionId).toBe(sessionId);
// Revocation likewise gets the current environment on the same session.
await execute({ ...ctx, runtimeTools: undefined });
expect(invocations.at(-1)!.env.PAPERCLIP_RUNTIME_TOOLS_MCP_URL).toBeUndefined();
expect(getFreshSessionHandoff).not.toHaveBeenCalled();
});
});
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { readVerifiedLocalAiCredential } from "../services/local-ai-credentials.js";
const mocks = vi.hoisted(() => ({ claude: vi.fn(), claudeIsolatedKeychain: vi.fn(), claudeQuota: vi.fn(), codex: vi.fn(), codexQuota: vi.fn(), readFile: vi.fn(), credentialFile: vi.fn() }));
vi.mock("@paperclipai/adapter-claude-local/server", () => ({ readClaudeToken: mocks.claude, readIsolatedClaudeKeychainToken: mocks.claudeIsolatedKeychain, fetchClaudeQuota: mocks.claudeQuota }));
const mocks = vi.hoisted(() => ({ claude: vi.fn(), claudeIsolatedKeychain: vi.fn(), codex: vi.fn(), codexQuota: vi.fn(), readFile: vi.fn(), credentialFile: vi.fn() }));
vi.mock("@paperclipai/adapter-claude-local/server", () => ({ readClaudeToken: mocks.claude, readIsolatedClaudeKeychainToken: mocks.claudeIsolatedKeychain }));
vi.mock("@paperclipai/adapter-codex-local/server", () => ({ readCodexAuthInfo: mocks.codex, fetchCodexQuota: mocks.codexQuota }));
vi.mock("../services/local-ai-credential-file.js", () => ({ readLocalAiCredentialFile: mocks.credentialFile }));
vi.mock("node:fs/promises", () => ({ default: { readFile: mocks.readFile } }));
@@ -11,18 +11,16 @@ describe("explicit local subscription import", () => {
mocks.credentialFile.mockResolvedValue(JSON.stringify({ claudeAiOauth: { accessToken: "isolated-claude" } }));
await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).resolves.toBe("isolated-claude");
expect(mocks.credentialFile).toHaveBeenCalledWith("/isolated/claude/.credentials.json");
expect(mocks.claudeQuota).toHaveBeenCalledWith("isolated-claude");
expect(mocks.claude).not.toHaveBeenCalled();
});
it("does not fall back to ambient Claude auth when an isolated login is absent or invalid", async () => {
mocks.claude.mockResolvedValue("server-operator-token");
mocks.claudeIsolatedKeychain.mockResolvedValue(null);
mocks.credentialFile.mockRejectedValue(new Error("No file"));
await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).rejects.toThrow("sign-in command shown");
await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).rejects.toThrow("Finish browser sign-in");
mocks.credentialFile.mockResolvedValue("malformed");
await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).rejects.toThrow("sign-in command shown");
await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).rejects.toThrow("Finish browser sign-in");
expect(mocks.claude).not.toHaveBeenCalled();
expect(mocks.claudeQuota).not.toHaveBeenCalled();
expect(mocks.claudeIsolatedKeychain).toHaveBeenCalledWith("/isolated/claude");
});
it("verifies a macOS isolated login from the home's suffixed Keychain item when no credentials file exists", async () => {
@@ -33,7 +31,6 @@ describe("explicit local subscription import", () => {
mocks.claudeIsolatedKeychain.mockResolvedValue("isolated-keychain-claude");
await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).resolves.toBe("isolated-keychain-claude");
expect(mocks.claudeIsolatedKeychain).toHaveBeenCalledWith("/isolated/claude");
expect(mocks.claudeQuota).toHaveBeenCalledWith("isolated-keychain-claude");
expect(mocks.claude).not.toHaveBeenCalled();
});
it("prefers the credentials file over the Keychain for an isolated login", async () => {
@@ -48,12 +45,6 @@ describe("explicit local subscription import", () => {
expect(mocks.credentialFile).toHaveBeenLastCalledWith("/isolated/claude/credentials.json");
expect(mocks.claude).not.toHaveBeenCalled();
});
it("verifies Claude's local credential, including explicit Keychain access", async () => {
mocks.claude.mockResolvedValue("fixture-claude");
await expect(readVerifiedLocalAiCredential("anthropic")).resolves.toBe("fixture-claude");
expect(mocks.claude).toHaveBeenCalledWith({ allowKeychain: true });
expect(mocks.claudeQuota).toHaveBeenCalledWith("fixture-claude");
});
it("reads Codex refresh credentials only from the isolated login home", async () => {
mocks.codex.mockResolvedValue({ accessToken: "access", refreshToken: "refresh", idToken: "identity", accountId: "account", lastRefresh: "date" });
const result = JSON.parse(await readVerifiedLocalAiCredential("openai", "/isolated/login"));
@@ -70,17 +61,18 @@ describe("explicit local subscription import", () => {
expect(fetch).toHaveBeenCalledWith("https://api.x.ai/v1/models", expect.objectContaining({ redirect: "error" }));
});
it("rejects missing and invalid logins with actionable, redacted errors", async () => {
mocks.claude.mockResolvedValue(null);
await expect(readVerifiedLocalAiCredential("anthropic")).rejects.toThrow("claude auth login");
mocks.claude.mockResolvedValue("fixture-secret");
mocks.claudeQuota.mockRejectedValue(new Error("credential fixture-secret rejected"));
await expect(readVerifiedLocalAiCredential("anthropic")).rejects.toThrow(/^Could not verify the local subscription\. Run claude auth login in a terminal on the machine running Paperclip, then try Connect again\.$/);
await expect(readVerifiedLocalAiCredential("anthropic")).rejects.toThrow("Start browser sign-in");
mocks.credentialFile.mockResolvedValue(JSON.stringify({ claudeAiOauth: { accessToken: "" } }));
mocks.claudeIsolatedKeychain.mockResolvedValue(null);
await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).rejects.toThrow(/^Could not verify the local subscription\. Finish browser sign-in for this connection, then try Connect again\.$/);
mocks.codex.mockResolvedValue({ accessToken: "incomplete" });
await expect(readVerifiedLocalAiCredential("openai", "/isolated/login")).rejects.toThrow("sign-in command shown");
await expect(readVerifiedLocalAiCredential("openai", "/isolated/login")).rejects.toThrow("Finish browser sign-in");
expect(mocks.codexQuota).not.toHaveBeenCalled();
});
it.each(["openai", "xai"] as const)("never clones the ambient rotating %s login", async (provider) => {
await expect(readVerifiedLocalAiCredential(provider)).rejects.toThrow("separate local sign-in");
it.each(["anthropic", "openai", "xai"] as const)("never clones the ambient rotating %s login", async (provider) => {
await expect(readVerifiedLocalAiCredential(provider)).rejects.toThrow(provider === "xai"
? "Start local sign-in"
: "Start browser sign-in");
expect(mocks.codex).not.toHaveBeenCalled();
expect(mocks.readFile).not.toHaveBeenCalled();
});
@@ -134,7 +134,7 @@ describe("merged pull-request confirmation extraction", () => {
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres.sequential("merged pull-request confirmation sweep", () => {
describeEmbeddedPostgres("merged pull-request confirmation sweep", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
@@ -12,7 +12,7 @@ import { issueRoutes } from "../routes/issues.js";
import type { StorageService } from "../storage/types.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
@@ -0,0 +1,161 @@
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { afterAll, describe, expect, it } from "vitest";
import type { Db } from "@paperclipai/db";
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
import { buildNativeModelEnvelope, parseNativeExecutionInput, type NativeExecutionInput } from "../../../packages/paperclip-runner/src/contracts/native-execution.js";
import type { PersistedNativeSession } from "../../../packages/paperclip-runner/src/contracts/native-session-backend.js";
import { NATIVE_RUNTIME_ASSET_SCHEMA, PAPERCLIP_EXECUTION_PROMPT, PAPERCLIP_EXECUTION_PROMPT_REVISION, canonicalNativeRuntimeContextDigest, nativeRuntimePromptDigest } from "../../../packages/paperclip-runner/src/contracts/runtime-context.js";
import { createRunnerdNativeSessionBackend } from "../../../packages/paperclip-runner/src/backends/codex-native-backend.js";
import { resolveQualifiedAcpxProfile } from "../../../packages/paperclip-runner/src/drivers/acpx/qualified-profiles.js";
import { FakeCodexTransport, WORKSPACE } from "../../../packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test-support.js";
import { startOpenCodeMcpBridge } from "../../../packages/paperclip-runner/src/drivers/opencode/mcp-bridge.js";
// Production catalog selection, scripted provider transport: this measures what
// Paperclip delivers, not the vendor's private prompt, tokenizer or billing.
const root = new URL("../../../", import.meta.url);
const sha = (value: string | Buffer) => createHash("sha256").update(value).digest("hex");
const identity = { companyId: "company", agentId: "agent", issueId: "issue", runId: "run", sessionId: "session" };
const tools = new PaperclipRunnerToolAuthority({} as Db, {
...identity, apiToolsEnabled: true, workspaceRoot: WORKSPACE,
}).definitions();
const { qualificationModel: _model, reportedModelId: _reportedModel, permissionPolicy: _policy, ...profile } = resolveQualifiedAcpxProfile("claude", "claude-sonnet-5");
const providers: NativeExecutionInput["provider"][] = [
{ kind: "codex", model: "gpt-5.6-sol", approvalPolicy: "never" },
{ kind: "acpx", agent: "claude", model: "claude-sonnet-5", permissionMode: "approve-all", profile },
{ kind: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731", permissionMode: "allow" },
];
const receipts: unknown[] = [];
let mcpReceipt: unknown;
function measure(value: unknown) {
const json = JSON.stringify(value);
const normalized = json.replaceAll(WORKSPACE, "/workspace");
return {
utf8Bytes: Buffer.byteLength(json), characters: json.length, sha256: sha(json),
normalizedComparison: { utf8Bytes: Buffer.byteLength(normalized), characters: normalized.length, sha256: sha(normalized) },
};
}
function execution(provider: NativeExecutionInput["provider"]): NativeExecutionInput {
const entry = readFileSync(new URL("server/src/onboarding-assets/default/AGENTS.md", root), "utf8");
writeFileSync(`${WORKSPACE}/AGENTS.md`, entry);
const context = {
prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
instructions: { entryPath: "AGENTS.md", bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, rootPath: WORKSPACE, digest: sha(entry), manifestDigest: sha(entry), fileCount: 1, totalBytes: Buffer.byteLength(entry) } },
skills: [], mcp: { assignmentSetId: "none", digest: "0".repeat(64), bindingId: null },
};
return parseNativeExecutionInput({
schema: "paperclip.native-execution-input.v5", binding: { companyId: identity.companyId, agentId: identity.agentId, issueId: identity.issueId, runId: identity.runId, executionWorkspaceId: "workspace" },
task: { identifier: "TASK-1", title: "Review delegated work", description: "Review delegated work", prompt: "Review delegated work.", workMode: "standard" },
workspace: { cwd: WORKSPACE, repoUrl: null, repoRef: null, branchName: null },
session: { normalizedSessionId: "session", driverKind: provider.kind === "acpx" ? "acpx_runtime" : provider.kind === "opencode" ? "opencode_server" : "codex_app_server", protocolVersion: 1 },
provider, executionMode: "default", planningContext: null,
completionContract: { id: "contract", sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.completion-contract.v1", contract: { revision: "1", objective: "Review delegated work", criteria: [{ id: "objective", requirement: "Review complete" }] } },
interactionResponses: [], credentialBindings: [],
runtimeContext: { ...context, aggregateDigest: canonicalNativeRuntimeContextDigest(context) },
});
}
function transport(provider: NativeExecutionInput["provider"]) {
return new FakeCodexTransport("thread-1", "provider-session-1", provider.kind === "acpx" ? {
kind: "acpx", normalizedSessionId: "session", acpxRecordId: "fixture-record", backendSessionId: "fixture-backend",
agentSessionId: "fixture-agent", profileDigest: `sha256:${"a".repeat(64)}`, workspaceDigest: `sha256:${"b".repeat(64)}`,
requestedModel: provider.model, effectiveModel: provider.model, permissionMode: "approve-all",
providerLifetimeFenceCandidates: [60001, 60002, 60003],
} : undefined);
}
describe("production native procedure instruction measurement", () => {
it("retains delivered serialized bytes separately from path-normalized comparison", () => {
const value = { cwd: WORKSPACE, text: "λ" };
expect(measure(value).utf8Bytes).toBe(Buffer.byteLength(JSON.stringify(value)));
expect(measure(value).normalizedComparison.utf8Bytes).toBe(Buffer.byteLength(JSON.stringify({ ...value, cwd: "/workspace" })));
expect(measure(value).sha256).not.toBe(measure(value).normalizedComparison.sha256);
});
it("uses the real standard-mode authority, including optional and connection tools", () => {
expect(tools.map(tool => tool.name)).toEqual(expect.arrayContaining([
"hire_agent", "list_agents", "create_task", "set_dependencies", "connections_search", "connection_request", "register_deliverable",
]));
expect(new Set(tools.map(tool => tool.name)).size).toBe(tools.length);
const withoutApi = new PaperclipRunnerToolAuthority({} as Db, { ...identity, apiToolsEnabled: false }).definitions();
expect(withoutApi.some(tool => tool.name === "hire_agent")).toBe(false);
});
for (const provider of providers) it(`captures the full ${provider.kind} start/resume/continuation payload`, async () => {
const input = execution(provider);
let checkpoint: PersistedNativeSession | undefined;
for (const phase of ["start", "resume", "continuation"] as const) {
const captured = transport(provider);
captured.readResponse = { thread: { id: "thread-1", sessionId: "provider-session-1", cwd: WORKSPACE, turns: [] } };
const current = phase === "continuation" ? parseNativeExecutionInput({ ...input, continuationPrompt: "Review the teammate's saved result." }) : input;
const backend = createRunnerdNativeSessionBackend(current, { transportFactory: () => captured, dynamicTools: tools, environment: { PAPERCLIP_WORKSPACE_CWD: WORKSPACE } });
const recovery = checkpoint ? await backend.recoverSession!(checkpoint, { signal: new AbortController().signal }) : null;
if (recovery) expect(recovery.recovered).toBe(true);
const session = recovery?.session ?? await backend.openSession({ identity, workingDirectory: WORKSPACE });
try {
checkpoint ??= await session.snapshot();
const envelope = buildNativeModelEnvelope(current, phase === "continuation" ? { resumedSession: true } : undefined);
if ("requestedSkills" in envelope && backend.preparedTaskConstraints) envelope.constraints = [...backend.preparedTaskConstraints];
await session.startTurn({ message: { role: "user", text: JSON.stringify(envelope) } });
const setup = captured.calls.find(call => call.method === (phase === "start" ? "thread/start" : "thread/resume"))!.params;
const turn = captured.calls.find(call => call.method === "turn/start")!.params;
const instructions = setup.developerInstructions ?? setup.baseInstructions;
const delivered = setup.dynamicTools as Record<string, unknown>[];
expect(delivered.filter(tool => !["paperclip_finish", "paperclip_block"].includes(String(tool.name)))).toEqual(tools);
expect(delivered).toHaveLength(tools.length + 2);
expect(instructions).toContain(PAPERCLIP_EXECUTION_PROMPT);
expect(JSON.parse((turn.input as {text: string}[])[0]!.text)).toEqual(envelope);
receipts.push({ provider: provider.kind, phase, instructions: measure(instructions), tools: measure(delivered), input: measure(turn.input),
fullProjection: measure({ instructions, tools: delivered, input: turn.input }), deliveredToolCount: delivered.length });
} finally { await session.close({ reason: "measurement" }); }
}
});
it("preserves the full catalog through the actual OpenCode MCP tools/list bridge", async () => {
const bridge = await startOpenCodeMcpBridge({ tools, handler: async () => { throw new Error("No tool execution in measurement"); } });
try {
const response = await fetch(bridge.url, { method: "POST", headers: { authorization: `Bearer ${bridge.secret}`, "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }) });
expect(response.ok).toBe(true);
const result = await response.json() as { result: { tools: Record<string, unknown>[] } };
expect(result.result.tools.filter(tool => !["paperclip_finish", "paperclip_block"].includes(String(tool.name)))).toEqual(
tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
);
mcpReceipt = { boundary: "authenticated loopback MCP tools/list; no OpenCode/provider process", serverAnnotationsForwarded: false, tools: measure(result.result.tools), count: result.result.tools.length };
} finally { await bridge.close(); }
});
});
afterAll(() => {
const output = process.env.PAPERCLIP_NATIVE_PROCEDURE_MEASUREMENT;
if (!output) return;
if (receipts.length !== 9 || !mcpReceipt) throw new Error("Refusing incomplete instruction measurement");
const paths = [
"server/src/onboarding-assets/default/AGENTS.md",
"packages/paperclip-runner/src/contracts/runtime-context.ts",
"packages/paperclip-runner/src/protocol-actions/hire-agent.ts",
"packages/paperclip-runner/src/protocol-actions/create-task.ts",
"packages/paperclip-runner/src/protocol-actions/set-dependencies.ts",
"packages/paperclip-runner/src/catalog/semantic-action-catalog.ts",
"packages/paperclip-runner/generated/semantic-action-catalog.json",
"packages/paperclip-runner/generated/capability/semantic-tool-contracts.json",
"packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json",
"packages/paperclip-runner/protocol/manifest.json",
"packages/paperclip-runner/src/backends/codex-native-backend.ts",
"packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts",
"server/src/services/native-runtime/paperclip-runner-tool-authority.ts",
"server/src/services/native-runtime/native-session-resume.ts",
"server/src/__tests__/native-procedure-measurement.test.ts",
];
writeFileSync(output, `${JSON.stringify({ schema: "paperclip.native-procedure-measurement.v2",
sourceSha: execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(),
sourceDirty: execFileSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8" }).trim().length > 0,
measuredInputsDirty: execFileSync("git", ["status", "--porcelain", "--", ...paths], { cwd: root, encoding: "utf8" }).trim().length > 0,
sourceHashes: Object.fromEntries(paths.map(path => [path, sha(readFileSync(new URL(path, root)))])),
boundary: "scripted runnerd RPC with production standard-mode authority, API tools enabled, local workspace, no assigned external apps",
byteAccounting: "utf8Bytes counts the unmodified JSON serialization of each extracted component/projection; normalizedComparison substitutes only the fixture workspace path. Neither is the entire transport request.",
providerCalls: 0, tokenCount: null, upstreamLoadingOrTruncation: "unverified", vendorStockPrompt: "unavailable",
toolDescriptions: tools.map(tool => ({ name: tool.name, characters: String(tool.description).length, utf8Bytes: Buffer.byteLength(String(tool.description)), inputSchema: measure(tool.inputSchema) })),
receipts, mcpReceipt,
}, null, 2)}\n`);
});
@@ -0,0 +1,122 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { heartbeatRuns, type Db } from "@paperclipai/db";
import type { EnvironmentLease } from "@paperclipai/shared";
import type { AdapterSandboxExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
import { runLocalGit } from "@paperclipai/adapter-utils/git-workspace-sync";
import { prepareNativeWorkspaceSync } from "../services/native-runtime/native-workspace-sync.js";
const runner: CommandManagedRuntimeRunner = {
execute: (input) => new Promise((resolve) => {
const child = execFile(input.command, input.args ?? [], {
cwd: input.cwd, env: { ...process.env, ...input.env },
timeout: input.timeoutMs, maxBuffer: 16 * 1024 * 1024,
}, (error, stdout, stderr) => resolve({
exitCode: error ? (typeof error.code === "number" ? error.code : 1) : 0,
signal: error?.signal ?? null, timedOut: error?.killed ?? false, stdout, stderr,
}));
child.stdin?.end(input.stdin ?? "");
}),
};
async function git(cwd: string, ...args: string[]) {
return (await runLocalGit(cwd, args)).stdout.trim();
}
describe("native warm workspace Git history", () => {
const oldHome = process.env.PAPERCLIP_HOME;
const oldInstance = process.env.PAPERCLIP_INSTANCE_ID;
const roots: string[] = [];
afterEach(async () => {
if (oldHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = oldHome;
if (oldInstance === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = oldInstance;
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })));
});
it.each(["unchanged", "host_commit", "host_branch", "remote_commit", "nested_commit"] as const)(
"checks Git identity before reusing a warm sandbox: %s", async (change) => {
const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-warm-history-"));
roots.push(root);
process.env.PAPERCLIP_HOME = path.join(root, "home");
process.env.PAPERCLIP_INSTANCE_ID = "test";
const host = path.join(root, "host");
const remote = path.join(root, "remote");
async function initRepo(dir: string) {
await mkdir(dir, { recursive: true });
await git(dir, "init", "-b", "work");
await git(dir, "config", "user.name", "Test");
await git(dir, "config", "user.email", "test@paperclip.dev");
await writeFile(path.join(dir, "file.txt"), "unchanged contents\n");
await git(dir, "add", ".");
await git(dir, "commit", "-m", "base");
}
await initRepo(host);
const nestedPath = path.join(".paperclip-repositories", "nested");
if (change === "nested_commit") await initRepo(path.join(host, nestedPath));
const lease = { id: "lease", providerLeaseId: "sandbox", metadata: {} } as EnvironmentLease;
let runnerProfileJson: Record<string, unknown> = {};
// Persist the real service's descriptors and stamps; only the DB query
// plumbing is in memory. Git, staging, warm adoption, and restore are real.
const db = {
select: () => {
const query = {
from: () => query, where: () => query, for: () => query,
limit: async () => [{ runnerProfileJson, metadata: lease.metadata }],
};
return query;
},
update: (table: unknown) => ({
set: (value: { runnerProfileJson?: Record<string, unknown>; metadata?: Record<string, unknown> }) => ({
where: async () => {
if (table === heartbeatRuns) runnerProfileJson = value.runnerProfileJson!;
else lease.metadata = value.metadata!;
},
}),
}),
transaction: async (fn: (tx: unknown) => Promise<unknown>) => fn(db),
};
const target: AdapterSandboxExecutionTarget = {
kind: "remote", transport: "sandbox", providerKey: "test", leaseId: lease.id,
remoteCwd: remote, runner, timeoutMs: 30_000,
sandboxLeaseAcquisition: { outcome: "created", providerLeaseId: "sandbox" },
};
const input = { db: db as unknown as Db, companyId: "company", workspaceId: "workspace", workspaceLocalDir: host, lease, target };
const first = (await prepareNativeWorkspaceSync({ ...input, runId: "first" }))!;
expect(first.mode).toBe("host_current");
await first.restoreWorkspace();
await first.cleanup();
if (change === "host_commit" || change === "nested_commit") {
await git(change === "nested_commit" ? path.join(host, nestedPath) : host, "commit", "--allow-empty", "-m", "host only");
} else if (change === "host_branch") {
await git(host, "checkout", "-b", "other");
} else if (change === "remote_commit") {
await git(remote, "-c", "user.name=Test", "-c", "user.email=test@paperclip.dev", "commit", "--allow-empty", "-m", "remote only");
}
const expectedHead = await git(host, "rev-parse", "HEAD");
const expectedBranch = await git(host, "symbolic-ref", "--short", "HEAD");
const nestedHead = change === "nested_commit" ? await git(path.join(host, nestedPath), "rev-parse", "HEAD") : null;
runnerProfileJson = {};
const second = (await prepareNativeWorkspaceSync({ ...input, runId: "second", target: {
...target, sandboxLeaseAcquisition: { outcome: "resumed", providerLeaseId: "sandbox" },
} }))!;
try {
expect(second.mode).toBe(change === "unchanged" ? "adopt_remote" : "host_current");
expect(await git(remote, "rev-parse", "HEAD")).toBe(expectedHead);
expect(await git(remote, "symbolic-ref", "--short", "HEAD")).toBe(expectedBranch);
await second.restoreWorkspace();
expect(await git(host, "rev-parse", "HEAD")).toBe(expectedHead);
if (nestedHead) {
expect(await git(path.join(remote, nestedPath), "rev-parse", "HEAD")).toBe(nestedHead);
expect(await git(path.join(host, nestedPath), "rev-parse", "HEAD")).toBe(nestedHead);
}
} finally { await second.cleanup(); }
}, 30_000,
);
});
@@ -19,6 +19,7 @@ const apiPrefixes: Record<string, string> = {
"activity.ts": "/api",
"adapters.ts": "/api",
"agents.ts": "/api",
"agent-commentary.ts": "/api",
"agent-avatars.ts": "/api",
"announcements.ts": "/api",
"ai-connections.ts": "/api",
@@ -97,6 +98,7 @@ const explicitOpenApiOperationCoverageExclusions = new Set([
// browser connection-management operations remain documented in the board API.
"GET /.well-known/oauth-authorization-server",
"POST /mcp/oauth/register",
"POST /mcp/oauth/device_authorization",
"GET /mcp/oauth/authorize",
"POST /mcp/oauth/token",
"POST /mcp/oauth/revoke",
@@ -236,6 +238,43 @@ function loadSpecRoutes() {
}
describe("openapi routes", () => {
it("documents strict run-attributed feedback without a read endpoint", () => {
const { spec } = loadSpecRoutes();
const path = spec.paths["/api/companies/{companyId}/agent-commentary"];
expect(Object.keys(path)).toEqual(["post"]);
const operation = path.post;
expect(operation.security).toEqual([{ AgentBearerAuth: [] }]);
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "agent", heartbeatBound: true });
expect(operation.description).toContain("X-Paperclip-Run-Id");
const body = operation.requestBody.content["application/json"].schema;
expect(body.additionalProperties).toBe(false);
expect(Object.keys(body.properties).sort()).toEqual(["body", "idempotencyKey", "kind"]);
expect(body.properties.body.maxLength).toBe(524288);
for (const code of ["200", "201"]) {
const result = operation.responses[code].content["application/json"].schema;
expect(result.additionalProperties).toBe(false);
expect(Object.keys(result.properties).sort()).toEqual(["createdAt", "id", "kind", "replayed"]);
}
expect(operation.responses["409"]).toBeDefined();
expect(operation.responses["503"]).toBeDefined();
});
it("documents board-only pool management with revision-checked deletion", () => {
const { spec } = loadSpecRoutes();
const pools = spec.paths["/api/companies/{companyId}/ai-connection-pools"];
const remove = spec.paths["/api/companies/{companyId}/ai-connection-pools/{poolId}"].delete;
const inspection = spec.paths["/api/companies/{companyId}/ai-connection-pools/{poolId}/inspection"].get;
for (const operation of [pools.get, pools.post, remove, inspection]) {
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
}
expect(remove.requestBody.required).toBe(true);
expect(remove.requestBody.content["application/json"].schema).toMatchObject({
required: ["expectedRevision"], additionalProperties: false,
properties: { expectedRevision: { type: "integer", minimum: 0, exclusiveMinimum: true } },
});
expect(Object.keys(remove.responses)).toEqual(expect.arrayContaining(["200", "400", "401", "403", "404", "409"]));
});
it("documents personal board-only announcements and private responses", () => {
const { spec } = loadSpecRoutes();
const current = spec.paths["/api/announcements/current"].get;
@@ -126,7 +126,7 @@ function createApp(actor: Record<string, unknown>, db: Record<string, unknown>)
return app;
}
describe.sequential("POST /companies/:companyId/openclaw/invite-prompt", () => {
describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
const companyBranding = {
name: "Acme AI",
logoAssetId: "logo-1",
@@ -73,7 +73,7 @@ function idleStream<T>(): TypedEventStream<T> {
async () => close(),
);
}
describe.sequential("iMessage Photon channel control plane", () => {
describe("iMessage Photon channel control plane", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let secrets: string;
@@ -46,7 +46,7 @@ import {
import { instanceSettingsService } from "../services/instance-settings.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
Loaded 100 of 1212 files, more files were not shown because too many files have changed in this diff. Show more