mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Carry current master through Pi public installation
This commit is contained in:
commit
e00258afd9
2195 files changed
+255923
-79198
No files matched your search
+19
-7
@@ -35,7 +35,7 @@
|
||||
"dev": "tsx src/index.ts",
|
||||
"dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts",
|
||||
"prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh",
|
||||
"build": "pnpm run prepare:runner-vendor && node scripts/verify-runner-vendor-dependencies.mjs && tsc && mkdir -p dist/onboarding-assets dist/built-ins dist/services/scripts dist/vendor/paperclip-runner && cp -R src/onboarding-assets/. dist/onboarding-assets/ && cp -R src/built-ins/. dist/built-ins/ && cp -R src/services/scripts/. dist/services/scripts/ && cp -R ../packages/paperclip-runner/dist/. dist/vendor/paperclip-runner/ && node scripts/write-build-stamp.mjs",
|
||||
"build": "pnpm run prepare:runner-vendor && node scripts/verify-runner-vendor-dependencies.mjs && tsc && mkdir -p dist/onboarding-assets dist/built-ins dist/services/scripts dist/vendor/paperclip-runner && cp -R src/onboarding-assets/. dist/onboarding-assets/ && cp -R src/built-ins/. dist/built-ins/ && cp -R src/services/scripts/. dist/services/scripts/ && cp -Rf ../packages/paperclip-runner/dist/. dist/vendor/paperclip-runner/ && node scripts/write-build-stamp.mjs",
|
||||
"prepack": "pnpm run prepare:ui-dist && pnpm run build",
|
||||
"postpack": "rm -rf ui-dist",
|
||||
"clean": "rm -rf dist",
|
||||
@@ -44,6 +44,9 @@
|
||||
"typecheck": "pnpm run prepare:runner-vendor && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"ai": "7.0.130",
|
||||
"@ai-sdk/openai": "4.0.86",
|
||||
"@openrouter/ai-sdk-provider": "3.1.0",
|
||||
"@aws-sdk/client-s3": "^3.1141.0",
|
||||
"@chat-adapter/discord": "4.39.0",
|
||||
"@chat-adapter/github": "4.39.0",
|
||||
@@ -51,7 +54,8 @@
|
||||
"@chat-adapter/teams": "4.39.0",
|
||||
"@chat-adapter/telegram": "4.39.0",
|
||||
"@discordjs/ws": "1.2.3",
|
||||
"@grpc/grpc-js": "1.14.4",
|
||||
"@grpc/grpc-js": "1.14.5",
|
||||
"@modelcontextprotocol/sdk": "^1.31.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||
@@ -70,7 +74,7 @@
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"@paperclipai/skills-catalog": "workspace:*",
|
||||
"@photon-ai/advanced-imessage": "2.1.0",
|
||||
"@vercel/connect": "0.6.1",
|
||||
"@vercel/connect": "2.3.3",
|
||||
"acorn": "8.18.0",
|
||||
"acpx": "0.13.1",
|
||||
"ajv": "^8.20.0",
|
||||
@@ -78,18 +82,20 @@
|
||||
"better-auth": "1.7.2",
|
||||
"chat": "4.39.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"compression": "^1.8.2",
|
||||
"detect-port": "^2.1.0",
|
||||
"dompurify": "^3.4.14",
|
||||
"dompurify": "^3.4.16",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"embedded-postgres": "^18.1.0-beta.16",
|
||||
"express": "^5.1.0",
|
||||
"heif2jpeg": "0.1.6",
|
||||
"jsdom": "^30.0.1",
|
||||
"light-my-request": "^6.6.0",
|
||||
"multer": "^2.4.0",
|
||||
"nice-grpc": "2.1.17",
|
||||
"nice-grpc-common": "2.0.4",
|
||||
"open": "^11.0.1",
|
||||
"open": "^11.0.4",
|
||||
"pino": "^10.0.0",
|
||||
"pino-http": "^11.0.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
@@ -111,6 +117,7 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"@paperclipai/paperclip-runner": "workspace:*",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/express-serve-static-core": "^5.1.3",
|
||||
"@types/jsdom": "^30.0.0",
|
||||
@@ -121,10 +128,10 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"supertest": "^7.0.0",
|
||||
"tsx": "^4.23.12",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.2.2",
|
||||
"vitest": "^4.1.11"
|
||||
"vitest": "^5.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/auto-instrumentations-node": "0.79.0",
|
||||
@@ -164,5 +171,10 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.11.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@github/copilot-darwin-arm64": "1.0.88",
|
||||
"@github/copilot-darwin-x64": "1.0.88",
|
||||
"@github/copilot-linux-x64": "1.0.88"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/** Real, disposable Daytona target for the opt-in feedback smoke. */
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { parse } from "dotenv";
|
||||
import { environmentLeases, environments, type Db } from "@paperclipai/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { AdapterSandboxExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
import { readEnvironmentCreationCleanupError, type PluginEnvironmentCreationCleanup,
|
||||
type PluginEnvironmentLease, type PluginEnvironmentTerminationReceipt } from "@paperclipai/plugin-sdk";
|
||||
|
||||
export async function acquireDaytonaLeaseWithCleanup(input: {
|
||||
companyId: string; environmentId: string; runId: string;
|
||||
acquire: () => Promise<PluginEnvironmentLease>;
|
||||
destroy: (cleanup: PluginEnvironmentCreationCleanup) => Promise<PluginEnvironmentTerminationReceipt | void>;
|
||||
}) {
|
||||
try {
|
||||
return await input.acquire();
|
||||
} catch (error) {
|
||||
const cleanup = readEnvironmentCreationCleanupError(error);
|
||||
if (cleanup && cleanup.companyId === input.companyId && cleanup.environmentId === input.environmentId
|
||||
&& (!cleanup.runId || cleanup.runId === input.runId)) {
|
||||
const receipt = await input.destroy(cleanup);
|
||||
assert.equal(receipt?.state, "destroyed", "Failed Daytona acquisition must be cleaned up");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function acquireCommentaryDaytonaTarget(db: Db, fixture: {
|
||||
companyId: string; agentId: string; issueId: string; runId: string;
|
||||
}, mode: "legacy" | "native") {
|
||||
const envFile = process.env.PAPERCLIP_LIVE_DAYTONA_ENV_FILE;
|
||||
const env = envFile ? parse(await readFile(envFile)) : process.env;
|
||||
const apiKey = env.DAYTONA_API_KEY;
|
||||
const image = env.PAPERCLIP_E2E_DAYTONA_IMAGE;
|
||||
assert.ok(apiKey, "DAYTONA_API_KEY required");
|
||||
assert.match(image ?? "", /@sha256:[a-f0-9]{64}$/, "Immutable Daytona image required");
|
||||
// Import only for explicit remote execution; the optional provider is not a server dependency.
|
||||
const { default: plugin } = await import("../../packages/plugins/sandbox-providers/daytona/src/plugin.js");
|
||||
const hooks = plugin.definition;
|
||||
const environmentId = randomUUID();
|
||||
const leaseId = randomUUID();
|
||||
const config = { apiKey, image, cpu: 4, memory: 4, disk: 10, reuseLease: false,
|
||||
autoStopInterval: 5, autoArchiveInterval: 15, autoDeleteInterval: 60,
|
||||
timeoutMs: 300_000, livenessTimeoutMs: 30_000 };
|
||||
const base = { driverKey: "daytona", companyId: fixture.companyId, environmentId, issueId: fixture.issueId, config };
|
||||
await db.insert(environments).values({ id: environmentId, name: `feedback-smoke-${environmentId}`, driver: "sandbox", config: { provider: "daytona", image } });
|
||||
console.error(`Acquiring disposable ${mode} Daytona sandbox.`);
|
||||
const lease = await acquireDaytonaLeaseWithCleanup({ ...fixture, environmentId,
|
||||
acquire: () => hooks.onEnvironmentAcquireLease!({ ...base, runId: fixture.runId, agentId: fixture.agentId,
|
||||
adapterType: mode === "legacy" ? "codex_local" : "paperclip_runner",
|
||||
requestedExpiresAt: new Date(Date.now() + 20 * 60_000).toISOString() }),
|
||||
destroy: cleanup => hooks.onEnvironmentDestroyLease!({ ...base, providerLeaseId: cleanup.providerLeaseId,
|
||||
leaseMetadata: { failedCreateCleanup: cleanup } }),
|
||||
});
|
||||
assert.ok(lease.providerLeaseId);
|
||||
console.error(`Acquired disposable ${mode} Daytona sandbox.`);
|
||||
const cleanup = async () => {
|
||||
const receipt = await hooks.onEnvironmentDestroyLease!({ ...base, providerLeaseId: lease.providerLeaseId, leaseMetadata: lease.metadata });
|
||||
assert.equal(receipt?.state, "destroyed", "Daytona deletion must be acknowledged");
|
||||
await db.update(environmentLeases).set({ status: "released", releasedAt: new Date(), cleanupStatus: "completed" }).where(eq(environmentLeases.id, leaseId));
|
||||
};
|
||||
try {
|
||||
await db.insert(environmentLeases).values({ id: leaseId, companyId: fixture.companyId, environmentId,
|
||||
issueId: fixture.issueId, heartbeatRunId: fixture.runId, provider: "daytona", providerLeaseId: lease.providerLeaseId, metadata: lease.metadata });
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote", transport: "sandbox", providerKey: "daytona", environmentId, leaseId,
|
||||
remoteCwd: String(lease.metadata?.remoteCwd), shellCommand: lease.metadata?.shellCommand === "sh" ? "sh" : "bash",
|
||||
timeoutMs: 300_000, streamRunLogs: true, reusableLeaseConfigured: false,
|
||||
effectiveCapabilities: { reusableLeases: false, nativeSyncIn: true, nativeSyncOut: true,
|
||||
persistentProcessSessions: true, independentControlCommands: true, incrementalSessionOutput: true,
|
||||
concurrentSyncOperations: false, duplexCommandStream: false, runnerWebSocketIngress: true },
|
||||
runnerLifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
|
||||
sandboxLeaseAcquisition: { outcome: "created", providerLeaseId: lease.providerLeaseId },
|
||||
runner: {
|
||||
async execute(input) {
|
||||
const start = Date.now();
|
||||
const result = await hooks.onEnvironmentExecute!({ ...base, lease, ...input,
|
||||
bypassSession: input.bypassSession ?? !input.useSession });
|
||||
if (result.stdout) await input.onLog?.("stdout", result.stdout);
|
||||
if (result.stderr) await input.onLog?.("stderr", result.stderr);
|
||||
return { ...result, signal: result.signal ?? null, pid: null,
|
||||
startedAt: new Date(start).toISOString(), finishedAt: new Date().toISOString(), durationMs: Date.now() - start };
|
||||
},
|
||||
syncIn: operations => hooks.onEnvironmentSyncIn!({ ...base, lease, operations }),
|
||||
syncOut: operations => hooks.onEnvironmentSyncOut!({ ...base, lease, operations }),
|
||||
},
|
||||
async getRunnerIngressEndpoint(input) {
|
||||
const refresh = async () => ({ ...await hooks.onEnvironmentRunnerIngressEndpoint!({ ...base, lease, port: input.port, path: input.path }), refresh, close: async () => {} });
|
||||
return refresh();
|
||||
},
|
||||
};
|
||||
return { target, cleanup, image, providerLeaseId: lease.providerLeaseId };
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Reproducible discovery load probe. Uses a throwaway embedded PostgreSQL DB;
|
||||
* no provider calls, production data, or model inference.
|
||||
* node --expose-gc --import tsx scripts/benchmark-tool-gateway-listing.ts
|
||||
* --tools 50,300,900 --parallel 1,16 --repeat 3
|
||||
* --implementation accepts a local tool-gateway module for before/after replay.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
createDb, heartbeatRuns, toolCatalogEntries, toolConnections, toolPolicies,
|
||||
toolProfileEntries, startEmbeddedPostgresTestDatabase,
|
||||
} from "@paperclipai/db";
|
||||
import { createListingFixture, recordingDb } from "../src/__tests__/helpers/tool-gateway-listing-fixture.js";
|
||||
import type { createToolGatewayService } from "../src/services/tool-gateway.js";
|
||||
|
||||
function option(name: string, fallback: string) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index < 0 ? fallback : process.argv[index + 1] ?? fallback;
|
||||
}
|
||||
function counts(value: string) {
|
||||
const result = value.split(",").map(Number);
|
||||
if (result.some((n) => !Number.isInteger(n) || n < 1)) throw new Error("Expected positive integer counts");
|
||||
return result;
|
||||
}
|
||||
const toolCounts = counts(option("--tools", "50,300,900"));
|
||||
const parallels = counts(option("--parallel", "1,16"));
|
||||
const repeats = counts(option("--repeat", "3"))[0]!;
|
||||
const connections = counts(option("--connections", "1"))[0]!;
|
||||
const implementation = option("--implementation", "");
|
||||
const moduleUrl = implementation ? pathToFileURL(implementation) : new URL("../src/services/tool-gateway.js", import.meta.url);
|
||||
const factory = (await import(moduleUrl.href)).createToolGatewayService as typeof createToolGatewayService;
|
||||
const temp = await startEmbeddedPostgresTestDatabase("paperclip-listing-benchmark-");
|
||||
const db = createDb(temp.connectionString);
|
||||
try {
|
||||
for (const tools of toolCounts) {
|
||||
const fixture = await createListingFixture(db, tools, { connectionCount: connections });
|
||||
await db.update(heartbeatRuns).set({
|
||||
contextSnapshot: { issueId: fixture.issue.id, projectId: fixture.project.id, taskMarkdown: "x".repeat(400_000) },
|
||||
resultJson: { summary: "x".repeat(140_000) },
|
||||
}).where(eq(heartbeatRuns.id, fixture.run.id));
|
||||
await db.update(toolConnections).set({ config: { url: "https://8.8.8.8/mcp", notes: "x".repeat(15_000) } })
|
||||
.where(eq(toolConnections.companyId, fixture.company.id));
|
||||
await db.update(toolCatalogEntries).set({ inputSchema: {
|
||||
type: "object", properties: { query: { type: "string", description: "x".repeat(5_000) } },
|
||||
} }).where(eq(toolCatalogEntries.companyId, fixture.company.id));
|
||||
await db.insert(toolProfileEntries).values(fixture.entries.map((entry) => ({
|
||||
companyId: fixture.company.id, profileId: fixture.namedGateway.profileId,
|
||||
selectorType: "catalog_entry" as const, effect: "include" as const, catalogEntryId: entry.id,
|
||||
})));
|
||||
await db.insert(toolPolicies).values(Array.from({ length: 371 }, (_, i) => ({
|
||||
companyId: fixture.company.id, name: `Unmatched fixture policy ${i}`, policyType: "block" as const,
|
||||
selectors: { catalogEntryId: randomUUID() }, priority: 100 + i,
|
||||
})));
|
||||
const input = { gatewayId: fixture.namedGateway.id, bearerToken: fixture.token.token };
|
||||
await factory(db).listToolsForNamedGateway(input);
|
||||
for (const parallel of parallels) {
|
||||
for (let iteration = 1; iteration <= repeats; iteration += 1) {
|
||||
global.gc?.();
|
||||
const before = process.memoryUsage();
|
||||
let peakHeap = before.heapUsed;
|
||||
let peakRss = before.rss;
|
||||
const sample = () => {
|
||||
const memory = process.memoryUsage();
|
||||
peakHeap = Math.max(peakHeap, memory.heapUsed);
|
||||
peakRss = Math.max(peakRss, memory.rss);
|
||||
};
|
||||
const timer = setInterval(sample, 2);
|
||||
const recorder = recordingDb(db);
|
||||
const started = performance.now();
|
||||
let visible = 0;
|
||||
try {
|
||||
const listings = await Promise.all(Array.from({ length: parallel }, () =>
|
||||
factory(recorder.db).listToolsForNamedGateway(input)));
|
||||
visible = listings[0]!.length;
|
||||
sample();
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
}
|
||||
const elapsedMs = performance.now() - started;
|
||||
global.gc?.();
|
||||
const after = process.memoryUsage();
|
||||
console.log(JSON.stringify({
|
||||
tools, connections, parallel, iteration, visible, queries: recorder.statements.length,
|
||||
elapsedMs: Math.round(elapsedMs), peakHeapIncreaseMiB: Math.round((peakHeap - before.heapUsed) / 1_048_576),
|
||||
peakRssMiB: Math.round(peakRss / 1_048_576), retainedHeapIncreaseMiB: Math.round((after.heapUsed - before.heapUsed) / 1_048_576),
|
||||
forcedGc: Boolean(global.gc), fixture: { snapshotBytes: 400_000, schemaBytes: 5_000, policyCount: 373 },
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await temp.cleanup();
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/** Synthetic local lab, not production admission or a real OpenAI Dot.
|
||||
* Uses real OAuth, MCP HTTP, database-backed events, HMAC verification and the
|
||||
* native runner backend. Never reads a user's existing Paperclip database. */
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import express from "express";
|
||||
import { activityLog, authUsers, companies, companyMemberships, createDb, issues, startEmbeddedPostgresTestDatabase } from "@paperclipai/db";
|
||||
import { DotHarnessDriver } from "../../packages/paperclip-runner/src/drivers/dot/dot-harness-driver.js";
|
||||
import { HarnessDriverBackend } from "../../packages/paperclip-runner/src/backends/harness-driver-backend.js";
|
||||
import { publicMcpIngressRoutes } from "../src/routes/public-mcp.js";
|
||||
import { createPublicMcpOAuth } from "../src/services/public-mcp/oauth.js";
|
||||
import { createPublicMcpEvents } from "../src/services/public-mcp/events.js";
|
||||
import { createPublicMcpExecutor, McpApiError, type ApiDispatch } from "../src/services/public-mcp/capabilities.js";
|
||||
import { createDotRunnerMcpBridge } from "../src/services/public-mcp/dot-runner.js";
|
||||
import { instanceSettingsService } from "../src/services/instance-settings.js";
|
||||
|
||||
async function listen(server: Server) {
|
||||
server.listen(0, "127.0.0.1"); await once(server, "listening");
|
||||
const address = server.address(); assert(address && typeof address !== "string");
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
async function close(server: Server) {
|
||||
server.closeAllConnections(); if (server.listening) await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
}
|
||||
|
||||
const scratch = await mkdtemp(join(tmpdir(), "paperclip-dot-demo-"));
|
||||
// This process has its own ephemeral encryption key, never the instance key.
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = join(scratch, "master.key");
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
delete process.env.PAPERCLIP_CLOUD_API_ORIGIN;
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
||||
const app = express(); app.use(express.json({ limit: "256kb" }));
|
||||
const http = createServer(app);
|
||||
const receiver = express(); receiver.use(express.raw({ type: "application/json", limit: "256kb" }));
|
||||
const callback = createServer(receiver);
|
||||
const secret = randomBytes(32);
|
||||
const webhookSecret = "whsec_" + secret.toString("base64");
|
||||
const receivedIds = new Set<string>();
|
||||
let service: ReturnType<typeof createPublicMcpEvents> | undefined;
|
||||
let unregister: (() => void) | undefined;
|
||||
let active = true;
|
||||
try {
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-dot-demo-");
|
||||
const db = createDb(database.connectionString);
|
||||
const origin = await listen(http);
|
||||
const callbackOrigin = await listen(callback);
|
||||
const oauth = createPublicMcpOAuth(db, { origin, resource: origin + "/mcp/paperclip" });
|
||||
await instanceSettingsService(db).updateExperimental({ enablePublicMcp: true });
|
||||
const userId = randomUUID();
|
||||
await db.insert(authUsers).values({ id: userId, name: "Dot lab operator", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
const [company] = await db.insert(companies).values({ name: "Dot prototype lab", issuePrefix: "DOT" }).returning();
|
||||
assert(company);
|
||||
await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" });
|
||||
const [task] = await db.insert(issues).values({ companyId: company.id, title: "Dot runner lab inbox", status: "todo" }).returning();
|
||||
assert(task);
|
||||
const actor = { type: "board" as const, source: "session" as const, userId };
|
||||
const client = await oauth.register({ client_name: "Synthetic Dot test peer", redirect_uris: [origin + "/callback"] });
|
||||
const verifier = randomBytes(32).toString("base64url");
|
||||
const authorization = await oauth.authorize({ client_id: client.client_id, redirect_uri: origin + "/callback", resource: oauth.config.resource,
|
||||
scope: "paperclip:read paperclip:write", response_type: "code", code_challenge_method: "S256", code_challenge: createHash("sha256").update(verifier).digest("base64url") });
|
||||
const consent = await oauth.consent(authorization.split("/").at(-1)!, actor, { decision: "approve", companyId: company.id, allowWrites: true });
|
||||
const tokens = await oauth.token({ grant_type: "authorization_code", client_id: client.client_id, redirect_uri: origin + "/callback",
|
||||
resource: oauth.config.resource, code: new URL(consent.redirectUrl).searchParams.get("code"), code_verifier: verifier });
|
||||
const principal = await oauth.authenticate(tokens.access_token);
|
||||
const api: ApiDispatch = async (p, method, path) => {
|
||||
if (p.grant.companyId !== company.id || method !== "GET" || path !== `/issues/${task.id}`) throw new McpApiError(403);
|
||||
return task;
|
||||
};
|
||||
const bridge = createDotRunnerMcpBridge();
|
||||
service = createPublicMcpEvents(db, oauth, api, { enableDotPrototype: true,
|
||||
// Only the lab receiver bypasses HTTPS/DNS. Production uses eventFetch's
|
||||
// public-address pinning. No CLI option can expand this exact destination.
|
||||
fetch: async (url, init) => {
|
||||
assert.equal(url, "https://dot-demo.invalid/events");
|
||||
return fetch(callbackOrigin + "/events", init);
|
||||
},
|
||||
});
|
||||
app.use(publicMcpIngressRoutes(oauth, createPublicMcpExecutor(db, oauth, api), service, bridge));
|
||||
const rpc = async (method: string, params: Record<string, unknown> = {}) => {
|
||||
const response = await fetch(origin + "/mcp/paperclip", { method: "POST", signal: AbortSignal.timeout(10_000),
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}`, "Content-Type": "application/json", "MCP-Protocol-Version": "2026-07-28", "Mcp-Method": method, "Mcp-Name": String(params.name ?? "") },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: randomUUID(), method, params: { ...params, _meta: {
|
||||
"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {},
|
||||
} } }),
|
||||
});
|
||||
const body = await response.json() as { result?: any; error?: unknown };
|
||||
assert(response.ok && !body.error && !body.result?.isError, "MCP operation failed");
|
||||
return body.result;
|
||||
};
|
||||
const tool = async (name: string, args: Record<string, unknown>) => (await rpc("tools/call", { name, arguments: args })).structuredContent;
|
||||
const identity = { companyId: company.id, agentId: randomUUID(), issueId: task.id, runId: randomUUID(), sessionId: randomUUID() };
|
||||
const documents: string[] = [];
|
||||
const driver = new DotHarnessDriver({ identity, principal: { companyId: company.id, grantId: principal.grant.id }, expiresAt: Date.now() + 60_000,
|
||||
assertAuthority: async () => { assert(active, "Lab run has been revoked"); await oauth.authorizeGrant(principal.grant.id); },
|
||||
tools: [{ name: "save_report", description: "Save the synthetic lab report", inputSchema: { type: "object", properties: { body: { type: "string" } }, required: ["body"], additionalProperties: false } }],
|
||||
executeTool: async ({ name, arguments: args }) => { assert.equal(name, "save_report"); assert.equal(typeof args.body, "string"); documents.push(args.body as string); return { saved: true }; },
|
||||
publish: async assignment => {
|
||||
await db.insert(activityLog).values({ companyId: company.id, actorType: "agent", actorId: identity.agentId,
|
||||
action: "dot.work_available", entityType: "issue", entityId: task.id, details: { ...assignment } });
|
||||
},
|
||||
});
|
||||
unregister = bridge.register(driver, principal.grant.id);
|
||||
const native = await new HarnessDriverBackend(driver).openSession({ identity });
|
||||
let resolveWork!: () => void; let rejectWork!: (error: unknown) => void;
|
||||
const completed = new Promise<void>((resolve, reject) => { resolveWork = resolve; rejectWork = reject; });
|
||||
// Avoid an unhandled rejection if a callback fails before the main await.
|
||||
void completed.catch(() => {});
|
||||
const peer = async () => {
|
||||
const { assignments } = await tool("paperclip_dot_inbox", { companyId: company.id });
|
||||
assert.equal(assignments.length, 1);
|
||||
const base = { companyId: company.id, runId: assignments[0].runId, turnId: assignments[0].turnId };
|
||||
const call = (name: string, args = {}, requestId = randomUUID()) => tool(name, { ...base, requestId, ...args });
|
||||
const context = await call("paperclip_dot_read"); assert.match(context.result.message.text, /competitor/);
|
||||
await call("paperclip_dot_accept");
|
||||
const writeId = randomUUID();
|
||||
await Promise.all([0, 1].map(() => call("paperclip_dot_tool", { name: "save_report", arguments: { body: "Synthetic competitor report" } }, writeId)));
|
||||
await call("paperclip_dot_progress", { text: "The lab report is saved." });
|
||||
await call("paperclip_dot_finish", { result: { schema: "paperclip.run_result.v1", reportedWorkDisposition: "done", summary: "Lab report saved.",
|
||||
completionClaim: { contractRevision: "dot-lab-v1", objectiveSatisfied: true, criteria: [], remainingWork: [] }, evidence: [], verification: [], attentionRequests: [], artifacts: [] } });
|
||||
resolveWork();
|
||||
};
|
||||
receiver.post("/events", (req, res) => {
|
||||
const id = String(req.headers["webhook-id"]);
|
||||
const timestamp = String(req.headers["webhook-timestamp"]);
|
||||
const expected = "v1," + createHmac("sha256", secret).update(`${id}.${timestamp}.${req.body.toString("utf8")}`).digest("base64");
|
||||
const actual = Buffer.from(String(req.headers["webhook-signature"]));
|
||||
if (actual.length !== expected.length || !timingSafeEqual(actual, Buffer.from(expected)) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) { res.sendStatus(401); return; }
|
||||
const body = JSON.parse(req.body.toString("utf8"));
|
||||
if (body.type === "verification") { res.json({ challenge: body.challenge }); return; }
|
||||
res.sendStatus(204);
|
||||
if (receivedIds.has(id)) return;
|
||||
receivedIds.add(id);
|
||||
assert.equal(body.name, "paperclip.dot.work_available");
|
||||
assert(!JSON.stringify(body).includes("competitor"));
|
||||
void peer().catch(rejectWork);
|
||||
});
|
||||
const subscription = { name: "paperclip.dot.work_available", arguments: { companyId: company.id, taskId: task.id },
|
||||
delivery: { mode: "webhook", url: "https://dot-demo.invalid/events", secret: webhookSecret } };
|
||||
await rpc("events/subscribe", subscription);
|
||||
console.log("✓ Real OAuth connection and signed webhook verification");
|
||||
await native.startTurn({ message: { role: "user", text: "Write a synthetic competitor report." } });
|
||||
await service.tick();
|
||||
await Promise.race([completed, new Promise<never>((_, reject) => { const timer = setTimeout(() => reject(new Error("Dot test peer timed out")), 15_000); timer.unref(); completed.finally(() => clearTimeout(timer)).catch(() => {}); })]);
|
||||
const transcript = [];
|
||||
for await (const event of native.events()) transcript.push(event);
|
||||
assert.equal((await native.result())?.result.reportedWorkDisposition, "done");
|
||||
assert.equal(documents.length, 1); assert.equal(receivedIds.size, 1);
|
||||
assert.equal(await native.usage?.(), null);
|
||||
console.log("✓ MCP Event → Dot test peer → native runner acceptance, tool write, progress, structured completion");
|
||||
console.log("✓ Concurrent duplicate write produced one report; usage remains unknown");
|
||||
assert.equal((await tool("paperclip_connection", {})).companyId, company.id);
|
||||
console.log("✓ Personal Paperclip tools remain available when no agent assignment is active");
|
||||
await rpc("events/unsubscribe", { ...subscription, delivery: { mode: "webhook", url: subscription.delivery.url } });
|
||||
await oauth.revokeConnection(principal.grant.id, userId);
|
||||
await assert.rejects(() => rpc("tools/list"));
|
||||
await native.close({ reason: "demo finished" });
|
||||
console.log(`✓ Revoked credentials rejected; ${transcript.length} runner events recorded`);
|
||||
console.log("PASS — synthetic local protocol proof. No real Dot was contacted; production agent admission is not enabled.");
|
||||
} finally {
|
||||
active = false; unregister?.(); await service?.stop();
|
||||
await close(http); await close(callback); await database?.cleanup(); await rm(scratch, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Local operator companion. Never print or send the control credential remotely.
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const [file, action, requestId] = process.argv.slice(2);
|
||||
assert(file && ["status", "approve", "queue", "stop"].includes(action),
|
||||
"Usage: node server/scripts/dot-runner-live-control.mjs <control.json> status|approve <requestId>|queue|stop");
|
||||
const config = JSON.parse(await readFile(file, "utf8"));
|
||||
const origin = new URL(config.controlOrigin);
|
||||
assert(origin.protocol === "http:" && origin.hostname === "127.0.0.1" && origin.pathname === "/" && !origin.username && !origin.password);
|
||||
assert(typeof config.token === "string" && /^[A-Za-z0-9_-]{43}$/.test(config.token));
|
||||
if (action === "approve") assert(requestId, "Inspect status and supply the exact pending request ID to approve");
|
||||
const response = await fetch(new URL(`/${action}`, origin), {
|
||||
method: action === "status" ? "GET" : "POST",
|
||||
headers: { Authorization: `Bearer ${config.token}`, "Content-Type": "application/json" },
|
||||
...(action === "approve" ? { body: JSON.stringify({ requestId }) } : {}),
|
||||
redirect: "error", signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
console.log(JSON.stringify(await response.json(), null, 2));
|
||||
if (!response.ok) process.exitCode = 1;
|
||||
@@ -0,0 +1,154 @@
|
||||
/** Disposable real-Dot transport lab. Not production scheduling or admission.
|
||||
* Only the MCP listener may be tunneled. Control stays on a separate loopback
|
||||
* port with a random bearer credential stored in a mode-0600 local file.
|
||||
* No existing Paperclip database, user, API key, or company is loaded. */
|
||||
import assert from "node:assert/strict";
|
||||
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import express from "express";
|
||||
import { z } from "zod";
|
||||
import { activityLog, authUsers, companies, companyMemberships, createDb, issues, mcpEventSubscriptions, mcpEventDeliveries, startEmbeddedPostgresTestDatabase } from "@paperclipai/db";
|
||||
import { DotHarnessDriver } from "../../packages/paperclip-runner/src/drivers/dot/dot-harness-driver.js";
|
||||
import { HarnessDriverBackend } from "../../packages/paperclip-runner/src/backends/harness-driver-backend.js";
|
||||
import { publicMcpIngressRoutes } from "../src/routes/public-mcp.js";
|
||||
import { createPublicMcpOAuth } from "../src/services/public-mcp/oauth.js";
|
||||
import { createPublicMcpEvents } from "../src/services/public-mcp/events.js";
|
||||
import { createPublicMcpExecutor, McpApiError, type ApiDispatch } from "../src/services/public-mcp/capabilities.js";
|
||||
import { createDotRunnerMcpBridge } from "../src/services/public-mcp/dot-runner.js";
|
||||
import { instanceSettingsService } from "../src/services/instance-settings.js";
|
||||
|
||||
const origin = new URL(process.env.DOT_LAB_ORIGIN ?? "").origin;
|
||||
assert(origin.startsWith("https://"), "DOT_LAB_ORIGIN must be the public HTTPS tunnel origin");
|
||||
const port = z.coerce.number().int().min(1024).max(65534).parse(process.env.DOT_LAB_PORT ?? 43127);
|
||||
const requestIdSchema = z.string().regex(/^pcmcp_request_[A-Za-z0-9_-]{43}$/);
|
||||
const scratch = await mkdtemp(join(tmpdir(), "paperclip-dot-live-"));
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = join(scratch, "master.key");
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
delete process.env.PAPERCLIP_CLOUD_API_ORIGIN;
|
||||
const controlToken = randomBytes(32).toString("base64url");
|
||||
const app = express(); app.use(express.json({ limit: "256kb" }));
|
||||
const control = express(); control.use(express.json({ limit: "16kb" }));
|
||||
const http = createServer(app), admin = createServer(control);
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
||||
let service: ReturnType<typeof createPublicMcpEvents> | undefined;
|
||||
let unregister: (() => void) | undefined;
|
||||
let active = true;
|
||||
async function listen(server: Server, listenPort: number) { server.listen(listenPort, "127.0.0.1"); await once(server, "listening"); }
|
||||
async function close(server: Server) { server.closeAllConnections(); if (server.listening) await new Promise<void>(resolve => server.close(() => resolve())); }
|
||||
const escape = (value: string) => value.replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
|
||||
try {
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-dot-live-");
|
||||
const db = createDb(database.connectionString);
|
||||
const oauth = createPublicMcpOAuth(db, { origin, resource: origin + "/mcp/paperclip" });
|
||||
await instanceSettingsService(db).updateExperimental({ enablePublicMcp: true });
|
||||
const userId = randomUUID();
|
||||
await db.insert(authUsers).values({ id: userId, name: "Dot lab operator", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
const [company] = await db.insert(companies).values({ name: "Disposable Dot integration lab", issuePrefix: "DOT" }).returning(); assert(company);
|
||||
await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" });
|
||||
const [task] = await db.insert(issues).values({ companyId: company.id, title: "Dot live test inbox", status: "todo" }).returning(); assert(task);
|
||||
const actor = { type: "board" as const, source: "session" as const, userId };
|
||||
const pending = new Map<string, Awaited<ReturnType<typeof oauth.describeRequest>>>();
|
||||
const redirects = new Map<string, string>();
|
||||
const records: Array<Record<string, unknown>> = [];
|
||||
const reports: string[] = [];
|
||||
const transcript: unknown[] = [];
|
||||
const record = (kind: string, fields: Record<string, unknown> = {}) => {
|
||||
const entry = { at: new Date().toISOString(), kind, ...fields }; records.push(entry);
|
||||
if (records.length > 300) records.shift();
|
||||
console.log(JSON.stringify(entry));
|
||||
};
|
||||
let native: Awaited<ReturnType<HarnessDriverBackend["openSession"]>> | undefined;
|
||||
let grantId: string | undefined;
|
||||
let queued = false;
|
||||
let stopLab!: () => void;
|
||||
const stopped = new Promise<void>(resolve => { stopLab = resolve; });
|
||||
const api: ApiDispatch = async (principal, method, path) => {
|
||||
if (principal.grant.companyId !== company.id || method !== "GET" || path !== `/issues/${task.id}`) throw new McpApiError(403);
|
||||
return task;
|
||||
};
|
||||
const bridge = createDotRunnerMcpBridge();
|
||||
// Real remote callback transport: retain production HTTPS, DNS/IP pinning,
|
||||
// verification challenge, encryption, signatures and retry policy unchanged.
|
||||
service = createPublicMcpEvents(db, oauth, api, { enableDotPrototype: true });
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store"); res.setHeader("Referrer-Policy", "no-referrer");
|
||||
if (req.path === "/mcp/paperclip" && req.method === "POST") {
|
||||
const method = String(req.body?.method ?? "").slice(0, 100);
|
||||
const tool = method === "tools/call" ? String(req.body?.params?.name ?? "").slice(0, 100) : undefined;
|
||||
res.on("finish", () => record("mcp", { method, ...(tool ? { tool } : {}), status: res.statusCode }));
|
||||
}
|
||||
next();
|
||||
});
|
||||
app.get("/", (_req, res) => res.type("text").send("Disposable Paperclip Dot lab. Authenticated MCP endpoint: /mcp/paperclip. No production data."));
|
||||
app.get("/mcp-connect/:id", async (req, res) => {
|
||||
const id = requestIdSchema.parse(req.params.id);
|
||||
const redirect = redirects.get(id);
|
||||
if (redirect) { res.redirect(303, redirect); return; }
|
||||
const request = await oauth.describeRequest(id, actor, null);
|
||||
if (pending.size >= 20 && !pending.has(id)) { res.sendStatus(429); return; }
|
||||
pending.set(id, request);
|
||||
res.setHeader("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'");
|
||||
res.type("html").send(`<!doctype html><meta charset="utf-8"><meta http-equiv="refresh" content="3"><title>Paperclip Dot lab connection</title><h1>Connect the disposable Dot lab</h1><p>Waiting for the local lab operator to approve this request.</p><p>Client: ${escape(request.clientName)}. Return destination: ${escape(request.redirectOrigin)}.</p><p>This lab contains one test task and no existing Paperclip data. It expires when the lab closes.</p><p>Request: ${escape(id)}</p>`);
|
||||
});
|
||||
app.use(publicMcpIngressRoutes(oauth, createPublicMcpExecutor(db, oauth, api), service, bridge));
|
||||
const errors: express.ErrorRequestHandler = (_error, _req, res, _next) => { res.status(400).json({ error: "Lab request unavailable" }); };
|
||||
app.use(errors);
|
||||
control.use((req, res, next) => {
|
||||
const supplied = Buffer.from(req.headers.authorization ?? "");
|
||||
const expected = Buffer.from(`Bearer ${controlToken}`);
|
||||
if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) { res.sendStatus(401); return; }
|
||||
next();
|
||||
});
|
||||
control.get("/status", async (_req, res) => res.json({ companyId: company.id, taskId: task.id, connected: !!native, queued,
|
||||
pending: [...pending.values()], reports, transcript, records,
|
||||
subscriptions: await db.select({ id: mcpEventSubscriptions.id, name: mcpEventSubscriptions.name, stoppedAt: mcpEventSubscriptions.stoppedAt }).from(mcpEventSubscriptions),
|
||||
deliveries: await db.select({ id: mcpEventDeliveries.id, outcome: mcpEventDeliveries.outcome, attempts: mcpEventDeliveries.attempts }).from(mcpEventDeliveries),
|
||||
}));
|
||||
// Explicit local operator approval, never a public auto-consent route.
|
||||
control.post("/approve", async (req, res) => {
|
||||
assert(!grantId, "This lab permits only one connection");
|
||||
const requestId = requestIdSchema.parse(req.body.requestId);
|
||||
assert(pending.has(requestId), "Visit the public consent page first");
|
||||
const consent = await oauth.consent(requestId, actor, { decision: "approve", companyId: company.id, allowWrites: true });
|
||||
const grants = await oauth.listConnections(userId); assert.equal(grants.length, 1);
|
||||
grantId = grants[0]!.id;
|
||||
const identity = { companyId: company.id, agentId: randomUUID(), issueId: task.id, runId: randomUUID(), sessionId: randomUUID() };
|
||||
const driver = new DotHarnessDriver({ identity, principal: { companyId: company.id, grantId }, expiresAt: Date.now() + 2 * 3600_000,
|
||||
assertAuthority: async () => { assert(active); await oauth.authorizeGrant(grantId!); },
|
||||
tools: [{ name: "save_report", description: "Save a short harmless report in this disposable lab only", inputSchema: { type: "object", properties: { body: { type: "string", maxLength: 2000 } }, required: ["body"], additionalProperties: false } }],
|
||||
executeTool: async ({ name, arguments: args }) => { assert.equal(name, "save_report"); const { body } = z.object({ body: z.string().min(1).max(2000) }).strict().parse(args); reports.push(body); record("report_saved", { body }); return { saved: true, reportNumber: reports.length }; },
|
||||
publish: async assignment => { await db.insert(activityLog).values({ companyId: company.id, actorType: "agent", actorId: identity.agentId, action: "dot.work_available", entityType: "issue", entityId: task.id, details: { ...assignment } }); record("assignment_published", { ...assignment }); },
|
||||
});
|
||||
unregister = bridge.register(driver, grantId);
|
||||
native = await new HarnessDriverBackend(driver).openSession({ identity });
|
||||
void (async () => { for await (const event of native!.events()) { transcript.push(event); record("runner_event", { event }); } })().catch(() => record("runner_stream_error"));
|
||||
redirects.set(requestId, consent.redirectUrl); pending.delete(requestId);
|
||||
record("connection_approved"); res.json({ connected: true, companyId: company.id, taskId: task.id });
|
||||
});
|
||||
control.post("/queue", async (_req, res) => {
|
||||
assert(native && !queued, "Connect first; this lab permits one task");
|
||||
const subscriptions = await db.select().from(mcpEventSubscriptions);
|
||||
assert(subscriptions.some(s => s.grantId === grantId && s.name === "paperclip.dot.work_available" && !s.stoppedAt && s.expiresAt > new Date()), "Dot must establish its event subscription first");
|
||||
queued = true;
|
||||
await native.startTurn({ message: { role: "user", text: "Live integration test: compute 17 + 25. Call the projected save_report tool with body 'Dot received the Paperclip event. 17 + 25 = 42.' Then finish with schema paperclip.run_result.v1, reportedWorkDisposition 'done', summary stating the saved result, completionClaim {contractRevision:'dot-live-v1',objectiveSatisfied:true,criteria:[],remainingWork:[]}, and empty arrays for evidence, verification, attentionRequests, artifacts. Do not use any other apps, private data, external messages, or additional tasks." } });
|
||||
await service!.tick(); res.json({ queued: true });
|
||||
});
|
||||
control.post("/stop", (_req, res) => { res.json({ stopping: true }); stopLab(); });
|
||||
control.use(errors);
|
||||
await listen(http, port); await listen(admin, port + 1); service.start();
|
||||
await writeFile(join(scratch, "control.json"), JSON.stringify({ origin, controlOrigin: `http://127.0.0.1:${port + 1}`, token: controlToken, companyId: company.id, taskId: task.id }), { mode: 0o600 });
|
||||
console.log(`LIVE LAB READY ${origin}/mcp/paperclip`);
|
||||
console.log(`Local control file: ${join(scratch, "control.json")}`);
|
||||
console.log(`Standing instruction: Subscribe to paperclip.dot.work_available for companyId ${company.id}, taskId ${task.id}. When an event arrives, check paperclip_dot_inbox; read, accept, execute and finish its assignment. Do not poll. Wait for the event before taking work.`);
|
||||
process.once("SIGINT", stopLab); process.once("SIGTERM", stopLab);
|
||||
const timer = setTimeout(stopLab, 2 * 3600_000); timer.unref();
|
||||
await stopped; clearTimeout(timer);
|
||||
if (grantId) await oauth.revokeConnection(grantId, userId);
|
||||
} finally {
|
||||
active = false; unregister?.(); await service?.stop(); await close(http); await close(admin);
|
||||
await database?.cleanup(); await rm(scratch, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/** Opt-in live qualification: creates an isolated database/workspace and spends two Codex turns.
|
||||
* PATH=<pnpm-bin>:<codex-bin>:$PATH node --import tsx server/scripts/smoke-native-task-monitor.ts --run
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
if (!process.argv.includes("--run")) throw new Error("Pass --run to authorize the live two-turn Codex smoke.");
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-live-task-monitor-"));
|
||||
process.env.PAPERCLIP_HOME = join(root, "paperclip");
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "monitor-smoke";
|
||||
process.env.PAPERCLIP_TELEMETRY_ENABLED = "false";
|
||||
const { createDb, companies, agents, authUsers, companyMemberships, issues, heartbeatRuns, agentWakeupRequests, nativeRunResults, statusDecisions } = await import("@paperclipai/db");
|
||||
const { startEmbeddedPostgresTestDatabase } = await import("../src/__tests__/helpers/embedded-postgres.js");
|
||||
const { heartbeatService } = await import("../src/services/heartbeat.js");
|
||||
const { setupRunnerPrpWebSocketServer, runnerPrpWebSocketInternals } = await import("../src/realtime/runner-prp-ws.js");
|
||||
const { closeIdleWarmNativeSessionsForRestart } = await import("../src/services/native-runtime/native-session-executor.js");
|
||||
const temporary = await startEmbeddedPostgresTestDatabase("live-task-monitor-");
|
||||
const db = createDb(temporary.connectionString);
|
||||
const server = createServer();
|
||||
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("TCP listener missing");
|
||||
process.env.PAPERCLIP_API_URL = `http://127.0.0.1:${address.port}`;
|
||||
setupRunnerPrpWebSocketServer(server, { apiUrl: process.env.PAPERCLIP_API_URL });
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
|
||||
const heartbeat = heartbeatService(db);
|
||||
const readRuns = () => db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId)).orderBy(heartbeatRuns.createdAt);
|
||||
console.log(JSON.stringify({ root, companyId, agentId, issueId }));
|
||||
try {
|
||||
const cwd = join(root, "workspace");
|
||||
await mkdir(cwd);
|
||||
await db.insert(companies).values({ id: companyId, name: "Isolated monitor smoke", issuePrefix: "MON", defaultResponsibleUserId: "monitor-smoke", requireBoardApprovalForNewAgents: false });
|
||||
await db.insert(authUsers).values({ id: "monitor-smoke", name: "Monitor smoke", email: "monitor-smoke@example.test", createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "monitor-smoke", status: "active", membershipRole: "owner" });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Monitor verifier", status: "active", adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "codex", cwd, lifecycleMode: "warm", idleTimeoutMs: 300_000, timeoutSeconds: 180 },
|
||||
runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: true } } });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Verify a one-shot native task monitor", identifier: "MON-1", status: "in_progress", assigneeAgentId: agentId,
|
||||
description: "This is an isolated two-run integration test. First run: use set_task_monitor on this task with a fresh nextCheckAt about 45 seconds in the future (compute the current UTC time if needed), notes saying to verify issue_monitor_due, and idempotencyKey monitor-live-first. Confirm the receipt. Then call paperclip_finish with yielded and continuation.kind monitor, accurately retaining the second run as blocking remaining work. End immediately after acceptance. Do not sleep or poll. Second run: if the wake reason is issue_monitor_due, the requested check succeeded. Report that reason and finish done using the current completion contract. Do not schedule another monitor or request human review. No files or other deliverables are required." });
|
||||
await heartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual", reason: "monitor_smoke",
|
||||
requestedByActorType: "system", requestedByActorId: "monitor-smoke", payload: { issueId }, contextSnapshot: { issueId, wakeReason: "monitor_smoke" } });
|
||||
const deadline = Date.now() + 8 * 60_000;
|
||||
let firstReleasedAt: number | null = null;
|
||||
while (Date.now() < deadline) {
|
||||
const runs = await readRuns();
|
||||
if (runs.some(run => ["failed", "timed_out", "cancelled"].includes(run.status))) {
|
||||
throw new Error(`Live run failed: ${JSON.stringify(runs.map(run => ({ id: run.id, status: run.status, error: run.error, errorCode: run.errorCode })))}`);
|
||||
}
|
||||
if (runs.length === 1 && runs[0].status === "succeeded") {
|
||||
firstReleasedAt ??= Date.now();
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
assert.equal(issue.status, "in_progress");
|
||||
assert.ok(issue.monitorNextCheckAt, "first run must leave a persisted monitor");
|
||||
assert.equal(issue.executionRunId, null);
|
||||
}
|
||||
if (runs.length >= 2 && runs[1].status === "succeeded") break;
|
||||
await heartbeat.tickTimers();
|
||||
await delay(1_000);
|
||||
}
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
const runs = await readRuns();
|
||||
assert.equal(runs.length, 2, "exactly two server runs must execute");
|
||||
assert.ok(runs.every(run => run.status === "succeeded"));
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
const wakes = await db.select().from(agentWakeupRequests).where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.reason, "issue_monitor_due")));
|
||||
assert.equal(wakes.length, 1);
|
||||
assert.equal(runs[1].contextSnapshot?.wakeReason, "issue_monitor_due");
|
||||
assert.equal(issue.status, "done");
|
||||
assert.equal(issue.monitorNextCheckAt, null);
|
||||
assert.ok(firstReleasedAt && runs[1].createdAt.getTime() - firstReleasedAt < 300_000, "wake must arrive within the configured warm window");
|
||||
const results = await db.select().from(nativeRunResults).where(eq(nativeRunResults.companyId, companyId));
|
||||
const decisions = await db.select().from(statusDecisions).where(eq(statusDecisions.companyId, companyId));
|
||||
const evidence = { companyId, issueId, agentId, configuredIdleTimeoutMs: 300_000, firstReleasedAt, status: issue.status,
|
||||
runs: runs.map(run => ({ id: run.id, status: run.status, createdAt: run.createdAt, finishedAt: run.finishedAt,
|
||||
wakeReason: run.contextSnapshot?.wakeReason, nativeSessionId: run.nativeSessionId, runnerInstanceId: run.runnerInstanceId,
|
||||
processPid: run.processPid, processStartedAt: run.processStartedAt, providerSessionId: run.sessionIdAfter,
|
||||
semanticToolReceipts: run.resultJson?.semanticToolReceipts })),
|
||||
warmReuse: { sameNativeSession: runs[0].nativeSessionId === runs[1].nativeSessionId,
|
||||
sameRunnerInstance: runs[0].runnerInstanceId === runs[1].runnerInstanceId,
|
||||
sameProcess: runs[0].processPid != null && runs[0].processPid === runs[1].processPid && runs[0].processStartedAt?.getTime() === runs[1].processStartedAt?.getTime() },
|
||||
results: results.map(row => ({ runId: row.runId, result: row.resultJson })),
|
||||
decisions: decisions.map(row => ({ runId: row.runId, reasonCode: row.reasonCode, toStatus: row.toStatus })),
|
||||
monitorWakeIds: wakes.map(row => row.id) };
|
||||
await writeFile(join(root, "evidence.json"), JSON.stringify(evidence, null, 2));
|
||||
console.log(JSON.stringify({ passed: true, evidence: join(root, "evidence.json"), warmReuse: evidence.warmReuse }));
|
||||
} finally {
|
||||
await writeFile(join(root, "run-records.json"), JSON.stringify(await readRuns(), null, 2));
|
||||
await closeIdleWarmNativeSessionsForRestart();
|
||||
runnerPrpWebSocketInternals.resetForTests();
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
await temporary.cleanup();
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/** Opt-in paid smoke: node cli/node_modules/tsx/dist/cli.mjs server/scripts/verify-agent-commentary-live.ts
|
||||
* Uses real Codex, disposable database/workspaces, and a private copy of login credentials.
|
||||
* Writes only content-free evidence to stdout; never runs as part of the unit suite.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { chmod, copyFile, lstat, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { activityLog, agentCommentary, agents, heartbeatRuns, issues, nativeRunFinalizations } from "@paperclipai/db";
|
||||
import { execute as executeLegacy } from "@paperclipai/adapter-codex-local/server";
|
||||
import { startRunnerApiTestServer } from "../src/__tests__/helpers/runner-api-server.js";
|
||||
import { createLocalAgentJwt } from "../src/agent-auth-jwt.js";
|
||||
import { prepareNativeHeartbeatRun } from "../src/services/native-runtime/prepare-native-run.js";
|
||||
import { executePaperclipNativeSession } from "../src/services/native-runtime/native-session-executor.js";
|
||||
import { buildNativeExecutionInput } from "../src/services/native-runtime/native-execution-input.js";
|
||||
import { buildNativeRuntimeContext } from "../src/services/native-runtime/runtime-context.js";
|
||||
import { ensureNativeCompletionContract } from "../src/services/native-runtime/completion-contracts.js";
|
||||
import { acquireCommentaryDaytonaTarget } from "./agent-commentary-daytona.js";
|
||||
import { redactSensitiveText } from "../src/redaction.js";
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-commentary-live-"));
|
||||
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>> | undefined;
|
||||
async function makeRemovable(path: string): Promise<void> {
|
||||
const stat = await lstat(path);
|
||||
if (stat.isSymbolicLink()) return;
|
||||
if (stat.isDirectory()) {
|
||||
await chmod(path, 0o700);
|
||||
for (const name of await readdir(path)) await makeRemovable(join(path, name));
|
||||
}
|
||||
}
|
||||
try {
|
||||
assert.ok(!process.argv[2] || ["legacy", "native"].includes(process.argv[2]), "Choose legacy, native, or omit the argument for both");
|
||||
if (process.env.PAPERCLIP_LIVE_ENVIRONMENT === "daytona" && process.argv[2] !== "legacy") {
|
||||
assert.ok(process.env.PAPERCLIP_LIVE_LINUX_RUNNER, "Build and specify the current Linux runner before the native Daytona smoke");
|
||||
}
|
||||
const authSource = process.env.PAPERCLIP_LIVE_CODEX_HOME ?? process.env.CODEX_HOME ?? join(homedir(), ".codex");
|
||||
const codexHome = join(root, "codex");
|
||||
await mkdir(codexHome, { mode: 0o700 });
|
||||
await copyFile(join(authSource, "auth.json"), join(codexHome, "auth.json"));
|
||||
await writeFile(join(codexHome, "config.toml"), 'model_reasoning_effort = "low"\n');
|
||||
process.env.PAPERCLIP_HOME = join(root, "instance");
|
||||
process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID();
|
||||
process.env.PAPERCLIP_CODEX_AUTH_CACHE = "false";
|
||||
process.env.PAPERCLIP_TELEMETRY_DISABLED = "1";
|
||||
server = await startRunnerApiTestServer();
|
||||
process.env.PAPERCLIP_API_URL = server.apiUrl;
|
||||
// Production boot exports this loopback origin for host-side sandbox relays.
|
||||
const listenUrl = new URL(server.apiUrl);
|
||||
process.env.PAPERCLIP_LISTEN_HOST = listenUrl.hostname;
|
||||
process.env.PAPERCLIP_LISTEN_PORT = listenUrl.port;
|
||||
const evidence: unknown[] = [];
|
||||
for (const mode of ["legacy", "native"] as const) {
|
||||
if (process.argv[2] && process.argv[2] !== mode) continue;
|
||||
const f: Awaited<ReturnType<typeof server.fixture>> = await server.fixture({ disableWakeOnDemand: true });
|
||||
const remote: Awaited<ReturnType<typeof acquireCommentaryDaytonaTarget>> | null = process.env.PAPERCLIP_LIVE_ENVIRONMENT === "daytona" ? await acquireCommentaryDaytonaTarget(server.db, f, mode) : null;
|
||||
try {
|
||||
const prompt = `This is a bounded integration test in a disposable Paperclip instance. Use the available feedback interface to submit exactly one complaint (body: "Live ${mode} complaint: the fixture made me repeat the same setup. UGH.", key: "live-complaint") and one suggestion (body: "Live ${mode} suggestion: repeated setup wastes time; cache the fixture after initialization.", key: "live-suggestion"). ${mode === "legacy" ? "Read the automatically installed complain and suggestion-box skills and use their operational-skill stdin helper, passing each key as its second argument." : "Use the submit_complaint and submit_suggestion native tools with body and idempotencyKey."} Then continue the primary task by writing continued.txt in the working directory containing exactly continued-after-feedback. End with a brief final response. Do not create task comments or modify task status; the test harness owns disposition. Do not retry submissions. Do not access other workspaces or any external messaging tools.`;
|
||||
let log = "";
|
||||
const onLog = async (_stream: "stdout" | "stderr", chunk: string) => { log += chunk; };
|
||||
console.error(`Starting real ${mode} Codex feedback smoke.`);
|
||||
let result;
|
||||
if (mode === "legacy") {
|
||||
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));
|
||||
result = await executeLegacy({
|
||||
runId: f.runId,
|
||||
agent: { id: f.agentId, companyId: f.companyId, name: "Feedback smoke", adapterType: "codex_local", adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: f.issueId },
|
||||
config: { engine: "cli", cwd: f.workspace, timeoutSec: 180, promptTemplate: prompt, extraArgs: ["--skip-git-repo-check"], env: { CODEX_HOME: codexHome } },
|
||||
context: { issueId: f.issueId },
|
||||
executionTarget: remote?.target,
|
||||
authToken: createLocalAgentJwt(f.agentId, f.companyId, "codex_local", f.runId)!, onLog,
|
||||
});
|
||||
for (const skill of ["complain", "suggestion-box", "paperclip"]) {
|
||||
assert.ok(await readFile(join(codexHome, "skills", skill, "SKILL.md"), "utf8"));
|
||||
}
|
||||
} else {
|
||||
const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
||||
const native = await prepareNativeHeartbeatRun({ db: server.db, run, issue: { id: f.issueId, title: "Feedback smoke", description: prompt, reviewPolicy: null }, environmentLeaseId: remote?.target.leaseId ?? randomUUID() });
|
||||
const [agent] = await server.db.select().from(agents).where(eq(agents.id, f.agentId));
|
||||
const completion = await ensureNativeCompletionContract({ db: server.db, companyId: f.companyId, issue: { id: f.issueId, title: "Feedback smoke", description: prompt }, actorId: f.agentId });
|
||||
const execution = buildNativeExecutionInput({
|
||||
companyId: f.companyId, runId: f.runId, agentId: f.agentId,
|
||||
issue: { id: f.issueId, identifier: null, title: "Feedback smoke", description: prompt, workMode: "standard" },
|
||||
taskPrompt: prompt, workspace: { id: f.runId, cwd: f.workspace, repoUrl: null, repoRef: null, branchName: null },
|
||||
normalizedSessionId: native.normalizedSessionId, provider: "codex", codexApprovalPolicy: "never", codexReasoningEffort: "low",
|
||||
completionContract: { id: completion.row.id, sha256: completion.row.canonicalSha256, schemaVersion: completion.row.schemaVersion, contract: completion.contract },
|
||||
runtimeContext: await buildNativeRuntimeContext({ db: server.db, agent, runId: f.runId, runtimeConfig: {}, runtimeSkillEntries: [] }),
|
||||
});
|
||||
await server.db.update(heartbeatRuns).set({ runnerProfileJson: { nativeExecutionInput: execution } }).where(eq(heartbeatRuns.id, f.runId));
|
||||
await server.db.insert(nativeRunFinalizations).values({ companyId: f.companyId, issueId: f.issueId, runId: f.runId, phase: "observed" });
|
||||
result = await executePaperclipNativeSession({
|
||||
db: server.db, execution, runnerInstanceId: native.runnerInstanceId, turnTimeoutMs: 180_000,
|
||||
runnerEnvironment: { ...process.env, CODEX_HOME: codexHome }, useRunnerd: true, onLog,
|
||||
runnerExecutionTarget: remote?.target, runnerIngressAuthorized: Boolean(remote),
|
||||
runnerRemoteBinaryPath: remote ? process.env.PAPERCLIP_LIVE_LINUX_RUNNER : undefined,
|
||||
runnerRemoteCodexNpmSpec: remote ? "@openai/codex@0.160.0" : undefined,
|
||||
managedAiCredentialHome: codexHome,
|
||||
}).catch((error) => { throw new Error(redactSensitiveText(log.slice(-3000)), { cause: error }); });
|
||||
}
|
||||
const rows = await server.db.select().from(agentCommentary).where(eq(agentCommentary.runId, f.runId));
|
||||
assert.equal(result.exitCode, 0, `${mode}: adapter must complete successfully`);
|
||||
assert.equal(result.timedOut, false);
|
||||
// On failure expose only sanitized adapter status and row count, never provider logs or credentials.
|
||||
assert.equal(rows.length, 2, `${mode}: expected two persisted rows (adapter exit ${result.exitCode}, timed out ${result.timedOut})`);
|
||||
assert.deepEqual(rows.map(r => r.kind).sort(), ["complaint", "suggestion"]);
|
||||
assert.ok(rows.every(r => r.companyId === f.companyId && r.agentId === f.agentId && r.runId === f.runId && r.issueId === f.issueId));
|
||||
const continued: string = remote && mode === "native"
|
||||
? (await remote.target.runner!.execute({ command: "cat", args: ["continued.txt"], cwd: remote.target.remoteCwd, bypassSession: true })).stdout
|
||||
: await readFile(join(f.workspace, "continued.txt"), "utf8");
|
||||
assert.equal(continued.trim(), "continued-after-feedback");
|
||||
const snapshot = await f.snapshot();
|
||||
assert.equal(snapshot.comments.length, 0);
|
||||
assert.equal(snapshot.issues.find(i => i.id === f.issueId)?.status, "in_progress");
|
||||
const audit = await server.db.select().from(activityLog).where(eq(activityLog.runId, f.runId));
|
||||
const feedbackAudit = audit.filter(a => a.action === "agent.commentary_submitted");
|
||||
assert.equal(feedbackAudit.length, 2);
|
||||
assert.ok(feedbackAudit.every(a => Object.keys(a.details ?? {}).join() === "kind"));
|
||||
evidence.push({ environment: remote ? "daytona" : "local", image: remote?.image, providerLeaseId: remote?.providerLeaseId, mode, exitCode: result.exitCode, timedOut: result.timedOut, companyId: f.companyId, agentId: f.agentId, runId: f.runId, issueId: f.issueId, rows: rows.map(({id, kind, createdAt}) => ({id, kind, createdAt})), continued: true, taskComments: 0, taskStatus: "in_progress", contentFreeAuditEntries: feedbackAudit.length });
|
||||
// Stop the fixture run so no subsequent sweep treats it as live work.
|
||||
await server.db.update(issues).set({ executionRunId: null }).where(eq(issues.id, f.issueId));
|
||||
await server.db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() }).where(eq(heartbeatRuns.id, f.runId));
|
||||
log = "";
|
||||
} finally {
|
||||
if (remote) {
|
||||
await remote.cleanup();
|
||||
console.error(`Destroyed disposable ${mode} Daytona sandbox.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const report = JSON.stringify({ verifiedAt: new Date().toISOString(), evidence }, null, 2);
|
||||
if (process.env.PAPERCLIP_LIVE_EVIDENCE_PATH) await writeFile(process.env.PAPERCLIP_LIVE_EVIDENCE_PATH, report + "\n");
|
||||
console.log(report);
|
||||
} finally {
|
||||
try {
|
||||
await server?.close();
|
||||
} finally {
|
||||
await makeRemovable(root);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
// (#13116) -- because nothing enforced it.
|
||||
//
|
||||
// This derives the required set from esbuild's own module-resolution scan
|
||||
// of the two entry points server actually imports (index.js, testing.js),
|
||||
// of the entry points server actually imports (index.js, testing.js, live/index.js),
|
||||
// with `write: false` so nothing is written to disk and `packages:
|
||||
// "external"` so npm imports are reported, not inlined. That is precise:
|
||||
// paperclip-runner declares dependencies (react-markdown, the codex/opencode
|
||||
@@ -43,9 +43,8 @@ const runnerRoot = resolve(serverRoot, "../packages/paperclip-runner");
|
||||
const runnerDist = resolve(runnerRoot, "dist");
|
||||
|
||||
// The only entry points server/src actually imports from the vendored
|
||||
// runner (server/src/**/*.ts import "../vendor/paperclip-runner/index.js"
|
||||
// or ".../testing.js").
|
||||
const ENTRY_POINT_NAMES = ["index.js", "testing.js"];
|
||||
// runner, including the installed-runtime readiness probe surface.
|
||||
const ENTRY_POINT_NAMES = ["index.js", "testing.js", "live/index.js"];
|
||||
|
||||
const NODE_BUILTINS = new Set([
|
||||
...builtinModules,
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -42,6 +42,14 @@ vi.mock("../services/index.js", () => ({
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
}));
|
||||
|
||||
vi.mock("../services/authorization.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../services/authorization.js")>();
|
||||
return {
|
||||
...actual,
|
||||
issueReadSqlCondition: vi.fn(async () => ({}) as any),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../services/agent-action-audit.js", () => ({
|
||||
agentActionAuditService: () => mockAgentActionAuditService,
|
||||
}));
|
||||
@@ -101,7 +109,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();
|
||||
@@ -219,6 +227,7 @@ describe.sequential("activity routes", () => {
|
||||
entityType: undefined,
|
||||
entityId: undefined,
|
||||
limit: 100,
|
||||
readCondition: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -237,6 +246,7 @@ describe.sequential("activity routes", () => {
|
||||
entityType: "issue",
|
||||
entityId: undefined,
|
||||
limit: 500,
|
||||
readCondition: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -277,6 +287,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",
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { activityService } from "../services/activity.ts";
|
||||
import { issueReadSqlCondition } from "../services/authorization.ts";
|
||||
import { issueService } from "../services/issues.ts";
|
||||
import { documentService } from "../services/documents.ts";
|
||||
|
||||
@@ -150,7 +151,55 @@ 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("filters company activity rows whose entity is a private issue", async () => {
|
||||
const companyId = randomUUID();
|
||||
const privateIssueId = randomUUID();
|
||||
const openIssueId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Private activity",
|
||||
issuePrefix: `A${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
});
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: privateIssueId,
|
||||
companyId,
|
||||
title: "Confidential activity",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
visibility: "private",
|
||||
privacyRootIssueId: privateIssueId,
|
||||
responsibleUserId: "private-owner",
|
||||
},
|
||||
{
|
||||
id: openIssueId,
|
||||
companyId,
|
||||
title: "Open activity",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
},
|
||||
]);
|
||||
await db.insert(activityLog).values([
|
||||
{ companyId, actorType: "system", actorId: "system", action: "private.changed", entityType: "issue", entityId: privateIssueId },
|
||||
{ companyId, actorType: "system", actorId: "system", action: "open.changed", entityType: "issue", entityId: openIssueId },
|
||||
]);
|
||||
const actor = {
|
||||
type: "board" as const,
|
||||
userId: "non-member",
|
||||
companyIds: [companyId],
|
||||
source: "session" as const,
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
|
||||
const rows = await activityService(db).list({
|
||||
companyId,
|
||||
readCondition: await issueReadSqlCondition(db, actor),
|
||||
});
|
||||
expect(rows.map((row) => row.action)).toContain("open.changed");
|
||||
expect(rows.map((row) => row.action)).not.toContain("private.changed");
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -175,12 +224,25 @@ describeEmbeddedPostgres("activity service", () => {
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Summarize a completed run",
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
scopeKind: "issue",
|
||||
issueId,
|
||||
invocationSource: "assignment",
|
||||
status: "succeeded",
|
||||
status: errorCode ? "failed" : "succeeded",
|
||||
errorCode,
|
||||
error: "provider-error".repeat(200),
|
||||
contextSnapshot: { issueId },
|
||||
usageJson: {
|
||||
inputTokens: 11,
|
||||
@@ -215,6 +277,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,
|
||||
@@ -292,6 +355,8 @@ describeEmbeddedPostgres("activity service", () => {
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
scopeKind: "issue",
|
||||
issueId,
|
||||
invocationSource: "assignment",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-04-18T20:00:00.000Z"),
|
||||
@@ -386,6 +451,8 @@ describeEmbeddedPostgres("activity service", () => {
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
scopeKind: "issue",
|
||||
issueId,
|
||||
invocationSource: "assignment",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-04-18T20:00:00.000Z"),
|
||||
@@ -401,6 +468,8 @@ describeEmbeddedPostgres("activity service", () => {
|
||||
id: otherRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
scopeKind: "issue",
|
||||
issueId,
|
||||
invocationSource: "assignment",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-04-18T20:05:00.000Z"),
|
||||
@@ -508,6 +577,8 @@ describeEmbeddedPostgres("activity service", () => {
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
scopeKind: "issue",
|
||||
issueId,
|
||||
invocationSource: "assignment",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-04-18T20:10:00.000Z"),
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
|
||||
const processResult = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@paperclipai/adapter-utils/execution-target", async (original) => ({
|
||||
...await original<typeof import("@paperclipai/adapter-utils/execution-target")>(),
|
||||
runAdapterExecutionTargetProcess: processResult,
|
||||
}));
|
||||
|
||||
type Execute = (context: AdapterExecutionContext) => Promise<AdapterExecutionResult>;
|
||||
const cases: Array<{ name: string; execute: () => Promise<Execute>; event: unknown; tokens?: { inputTokens: number; outputTokens: number; cachedInputTokens: number }; price: number | null }> = [
|
||||
{ name: "codex", execute: async () => (await import("../../../packages/adapters/codex-local/src/server/execute.js")).execute,
|
||||
event: { type: "turn.completed", usage: { input_tokens: 120, cached_input_tokens: 100, output_tokens: 10 } }, tokens: { inputTokens: 20, cachedInputTokens: 100, outputTokens: 10 }, price: null },
|
||||
{ name: "claude", execute: async () => (await import("../../../packages/adapters/claude-local/src/server/execute.js")).execute,
|
||||
event: { type: "assistant", message: { id: "m1", usage: { input_tokens: 20, cache_read_input_tokens: 100, output_tokens: 10 }, content: [] } }, tokens: { inputTokens: 20, cachedInputTokens: 100, outputTokens: 10 }, price: null },
|
||||
{ name: "opencode", execute: async () => (await import("../../../packages/adapters/opencode-local/src/server/execute.js")).execute,
|
||||
event: { type: "step_finish", part: { tokens: { input: 20, output: 10, cache: { read: 100 } }, cost: 0.004 } }, tokens: { inputTokens: 20, cachedInputTokens: 100, outputTokens: 10 }, price: 0.004 },
|
||||
{ name: "pi", execute: async () => (await import("../../../packages/adapters/pi-local/src/server/execute.js")).execute,
|
||||
event: { type: "turn_end", message: { role: "assistant", content: [], usage: { input: 20, output: 10, cacheRead: 100, cost: { total: 0.004 } } } }, tokens: { inputTokens: 20, cachedInputTokens: 100, outputTokens: 10 }, price: 0.004 },
|
||||
{ name: "cursor", execute: async () => (await import("../../../packages/adapters/cursor-local/src/server/execute.js")).execute,
|
||||
event: { type: "result", usage: { input_tokens: 20, cache_read_input_tokens: 100, output_tokens: 10 }, total_cost_usd: 0.004 }, tokens: { inputTokens: 20, cachedInputTokens: 100, outputTokens: 10 }, price: 0.004 },
|
||||
{ name: "gemini", execute: async () => (await import("../../../packages/adapters/gemini-local/src/server/execute.js")).execute,
|
||||
event: { type: "result", usage: { input_tokens: 120, cached_input_tokens: 100, output_tokens: 10 }, total_cost_usd: 0.004 }, tokens: { inputTokens: 20, cachedInputTokens: 100, outputTokens: 10 }, price: 0.004 },
|
||||
{ name: "kimi", execute: async () => (await import("../../../packages/adapters/kimi-local/src/server/execute.js")).execute,
|
||||
event: { type: "assistant", content: "partial work" }, price: null },
|
||||
];
|
||||
|
||||
describe("CLI adapter accounting on timeout", () => {
|
||||
// Keep OpenCode runtime config copies inside each fixture; host config may
|
||||
// include plugins and large dependency trees unrelated to accounting.
|
||||
const directories: string[] = [];
|
||||
afterEach(async () => { vi.clearAllMocks(); for (const dir of directories.splice(0)) await rm(dir, { recursive: true, force: true }); });
|
||||
it.each(cases.filter(f => ["codex", "cursor", "opencode"].includes(f.name)))("$name preserves malformed optional counters through the checkpoint wrapper", async fixture => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "paperclip-invalid-counter-")); directories.push(dir);
|
||||
const command = join(dir, "runtime"); await writeFile(command, "#!/bin/sh\nprintf 'openai test\\n'\n", { mode: 0o755 });
|
||||
const execute = await fixture.execute();
|
||||
for (const cached of ["private malformed counter", false, {}, 0, undefined]) for (const newline of [false, true]) {
|
||||
const event = fixture.name === "opencode"
|
||||
? { type: "step_finish", part: { tokens: { input: 20, output: 10, cache: { read: cached } } } }
|
||||
: { type: fixture.name === "codex" ? "turn.completed" : "result", usage: { input_tokens: 20, output_tokens: 10, cached_input_tokens: cached } };
|
||||
const stdout = JSON.stringify(event) + (newline ? "\n" : "");
|
||||
processResult.mockImplementation(async (_run, _target, _command, _args, options) => {
|
||||
for (let offset = 0; offset < stdout.length; offset += 7) await options.onLog("stdout", stdout.slice(offset, offset + 7));
|
||||
return { exitCode: 0, signal: null, timedOut: false, stdout, stderr: "", pid: 123, startedAt: new Date().toISOString() };
|
||||
});
|
||||
const onUsage = vi.fn();
|
||||
const result = await execute({
|
||||
runId: "invalid-counter-run", agent: { id: "agent", companyId: "company", name: "Worker", adapterType: `${fixture.name}_local`, adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: { engine: "cli", command, cwd: dir, model: fixture.name === "opencode" ? "openai/test" : "gpt-6-astra", paperclipRuntimeSkills: [],
|
||||
env: { XDG_CONFIG_HOME: dir, OPENAI_API_KEY: "fixture", OPENCODE_ALLOW_ALL_MODELS: "1" } },
|
||||
context: {}, onLog: async () => {}, onUsage,
|
||||
});
|
||||
const valid = cached === 0 || cached === undefined;
|
||||
expect(result.usageComplete, `${fixture.name}: ${JSON.stringify(cached)}`).toBe(valid);
|
||||
expect(result.usage).toEqual(valid ? { inputTokens: 20, outputTokens: 10, cachedInputTokens: 0 } : undefined);
|
||||
expect(onUsage.mock.calls.at(-1)![0].complete).toBe(valid);
|
||||
if (!valid) {
|
||||
expect(result.costStatus).toBe("unpriced");
|
||||
expect(onUsage.mock.calls.every(([receipt]) => !receipt.complete && receipt.costStatus === "unpriced")).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
it.each(cases.filter(fixture => fixture.name !== "kimi"))("$name flushes its attempt and surfaces checkpoint persistence failures", async (fixture) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "paperclip-accounting-adapter-")); directories.push(dir);
|
||||
const command = join(dir, "runtime"); await writeFile(command, "#!/bin/sh\nprintf 'openai test\\n'\n", { mode: 0o755 });
|
||||
const stdout = JSON.stringify(fixture.event);
|
||||
processResult.mockImplementation(async (_run, _target, _command, _args, options) => {
|
||||
// Local process logging catches callback failures. Finalization must
|
||||
// independently enforce receipt durability despite that contract.
|
||||
await options.onLog("stdout", stdout).catch(() => {});
|
||||
return { exitCode: 0, signal: null, timedOut: false, stdout, stderr: "", pid: 123, startedAt: new Date().toISOString() };
|
||||
});
|
||||
const onUsage = vi.fn();
|
||||
const context: AdapterExecutionContext = {
|
||||
runId: "test-run", agent: { id: "test-agent", companyId: "test-company", name: "Accounting", adapterType: `${fixture.name}_local`, adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: { engine: "cli", command, cwd: dir, model: ["opencode", "pi"].includes(fixture.name) ? "openai/test" : fixture.name === "cursor" ? "gpt-5" : "test", paperclipRuntimeSkills: [],
|
||||
env: { XDG_CONFIG_HOME: dir, OPENAI_API_KEY: "test-placeholder", ANTHROPIC_API_KEY: "test-placeholder", GEMINI_API_KEY: "test-placeholder", OPENCODE_ALLOW_ALL_MODELS: "1", CLAUDE_CONFIG_DIR: dir } },
|
||||
context: {}, onLog: async () => {}, onUsage,
|
||||
};
|
||||
const execute = await fixture.execute();
|
||||
const initial = await execute(context);
|
||||
const complete = !["claude", "pi"].includes(fixture.name);
|
||||
expect(initial.usageComplete).toBe(complete);
|
||||
expect(onUsage.mock.calls.at(-1)![0]).toMatchObject({ usage: fixture.tokens, costUsd: fixture.price, complete });
|
||||
if (fixture.name === "codex") {
|
||||
for (const type of ["turn.failed", "error", "turn.completed"]) {
|
||||
processResult.mockImplementationOnce(async (_run, _target, _command, _args, options) => {
|
||||
const failedOutput = JSON.stringify({ type }) + "\n";
|
||||
await options.onLog("stdout", failedOutput);
|
||||
return { exitCode: 1, signal: null, timedOut: false, stdout: failedOutput, stderr: "", pid: 123, startedAt: new Date().toISOString() };
|
||||
});
|
||||
const failed = await execute({ ...context, config: { ...context.config, model: "gpt-6-astra" } });
|
||||
expect(failed).toMatchObject({ usage: undefined, usageComplete: false, costStatus: "unpriced", costUsd: null });
|
||||
expect(onUsage.mock.calls.at(-1)![0]).toMatchObject({ usage: undefined, complete: false, costStatus: "unpriced", costUsd: null });
|
||||
}
|
||||
for (const [kind, auth, biller] of [["openrouter", "api_key", "openrouter"], ["custom", "api_key", "unknown"], ["local", "none", "unknown"]]) {
|
||||
const routed = await execute({ ...context, config: { ...context.config,
|
||||
managedAiRouting: { kind, auth }, env: { ...(context.config.env as object), OPENAI_API_KEY: "", PAPERCLIP_AI_PROVIDER_KEY: auth === "none" ? "" : "fixture" },
|
||||
} });
|
||||
expect(routed).toMatchObject({ billingType: "api", biller, costUsd: null });
|
||||
expect(onUsage.mock.calls.at(-1)![0]).toMatchObject({ billingType: "api", biller, costUsd: null });
|
||||
}
|
||||
for (const safer of [false, true]) {
|
||||
const priced = await execute({ ...context,
|
||||
config: { ...context.config, model: "gpt-5.4", fastMode: true },
|
||||
context: safer ? { codexTransientFallbackMode: "fresh_session_safer_invocation" } : {},
|
||||
});
|
||||
const expected = safer ? undefined : { serviceTier: "fast" };
|
||||
expect(priced.pricingContext).toEqual(expected);
|
||||
expect(onUsage.mock.calls.at(-1)![0].pricingContext).toEqual(expected);
|
||||
}
|
||||
}
|
||||
if (fixture.name === "opencode") {
|
||||
for (const kind of ["zero_usage", "zero_price", "missing", "partial"]) for (const timedOut of [false, true]) {
|
||||
const part = kind === "zero_usage" ? { tokens: { input: 0, output: 0 } } : kind === "zero_price" ? { cost: 0 } : kind === "partial" ? { tokens: { input: 2 } } : {};
|
||||
processResult.mockImplementationOnce(async (_run, _target, _command, _args, options) => {
|
||||
const output = JSON.stringify({ type: "step_finish", part });
|
||||
await options.onLog("stdout", output);
|
||||
return { exitCode: timedOut ? null : 0, signal: timedOut ? "SIGTERM" : null, timedOut, stdout: output, stderr: "", pid: 123, startedAt: new Date().toISOString() };
|
||||
});
|
||||
onUsage.mockClear();
|
||||
const reported = await execute(context);
|
||||
const complete = !timedOut && kind.startsWith("zero_");
|
||||
expect(reported.usageComplete).toBe(complete);
|
||||
expect(reported.usage).toEqual(kind === "zero_usage" ? { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0 } : undefined);
|
||||
expect(reported.costUsd).toBe(kind === "zero_price" ? 0 : null);
|
||||
if (complete || !kind.startsWith("zero_")) expect(onUsage.mock.calls.at(-1)![0]).toMatchObject({ complete });
|
||||
if (!kind.startsWith("zero_")) expect(reported.costStatus).toBe("unpriced");
|
||||
}
|
||||
}
|
||||
if (fixture.name === "cursor") {
|
||||
for (const type of ["result", "step_finish"]) for (const timedOut of [false, true]) {
|
||||
processResult.mockImplementationOnce(async (_run, _target, _command, _args, options) => {
|
||||
const output = JSON.stringify({ type }) + "\n";
|
||||
await options.onLog("stdout", output);
|
||||
return { exitCode: timedOut ? null : 0, signal: timedOut ? "SIGTERM" : null, timedOut, stdout: output, stderr: "", pid: 123, startedAt: new Date().toISOString() };
|
||||
});
|
||||
const missing = await execute({ ...context, config: { ...context.config, model: "gpt-5.6-sol" } });
|
||||
expect(missing).toMatchObject({ usage: undefined, usageComplete: false, costStatus: "unpriced", costUsd: null });
|
||||
expect(onUsage.mock.calls.at(-1)![0]).toMatchObject({ usage: undefined, complete: false, costStatus: "unpriced", costUsd: null });
|
||||
}
|
||||
for (const explicitPrice of [false, true]) {
|
||||
processResult.mockImplementationOnce(async (_run, _target, _command, _args, options) => {
|
||||
const output = JSON.stringify({ type: "result", ...(explicitPrice ? { total_cost_usd: 0 } : { usage: { input_tokens: 0, output_tokens: 0 } }) }) + "\n";
|
||||
await options.onLog("stdout", output);
|
||||
return { exitCode: 0, signal: null, timedOut: false, stdout: output, stderr: "", pid: 123, startedAt: new Date().toISOString() };
|
||||
});
|
||||
const reported = await execute(context);
|
||||
expect(reported.usageComplete).toBe(true);
|
||||
expect(reported.usage).toEqual(explicitPrice ? undefined : { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0 });
|
||||
expect(reported.costUsd).toBe(explicitPrice ? 0 : null);
|
||||
expect(onUsage.mock.calls.at(-1)![0].complete).toBe(true);
|
||||
}
|
||||
}
|
||||
if (["claude", "pi", "opencode"].includes(fixture.name)) {
|
||||
processResult.mockImplementationOnce(async (_run, _target, _command, _args, options) => {
|
||||
await options.onLog("stdout", stdout).catch(() => {});
|
||||
return { exitCode: 1, signal: null, timedOut: false, stdout, stderr: "process failed", pid: 123, startedAt: new Date().toISOString() };
|
||||
});
|
||||
expect((await execute(context)).usageComplete).toBe(false);
|
||||
expect(onUsage.mock.calls.at(-1)![0].complete).toBe(false);
|
||||
}
|
||||
if (fixture.name === "claude") {
|
||||
const mixed = [
|
||||
{ type: "system", subtype: "init", model: "actual-model" },
|
||||
{ type: "result", total_cost_usd: 0.003, usage: { input_tokens: 3, output_tokens: 3 }, modelUsage: {
|
||||
"model-a": { inputTokens: 1, outputTokens: 1, costUSD: 0.001 },
|
||||
"model-b": { inputTokens: 2, outputTokens: 2, costUSD: 0.002 },
|
||||
} },
|
||||
];
|
||||
processResult.mockImplementation(async (_run, _target, _command, _args, options) => {
|
||||
const output = mixed.map(item => JSON.stringify(item)).join("\n");
|
||||
await options.onLog("stdout", output).catch(() => {});
|
||||
return { exitCode: 0, signal: null, timedOut: false, stdout: output, stderr: "", pid: 123, startedAt: new Date().toISOString() };
|
||||
});
|
||||
await execute(context);
|
||||
expect(onUsage.mock.calls.at(-1)![0]).toMatchObject({ model: "mixed", costUsd: 0.003, complete: true, usageByModel: [
|
||||
{ model: "model-a", costUsd: 0.001 }, { model: "model-b", costUsd: 0.002 },
|
||||
] });
|
||||
delete (mixed[1] as { modelUsage?: unknown }).modelUsage;
|
||||
await execute(context);
|
||||
expect(onUsage.mock.calls.at(-1)![0]).toMatchObject({ model: "actual-model", complete: true });
|
||||
}
|
||||
onUsage.mockRejectedValue(new Error("Receipt persistence failed"));
|
||||
await expect(execute(context)).rejects.toThrow("Receipt persistence failed");
|
||||
});
|
||||
it.each(cases.filter(f => ["pi", "opencode"].includes(f.name)))("$name preserves full-stream accounting after stdout truncation", async (fixture) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "paperclip-accounting-tail-")); directories.push(dir);
|
||||
const command = join(dir, "runtime"); await writeFile(command, "#!/bin/sh\nprintf 'anthropic test\\n'\n", { mode: 0o755 });
|
||||
const execute = await fixture.execute();
|
||||
for (const checkpoint of [true, false]) for (const timedOut of [false, true]) for (const missingPrice of [false, true]) {
|
||||
const early = structuredClone(fixture.event) as any;
|
||||
if (missingPrice) {
|
||||
if (fixture.name === "pi") delete early.message.usage.cost;
|
||||
else delete early.part.cost;
|
||||
}
|
||||
const end = fixture.name === "pi" && !timedOut ? '\n{"type":"agent_end","messages":[]}' : "";
|
||||
const first = JSON.stringify(early) + "\n";
|
||||
const noise = JSON.stringify({ type: "text", part: { text: "x".repeat(9 * 1024 * 1024) } }) + "\n";
|
||||
const last = JSON.stringify(fixture.event) + end;
|
||||
processResult.mockImplementation(async (_run, _target, _command, _args, options) => {
|
||||
for (const record of [first, noise, last]) {
|
||||
for (let offset = 0; offset < record.length; offset += 65521) await options.onLog("stdout", record.slice(offset, offset + 65521));
|
||||
}
|
||||
return { exitCode: timedOut ? null : 0, signal: timedOut ? "SIGTERM" : null, timedOut, stdout: (first + noise + last).slice(-4 * 1024 * 1024), stderr: "", pid: 123, startedAt: new Date().toISOString() };
|
||||
});
|
||||
const onUsage = checkpoint ? vi.fn() : undefined;
|
||||
const result = await execute({
|
||||
runId: "tail-run", agent: { id: "test-agent", companyId: "test-company", name: "Accounting", adapterType: `${fixture.name}_local`, adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: { engine: "cli", command, cwd: dir, model: "anthropic/test", paperclipRuntimeSkills: [], env: { XDG_CONFIG_HOME: dir, ANTHROPIC_API_KEY: "fixture", OPENAI_BASE_URL: "https://unrelated-proxy.example/v1", OPENROUTER_API_KEY: "unrelated-key", OPENCODE_ALLOW_ALL_MODELS: "1" } },
|
||||
context: {}, onLog: async () => {}, onUsage,
|
||||
});
|
||||
expect(result.usage).toMatchObject({ inputTokens: 40, outputTokens: 20, cachedInputTokens: 200 });
|
||||
expect(result.costUsd).toBe(missingPrice ? null : 0.008);
|
||||
expect(result.biller).toBe("anthropic");
|
||||
expect(result.usageComplete).toBe(!timedOut);
|
||||
if (onUsage) expect(onUsage.mock.calls.at(-1)![0]).toMatchObject({ usage: result.usage, costUsd: result.costUsd });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(cases)("$name retains observed accounting when the process times out", async (fixture) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "paperclip-accounting-adapter-")); directories.push(dir);
|
||||
const command = join(dir, "runtime"); await writeFile(command, "#!/bin/sh\nprintf 'openai test\\n'\n", { mode: 0o755 });
|
||||
processResult.mockResolvedValue({ exitCode: null, signal: "SIGTERM", timedOut: true, stdout: JSON.stringify(fixture.event), stderr: "", pid: 123, startedAt: new Date().toISOString() });
|
||||
const execute = await fixture.execute();
|
||||
const result = await execute({
|
||||
runId: "test-run", agent: { id: "test-agent", companyId: "test-company", name: "Accounting", adapterType: `${fixture.name}_local`, adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: { engine: "cli", command, cwd: dir, model: ["opencode", "pi"].includes(fixture.name) ? "openai/test" : fixture.name === "cursor" ? "gpt-5" : "test", paperclipRuntimeSkills: [],
|
||||
env: { XDG_CONFIG_HOME: dir, OPENAI_API_KEY: "test-placeholder", ANTHROPIC_API_KEY: "test-placeholder", GEMINI_API_KEY: "test-placeholder", MOONSHOT_API_KEY: "test-placeholder", OPENCODE_ALLOW_ALL_MODELS: "1", CLAUDE_CONFIG_DIR: dir } },
|
||||
context: {}, onLog: async () => {},
|
||||
});
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.usageComplete).toBe(["codex", "cursor", "gemini", "kimi"].includes(fixture.name));
|
||||
if (fixture.name === "kimi") expect(result.costStatus).toBe("unpriced");
|
||||
expect(result.usageBasis).toBe("per_run");
|
||||
expect(result.provider).toBeTruthy();
|
||||
expect(result.billingType).toBeTruthy();
|
||||
if (fixture.tokens) expect(result.usage).toMatchObject(fixture.tokens);
|
||||
expect(result.costUsd).toBe(fixture.price);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("incremental protocol accounting", () => {
|
||||
it.each(["turn.failed", "error", "turn.started"])("saves an unpriced Codex %s checkpoint before exposing its log", async (type) => {
|
||||
const { createCodexJsonlParser } = await import("../../../packages/adapters/codex-local/src/server/parse.js");
|
||||
const { createUsageCheckpointLog } = await import("@paperclipai/adapter-utils/usage-checkpoint");
|
||||
const consume = createCodexJsonlParser();
|
||||
const order: string[] = [];
|
||||
const saved = vi.fn(async () => { order.push("receipt"); });
|
||||
const parsed = vi.fn((records: string) => {
|
||||
const result = consume(records);
|
||||
return { usage: result.usageReported ? result.usage : undefined, complete: result.usageComplete, costStatus: "unpriced" as const, costUsd: null };
|
||||
});
|
||||
const log = createUsageCheckpointLog(async () => { order.push("log"); }, saved, parsed);
|
||||
await log("stdout", JSON.stringify({ type, message: "private provider failure" }) + "\n");
|
||||
// No flush or adapter return has happened: a host crash after this point
|
||||
// must preserve the unknown usage and keep the reservation pending.
|
||||
expect(saved).toHaveBeenCalledWith(expect.objectContaining({ complete: false, costUsd: null, costStatus: "unpriced", usage: undefined }));
|
||||
expect(order).toEqual(["receipt", "log"]);
|
||||
expect(parsed.mock.calls[0][0]).not.toContain("private provider failure");
|
||||
});
|
||||
|
||||
it("keeps Claude message updates idempotent and preserves immutable snapshots", async () => {
|
||||
const { createClaudeStreamParser } = await import("../../../packages/adapters/claude-local/src/server/parse.js");
|
||||
const consume = createClaudeStreamParser();
|
||||
consume(JSON.stringify({ type: "system", subtype: "init", model: "actual-model" }));
|
||||
const message = (id: string, output: number) => JSON.stringify({ type: "assistant", message: { id, usage: { input_tokens: 2, output_tokens: output } } });
|
||||
const first = consume(message("one", 1));
|
||||
expect(first.usage).toMatchObject({ inputTokens: 2, outputTokens: 1 });
|
||||
expect(consume(message("one", 3)).usage).toMatchObject({ inputTokens: 2, outputTokens: 3 });
|
||||
expect(consume(message("two", 2)).usage).toMatchObject({ inputTokens: 4, outputTokens: 5 });
|
||||
expect(first.usage).toMatchObject({ inputTokens: 2, outputTokens: 1 });
|
||||
const final = consume(JSON.stringify({ type: "result", total_cost_usd: 1, usage: { input_tokens: 9, output_tokens: 8 } }));
|
||||
expect(final).toMatchObject({ model: "actual-model", costUsd: 1, usage: { inputTokens: 9, outputTokens: 8 } });
|
||||
});
|
||||
it.each(cases.filter(item => !["claude", "kimi"].includes(item.name)))("updates $name totals once per newly received record", async fixture => {
|
||||
const factories = {
|
||||
codex: async () => (await import("../../../packages/adapters/codex-local/src/server/parse.js")).createCodexJsonlParser(),
|
||||
cursor: async () => (await import("../../../packages/adapters/cursor-local/src/server/parse.js")).createCursorJsonlParser(),
|
||||
gemini: async () => (await import("../../../packages/adapters/gemini-local/src/server/parse.js")).createGeminiJsonlParser(),
|
||||
pi: async () => (await import("../../../packages/adapters/pi-local/src/server/parse.js")).createPiJsonlParser(),
|
||||
opencode: async () => (await import("../../../packages/adapters/opencode-local/src/server/parse.js")).createOpenCodeJsonlParser(),
|
||||
};
|
||||
const consume = await factories[fixture.name as keyof typeof factories]();
|
||||
const line = JSON.stringify(fixture.event);
|
||||
const first = consume(line);
|
||||
const second = consume(line);
|
||||
expect(first.usage).toMatchObject(fixture.tokens!);
|
||||
const multiplier = fixture.name === "codex" ? 1 : 2;
|
||||
expect(second.usage).toMatchObject(Object.fromEntries(Object.entries(fixture.tokens!).map(([key, count]) => [key, count * multiplier])));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("process adapter bootstrap accounting proof", () => {
|
||||
const context = (config: Record<string, unknown>): AdapterExecutionContext => ({
|
||||
runId: `bootstrap-${crypto.randomUUID()}`,
|
||||
agent: { id: "agent", companyId: "company", name: "Process", adapterType: "process", adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config, context: {}, onLog: async () => {},
|
||||
});
|
||||
it.each([{}, { command: "/nonexistent-paperclip-accounting-command" },
|
||||
{ command: process.execPath, cwd: "/nonexistent-paperclip-accounting-directory" }])(
|
||||
"proves no provider work for a failure before spawn: %j", async (config) => {
|
||||
const { execute } = await import("../adapters/process/execute.js");
|
||||
const onSpawn = vi.fn();
|
||||
const result = await execute({ ...context(config), onSpawn });
|
||||
expect(result).toMatchObject({ exitCode: 1, executionRecovery: { kind: "bootstrap", providerWorkStarted: false } });
|
||||
expect(result.errorMessage).toBeTruthy();
|
||||
expect(onSpawn).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
it("does not invent bootstrap proof when a spawned process fails", async () => {
|
||||
const { execute } = await import("../adapters/process/execute.js");
|
||||
const onSpawn = vi.fn(async () => { throw new Error("Metadata persistence failed"); });
|
||||
const result = await execute({ ...context({ command: process.execPath, args: ["-e", "process.exit(7)"] }), onSpawn });
|
||||
expect(onSpawn).toHaveBeenCalledOnce();
|
||||
expect(result.exitCode).toBe(7);
|
||||
expect(result.executionRecovery).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { probeAcpxClaudeInstallation, probeAcpxPiInstallation } from "../vendor/paperclip-runner/index.js";
|
||||
import { probeAcpxClaudeInstallation, probeAcpxCursorInstallation } from "@paperclipai/paperclip-runner/live";
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
||||
import { buildSandboxNpmInstallCommand } from "@paperclipai/adapter-utils";
|
||||
import type { ServerAdapterModule } from "../adapters/index.js";
|
||||
@@ -21,9 +21,17 @@ vi.mock("../vendor/paperclip-runner/index.js", async (importOriginal) => ({
|
||||
...await importOriginal<typeof import("../vendor/paperclip-runner/index.js")>(),
|
||||
probeAcpxClaudeInstallation: vi.fn(async () => undefined),
|
||||
probeAcpxGrokInstallation: vi.fn(async () => undefined),
|
||||
probeAcpxPiInstallation: vi.fn(async () => undefined),
|
||||
probeAcpxCursorInstallation: 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 () => ({
|
||||
@@ -320,10 +328,23 @@ describe("server adapter registry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([true, false])("probes the exact Pi installation without qualification opt-in (ready=%s)", async (ready) => {
|
||||
const probe = vi.mocked(probeAcpxPiInstallation);
|
||||
it.each([true, false])("checks ordinary Cursor runtime readiness (%s)", async (ready) => {
|
||||
const probe = vi.mocked(probeAcpxCursorInstallation);
|
||||
if (ready) probe.mockResolvedValueOnce(undefined);
|
||||
else probe.mockRejectedValueOnce(new Error("Pi closure unavailable"));
|
||||
else probe.mockRejectedValueOnce(new Error("Run paperclipai runtime setup cursor"));
|
||||
const model = "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]";
|
||||
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
|
||||
companyId: "company-1", adapterType: "paperclip_runner",
|
||||
config: { provider: "acpx", acpxAgent: "cursor", model },
|
||||
});
|
||||
expect(probe).toHaveBeenLastCalledWith(model);
|
||||
expect(result).toMatchObject({
|
||||
status: ready ? "pass" : "fail",
|
||||
checks: [expect.objectContaining({ code: ready ? "acpx_runtime_ready" : "acpx_runtime_unavailable" })],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the ACPX Pi profile unavailable", async () => {
|
||||
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "paperclip_runner",
|
||||
@@ -335,19 +356,18 @@ describe("server adapter registry", () => {
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: ready ? "pass" : "fail",
|
||||
checks: [{ code: ready ? "acpx_runtime_ready" : "acpx_runtime_unavailable" }],
|
||||
status: "fail",
|
||||
checks: [{ code: "paperclip_runner_acpx_agent_unavailable" }],
|
||||
});
|
||||
expect(probe).toHaveBeenLastCalledWith("openrouter/deepseek/deepseek-v4-flash-0731");
|
||||
});
|
||||
it("reports qualification-only readiness for an exact host-authorized candidate", async () => {
|
||||
const key = "PAPERCLIP_RUNNER_ACPX_QUALIFICATION";
|
||||
const previous = process.env[key];
|
||||
process.env[key] = JSON.stringify([{ agent: "cursor", model: "exact-model" }]);
|
||||
process.env[key] = JSON.stringify([{ agent: "copilot", model: "exact-model" }]);
|
||||
try {
|
||||
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
|
||||
companyId: "company-1", adapterType: "paperclip_runner",
|
||||
config: { provider: "acpx", acpxAgent: "cursor", model: "exact-model" },
|
||||
config: { provider: "acpx", acpxAgent: "copilot", model: "exact-model" },
|
||||
});
|
||||
expect(result).toMatchObject({ status: "warn", checks: [{ code: "acpx_candidate_qualification_only" }] });
|
||||
} finally {
|
||||
@@ -360,8 +380,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");
|
||||
|
||||
@@ -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");
|
||||
@@ -168,6 +166,15 @@ describe("adapter routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the shared implementation available for Dot independently, while honoring adapter-admin disabling", async () => {
|
||||
const options = { getNativeRunnerEnabled: async () => false, getOpenAiDotEnabled: async () => true };
|
||||
const enabled = await request(createApp({}, options)).get("/api/adapters");
|
||||
expect(enabled.body.find((adapter: any) => adapter.type === "paperclip_runner")).toMatchObject({ disabled: false });
|
||||
mockAdapterPluginStore.getDisabledAdapterTypes.mockReturnValue(["paperclip_runner"]);
|
||||
const disabled = await request(createApp({}, options)).get("/api/adapters");
|
||||
expect(disabled.body.find((adapter: any) => adapter.type === "paperclip_runner")).toMatchObject({ disabled: true });
|
||||
});
|
||||
|
||||
it("GET /api/adapters returns correct capabilities for built-in adapters", async () => {
|
||||
const app = createApp();
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ const mockApprovalService = vi.hoisted(() => ({
|
||||
const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
get: vi.fn(async () => ({ defaultEnvironmentId: null })),
|
||||
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
|
||||
getExperimental: vi.fn(async () => ({ enableNativeRunner: false })),
|
||||
getExperimental: vi.fn(async (): Promise<{ enableNativeRunner: boolean; enableOpenAiDot?: boolean }> => ({ enableNativeRunner: false })),
|
||||
}));
|
||||
|
||||
const mockManagedAgentProfileService = vi.hoisted(() => ({
|
||||
@@ -173,7 +173,7 @@ const externalAdapter: ServerAdapterModule = {
|
||||
|
||||
const missingAdapterType = "missing_adapter_validation_test";
|
||||
|
||||
async function createApp() {
|
||||
async function createApp(actorOverride: Record<string, unknown> = {}) {
|
||||
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
@@ -187,6 +187,7 @@ async function createApp() {
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
...actorOverride,
|
||||
};
|
||||
next();
|
||||
});
|
||||
@@ -672,6 +673,7 @@ describe("agent routes adapter validation", () => {
|
||||
});
|
||||
|
||||
it("rejects a new paperclip_runner selection while the rollout flag is off", async () => {
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true });
|
||||
const app = await createApp();
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
@@ -723,6 +725,60 @@ describe("agent routes adapter validation", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["dotAttachmentAccess", "dotWorkspaceAccess", "dotBindingId"])("refuses an agent granting itself the operator-owned Dot setting %s", async key => {
|
||||
const app = await createApp({ type: "agent", agentId: "11111111-1111-4111-8111-111111111111", companyId: "company-1", source: "agent_jwt" });
|
||||
const response = await requestApp(app, baseUrl => request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111").send({ adapterConfig: { [key]: key === "dotBindingId" ? "another-binding" : true } }));
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(403);
|
||||
expect(mockAgentService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
it("allows an operator to configure Dot attachment access separately", async () => {
|
||||
const app = await createApp();
|
||||
const response = await requestApp(app, baseUrl => request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111").send({ adapterConfig: { dotAttachmentAccess: true, dotWorkspaceAccess: false } }));
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ adapterConfig: expect.objectContaining({ dotAttachmentAccess: true, dotWorkspaceAccess: false }) }), expect.anything());
|
||||
});
|
||||
|
||||
it.each(["create", "hire", "convert"])("saves an unpaired Dot configuration independently of the Runner rollout for %s while refusing task admission", async mode => {
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true });
|
||||
const app = await createApp();
|
||||
const config = { provider: "openai_dot", lifecycleMode: "per_turn", allowUnmeteredProvider: true };
|
||||
const response = await requestApp(app, baseUrl => mode === "create"
|
||||
? request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Dot", adapterType: "paperclip_runner", adapterConfig: config })
|
||||
: mode === "hire" ? request(baseUrl).post("/api/companies/company-1/agent-hires").send({ name: "Dot", role: "engineer", adapterType: "paperclip_runner", adapterConfig: config })
|
||||
: request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111").send({ adapterType: "paperclip_runner", replaceAdapterConfig: true, adapterConfig: config }));
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(mode === "convert" ? 200 : 201);
|
||||
const saved = mode === "hire" ? response.body.agent : response.body;
|
||||
expect(saved.adapterConfig).toMatchObject(config);
|
||||
expect(saved.adapterConfig.model).toBeUndefined();
|
||||
expect(saved.adapterConfig.codexPermissionMode).toBeUndefined();
|
||||
const { resolvePaperclipRunnerProviderProfile } = await import("../services/native-runtime/provider-profile.js");
|
||||
expect(() => resolvePaperclipRunnerProviderProfile(saved.adapterConfig)).toThrow(expect.objectContaining({ code: "paperclip_runner_dot_config_invalid" }));
|
||||
});
|
||||
|
||||
it.each(["create", "hire", "convert"])("rejects a new Dot selection when only the general Runner rollout is enabled for %s", async mode => {
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true, enableOpenAiDot: false });
|
||||
const app = await createApp();
|
||||
const body = { name: "Dot", role: "engineer", adapterType: "paperclip_runner", adapterConfig: { provider: "openai_dot", allowUnmeteredProvider: true } };
|
||||
const response = await requestApp(app, baseUrl => mode === "convert"
|
||||
? request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111").send(body)
|
||||
: request(baseUrl).post(`/api/companies/company-1/${mode === "hire" ? "agent-hires" : "agents"}`).send(body));
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(422);
|
||||
expect(response.body.details).toMatchObject({ code: "paperclip_runner_dot_disabled" });
|
||||
expect(mockAgentService.create).not.toHaveBeenCalled();
|
||||
expect(mockAgentService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires the general Runner rollout when changing an existing Dot to Codex", async () => {
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true });
|
||||
mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "paperclip_runner", adapterConfig: { provider: "openai_dot", allowUnmeteredProvider: true } });
|
||||
const app = await createApp();
|
||||
const response = await requestApp(app, baseUrl => request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111")
|
||||
.send({ adapterConfig: { provider: "codex" }, replaceAdapterConfig: true }));
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(422);
|
||||
expect(response.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" });
|
||||
expect(mockAgentService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes legacy skills and permissions when switching to paperclip_runner", async () => {
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
|
||||
const existing = await mockAgentService.getById();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import { Readable } from "node:stream";
|
||||
import { S3Client, PutObjectCommand, HeadObjectCommand, GetObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
|
||||
@@ -8,7 +8,7 @@ import path from "node:path";
|
||||
import express from "express";
|
||||
import type { Server } from "node:http";
|
||||
import sharp from "sharp";
|
||||
import { AGENT_PALETTE_IDS, appearanceForPalette } from "@paperclipai/shared";
|
||||
import { AGENT_PALETTE_IDS, appearanceForPalette, PAPERCLIP_DARK_AVATAR_BACKGROUND } from "@paperclipai/shared";
|
||||
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
|
||||
import { createAgentAvatarService, avatarCacheKey, type AgentAvatarRequest } from "../services/agent-avatars.js";
|
||||
import { createAgentAvatarPool } from "../services/agent-avatar-pool.js";
|
||||
@@ -114,6 +114,37 @@ describe("on-demand agent avatars", () => {
|
||||
const png = await pool.render(request);
|
||||
expect(await sharp(png).metadata()).toMatchObject({ width: 48, height: 48, format: "png", hasAlpha: true });
|
||||
}, 20_000);
|
||||
it("preserves transparent background pixels in ordinary 512px avatars", async () => {
|
||||
const pool = createAgentAvatarPool(1); cleanups.push(() => pool.close());
|
||||
const png = await pool.render({ ...request, size: 512, scale: 1 });
|
||||
const { data, info } = await sharp(png).raw().toBuffer({ resolveWithObject: true });
|
||||
expect(info).toMatchObject({ width: 512, height: 512, channels: 4 });
|
||||
for (const [x, y] of [[0, 0], [511, 0], [0, 511], [511, 511]]) {
|
||||
expect(data[(y * info.width + x) * info.channels + 3]).toBe(0);
|
||||
}
|
||||
expect(data[(256 * info.width + 256) * info.channels + 3]).toBe(255);
|
||||
}, 20_000);
|
||||
it("serves Slack exports on the Paperclip dark-mode color in a separate cache", async () => {
|
||||
const css = await readFile(new URL("../../../ui/src/index.css", import.meta.url), "utf8");
|
||||
const lightness = Number(css.match(/\.dark\s*\{\s*--background:\s*oklch\(([\d.]+) 0 0\)/)?.[1]);
|
||||
// Neutral OKLCH converts to equal sRGB channels; PNG rounds to 8-bit color.
|
||||
const linear = lightness ** 3;
|
||||
const channel = Math.round((linear <= 0.0031308 ? 12.92 * linear : 1.055 * linear ** (1 / 2.4) - 0.055) * 255);
|
||||
expect(PAPERCLIP_DARK_AVATAR_BACKGROUND).toBe(`#${channel.toString(16).padStart(2, "0").repeat(3)}`);
|
||||
const service = createAgentAvatarService(await storage()); cleanups.push(() => service.close());
|
||||
const url = await serve(service);
|
||||
const clear = await fetch(url + "?size=512&scale=1");
|
||||
const dark = await fetch(url + "?size=512&scale=1&background=paperclip-dark");
|
||||
expect(dark.status).toBe(200);
|
||||
expect(dark.headers.get("etag")).not.toBe(clear.headers.get("etag"));
|
||||
const clearPixels = await sharp(Buffer.from(await clear.arrayBuffer())).ensureAlpha().raw().toBuffer();
|
||||
const darkPixels = await sharp(Buffer.from(await dark.arrayBuffer())).ensureAlpha().raw().toBuffer();
|
||||
expect(Array.from(clearPixels.subarray(0, 4))).toEqual([0, 0, 0, 0]);
|
||||
expect(Array.from(darkPixels.subarray(0, 4))).toEqual([channel, channel, channel, 255]);
|
||||
expect(avatarCacheKey({ ...request, background: "paperclip-dark" })).not.toBe(avatarCacheKey(request));
|
||||
const invalid = await fetch(url + "?background=white");
|
||||
expect(invalid.status).toBe(400); await invalid.text();
|
||||
}, 20_000);
|
||||
it("serves public images with content ETags and validates the finite request space", async () => {
|
||||
const render = vi.fn(async () => Buffer.from("png-bytes"));
|
||||
const url = await serve(createAgentAvatarService(await storage(), render));
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,8 @@ import { buildNativeRuntimeContext } from "../services/native-runtime/runtime-co
|
||||
import type { EnvironmentRuntimeService } from "../services/environment-runtime.js";
|
||||
import { remoteTerminationReceipt } from "../services/remote-execution-termination.js";
|
||||
import { AgentDirectoryReuseInvalidatedError, agentDirectoryWorkingCopyService } from "../services/agent-directory-working-copies.js";
|
||||
import { withDirectoryMergeLock } from "@paperclipai/adapter-utils/workspace-restore-merge";
|
||||
import { heartbeatRunEvents } from "@paperclipai/db";
|
||||
|
||||
describe("persistent agent directories", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
@@ -969,4 +971,250 @@ describe("persistent agent directories", () => {
|
||||
expect(await fs.readFile(path.join(root, "note.txt"), "utf8")).toBe("one write");
|
||||
});
|
||||
|
||||
it.each(["prepared", "unavailable", "warm_saved", "superseded", "saved", "unchanged", "resolved"])(
|
||||
"does not acquire a held directory lock for a no-op %s release", async state => {
|
||||
const copy = await run();
|
||||
const stopped = ["superseded", "saved", "unchanged", "resolved"].includes(state);
|
||||
await db.update(agentInstructionWorkingCopies).set({ state, processStoppedAt: stopped ? new Date() : null,
|
||||
receipt: { ...copy.receipt, ...(stopped ? { cleanupPending: false } : {}) } })
|
||||
.where(eq(agentInstructionWorkingCopies.runId, copy.runId));
|
||||
const before = await copies.get(companyId, copy.runId);
|
||||
await withDirectoryMergeLock(path.resolve(copy.localRoot, "../../.."), async () => {
|
||||
// Any attempted nested acquisition fails promptly instead of hanging the test.
|
||||
let now = Date.now();
|
||||
const clock = vi.spyOn(Date, "now").mockImplementation(() => now += 31_000);
|
||||
try { await copies.release(companyId, copy.runId); } finally { clock.mockRestore(); }
|
||||
expect(await copies.get(companyId, copy.runId)).toEqual(before);
|
||||
expect(await fs.readFile(path.join(copy.localRoot, entryFile), "utf8")).toBe(initial);
|
||||
});
|
||||
});
|
||||
|
||||
async function unavailableRemote(options: { state?: "stopped" | "destroyed"; receiptMismatch?: string } = {}) {
|
||||
const copy = await run();
|
||||
const environmentId = randomUUID(), leaseId = randomUUID(), remoteCwd = "/fixture/cleanup-task";
|
||||
const lease = { id: leaseId, companyId, environmentId, heartbeatRunId: copy.runId, provider: "daytona", providerLeaseId: "cleanup-fixture" };
|
||||
await db.insert(environments).values({ id: environmentId, name: environmentId, driver: "sandbox" });
|
||||
const receipt = options.state ? remoteTerminationReceipt(lease, { providerLeaseId: lease.providerLeaseId, state: options.state })! : undefined;
|
||||
if (receipt && options.receiptMismatch) (receipt as Record<string, unknown>)[options.receiptMismatch] = randomUUID();
|
||||
await db.insert(environmentLeases).values({ ...lease, ...(receipt ? { status: "released", releasedAt: new Date(), cleanupStatus: "success",
|
||||
metadata: { remoteExecutionTermination: receipt } } : {}) });
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, copy.runId));
|
||||
await db.update(agentInstructionWorkingCopies).set({ state: "unavailable", errorCode: "INSTRUCTION_COLLECTION_UNAVAILABLE",
|
||||
errorMessage: "Fixture could not collect files", candidateHash: "preserved-candidate", candidateBase64: Buffer.from("preserved bytes").toString("base64"), attempts: 3,
|
||||
location: `remote:${environmentId}`, executionRoot: path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", agentId, copy.runId),
|
||||
receipt: { ...copy.receipt, cleanup: { leaseId, remoteCwd } } }).where(eq(agentInstructionWorkingCopies.runId, copy.runId));
|
||||
return { copy: (await copies.get(companyId, copy.runId))!, lease, environmentId };
|
||||
}
|
||||
|
||||
it.each([undefined, "stopped", "companyId", "runId", "leaseId", "provider", "providerLeaseId"])(
|
||||
"defers unavailable cleanup without exact destruction proof (%s)", async proof => {
|
||||
const { copy } = await unavailableRemote({ state: proof === undefined ? undefined : proof === "stopped" ? "stopped" : "destroyed",
|
||||
receiptMismatch: proof && proof !== "stopped" ? proof : undefined });
|
||||
const execute = vi.fn();
|
||||
copies = agentInstructionWorkingCopyService(db, { environmentRuntime: { execute } as unknown as EnvironmentRuntimeService });
|
||||
const before = Date.now();
|
||||
await withDirectoryMergeLock(path.resolve(copy.localRoot, "../../.."), () => copies.recoverStopped());
|
||||
const deferred = (await copies.get(companyId, copy.runId))!;
|
||||
expect(deferred).toMatchObject({ state: "unavailable", processStoppedAt: null, attempts: 3,
|
||||
candidateHash: copy.candidateHash, candidateBase64: copy.candidateBase64, errorCode: copy.errorCode, errorMessage: copy.errorMessage, receipt: copy.receipt });
|
||||
expect(deferred.nextAttemptAt!.getTime()).toBeGreaterThanOrEqual(before + 30_000);
|
||||
expect(await copies.recoverStopped()).toBe(0);
|
||||
expect(await fs.readFile(path.join(copy.localRoot, entryFile), "utf8")).toBe(initial);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cleans an unavailable copy after exact destruction without claiming a save or executing remotely", async () => {
|
||||
const { copy, environmentId } = await unavailableRemote({ state: "destroyed" });
|
||||
await fs.writeFile(path.join(copy.localRoot, "unsaved.txt"), "unsaved edit");
|
||||
await db.delete(environments).where(eq(environments.id, environmentId));
|
||||
const execute = vi.fn();
|
||||
const shell = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockRejectedValue(new Error("must not execute"));
|
||||
copies = agentInstructionWorkingCopyService(db, { environmentRuntime: { execute } as unknown as EnvironmentRuntimeService });
|
||||
try {
|
||||
await copies.recoverStopped();
|
||||
const recovered = (await copies.get(companyId, copy.runId))!;
|
||||
expect(recovered).toMatchObject({ state: "unavailable", candidateHash: copy.candidateHash, candidateBase64: copy.candidateBase64, attempts: 3,
|
||||
errorCode: copy.errorCode, errorMessage: copy.errorMessage, receipt: { cleanupPending: false } });
|
||||
expect(recovered.processStoppedAt).toBeInstanceOf(Date);
|
||||
expect(recovered.nextAttemptAt).toBeNull();
|
||||
await expect(fs.stat(copy.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(fs.stat(path.join(root, "unsaved.txt"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
|
||||
expect(execute).not.toHaveBeenCalled(); expect(shell).not.toHaveBeenCalled();
|
||||
} finally { shell.mockRestore(); }
|
||||
});
|
||||
|
||||
it.each([false, true])("preserves uncollected local edits and their unavailable receipt even after local stop proof (%s)", async stopped => {
|
||||
const copy = await run();
|
||||
await fs.writeFile(path.join(copy.localRoot, "unsaved-local.txt"), "recoverable local edit");
|
||||
await db.update(heartbeatRuns).set({ status: "failed", runtimeMode: "native" }).where(eq(heartbeatRuns.id, copy.runId));
|
||||
await copies.reportUnavailable(companyId, copy.runId);
|
||||
if (stopped) await db.insert(heartbeatRunEvents).values({ companyId, runId: copy.runId, agentId, seq: 1, eventType: "native.local_process_stopped", stream: "system", level: "info", message: "fixture stop" });
|
||||
const before = (await copies.get(companyId, copy.runId))!;
|
||||
expect(before).toMatchObject({ state: "unavailable", attempts: 0, processStoppedAt: null });
|
||||
await copies.recoverStopped();
|
||||
const patch = vi.fn(async () => { throw new Error("local copy must not be changed"); });
|
||||
await agentDirectoryWorkingCopyService(db, copies.get, patch).recoverUnavailable(before);
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
expect(await copies.get(companyId, copy.runId)).toEqual(before);
|
||||
expect(await fs.readFile(path.join(copy.localRoot, "unsaved-local.txt"), "utf8")).toBe("recoverable local edit");
|
||||
await expect(fs.stat(path.join(root, "unsaved-local.txt"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("serializes same-run preparation after recovery commits stop proof and before it removes bytes", async () => {
|
||||
const { copy, lease } = await unavailableRemote({ state: "destroyed" });
|
||||
const remoteCwd = "/fixture/cleanup-task";
|
||||
const executionTarget = { kind: "remote" as const, transport: "ssh" as const, environmentId: lease.environmentId, leaseId: lease.id, remoteCwd,
|
||||
spec: { host: "unused.invalid", port: 22, username: "test", remoteCwd } };
|
||||
const shell = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "" });
|
||||
const stage = vi.spyOn(ssh, "syncDirectoryToSsh").mockResolvedValue(undefined);
|
||||
let stopCommitted!: () => void, finishCleanup!: () => void;
|
||||
const committed = new Promise<void>(resolve => { stopCommitted = resolve; });
|
||||
const proceed = new Promise<void>(resolve => { finishCleanup = resolve; });
|
||||
const directories = agentDirectoryWorkingCopyService(db, copies.get, async (row, values) => {
|
||||
const [updated] = await db.update(agentInstructionWorkingCopies).set(values).where(eq(agentInstructionWorkingCopies.runId, row.runId)).returning();
|
||||
if (values.processStoppedAt) { stopCommitted(); await proceed; }
|
||||
return updated!;
|
||||
});
|
||||
const recovering = directories.recoverUnavailable(copy);
|
||||
await committed;
|
||||
let prepareSettled = false;
|
||||
const preparing = copies.prepare({ ...target(), runId: copy.runId, cwd: home, target: executionTarget }).then(row => { prepareSettled = true; return row; });
|
||||
try {
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(prepareSettled).toBe(false);
|
||||
expect((await copies.get(companyId, copy.runId))?.state).toBe("unavailable");
|
||||
} finally { finishCleanup(); await recovering; }
|
||||
const prepared = await preparing;
|
||||
expect(prepared).toMatchObject({ state: "prepared", processStoppedAt: null });
|
||||
expect(await fs.readFile(path.join(copy.localRoot, entryFile), "utf8")).toBe(initial);
|
||||
// A stale cleanup callback now observes the live lifecycle and does nothing.
|
||||
await directories.release(copy);
|
||||
expect(await fs.readFile(path.join(copy.localRoot, entryFile), "utf8")).toBe(initial);
|
||||
} finally { shell.mockRestore(); stage.mockRestore(); }
|
||||
});
|
||||
|
||||
it("keeps stopped pending cleanup behind a held lock and lets recovery proceed to other agents", async () => {
|
||||
const blocked = await run();
|
||||
await db.update(agentInstructionWorkingCopies).set({ state: "unavailable", processStoppedAt: new Date() }).where(eq(agentInstructionWorkingCopies.runId, blocked.runId));
|
||||
// A distinct canonical agent has its own lifecycle lock.
|
||||
const firstAgent = agentId, firstRoot = root;
|
||||
agentId = randomUUID();
|
||||
root = resolveManagedInstructionsRoot({ ...target(), id: agentId, name: "Other", adapterConfig: {} });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Other", adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root, instructionsEntryFile: entryFile } });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "agent", principalId: agentId, membershipRole: "member" });
|
||||
await db.update(principalPermissionGrants).set({ scope: { agentIds: [firstAgent, agentId] } }).where(eq(principalPermissionGrants.companyId, companyId));
|
||||
await fs.mkdir(path.dirname(path.join(root, entryFile)), { recursive: true }); await fs.writeFile(path.join(root, entryFile), initial);
|
||||
const other = await run();
|
||||
await db.update(agentInstructionWorkingCopies).set({ state: "unavailable", processStoppedAt: new Date() }).where(eq(agentInstructionWorkingCopies.runId, other.runId));
|
||||
agentId = firstAgent; root = firstRoot;
|
||||
await withDirectoryMergeLock(path.resolve(blocked.localRoot, "../../.."), async () => {
|
||||
let now = Date.now();
|
||||
const clock = vi.spyOn(Date, "now").mockImplementation(() => now += 31_000);
|
||||
try {
|
||||
await expect(copies.release(companyId, blocked.runId)).rejects.toMatchObject({ code: "ERR_WORKSPACE_RESTORE_LOCK_TIMEOUT" });
|
||||
await copies.recoverCaptured();
|
||||
} finally { clock.mockRestore(); }
|
||||
expect((await copies.get(companyId, blocked.runId))?.nextAttemptAt).toBeInstanceOf(Date);
|
||||
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 => {
|
||||
const { copy, lease } = await unavailableRemote({ state: "destroyed" });
|
||||
if (mismatch === "environment") await db.update(agentInstructionWorkingCopies).set({ location: `remote:${randomUUID()}` }).where(eq(agentInstructionWorkingCopies.runId, copy.runId));
|
||||
if (mismatch === "path") await db.update(agentInstructionWorkingCopies).set({ executionRoot: "/fixture/foreign" }).where(eq(agentInstructionWorkingCopies.runId, copy.runId));
|
||||
if (mismatch === "lease-run") {
|
||||
const other = await run();
|
||||
const moved = { ...lease, heartbeatRunId: other.runId };
|
||||
await db.update(environmentLeases).set({ heartbeatRunId: other.runId, metadata: { remoteExecutionTermination: remoteTerminationReceipt(moved, { providerLeaseId: moved.providerLeaseId, state: "destroyed" }) } }).where(eq(environmentLeases.id, lease.id));
|
||||
}
|
||||
await copies.recoverStopped();
|
||||
expect((await copies.get(companyId, copy.runId))?.processStoppedAt).toBeNull();
|
||||
expect(await fs.readFile(path.join(copy.localRoot, entryFile), "utf8")).toBe(initial);
|
||||
});
|
||||
|
||||
it("backs off an unproven batch so the next unavailable copy can be cleaned", async () => {
|
||||
const deferred: string[] = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const { copy } = await unavailableRemote();
|
||||
deferred.push(copy.runId);
|
||||
await db.update(agentInstructionWorkingCopies).set({ updatedAt: new Date(i) }).where(eq(agentInstructionWorkingCopies.runId, copy.runId));
|
||||
}
|
||||
const { copy: ready } = await unavailableRemote({ state: "destroyed" });
|
||||
await db.update(agentInstructionWorkingCopies).set({ updatedAt: new Date(20) }).where(eq(agentInstructionWorkingCopies.runId, ready.runId));
|
||||
await copies.recoverStopped();
|
||||
expect((await copies.get(companyId, ready.runId))?.processStoppedAt).toBeNull();
|
||||
await copies.recoverStopped();
|
||||
expect((await copies.get(companyId, ready.runId))?.receipt?.cleanupPending).toBe(false);
|
||||
for (const runId of deferred) expect(await copies.get(companyId, runId)).toMatchObject({ state: "unavailable", processStoppedAt: null, attempts: 3 });
|
||||
});
|
||||
|
||||
it("continues cleanup when both retry-reservation writes fail for the first copy", async () => {
|
||||
const first = await run(), second = await run();
|
||||
for (const [index, copy] of [first, second].entries()) {
|
||||
await db.update(agentInstructionWorkingCopies).set({ state: "unavailable", processStoppedAt: new Date(), updatedAt: new Date(index),
|
||||
errorCode: "INSTRUCTION_COLLECTION_UNAVAILABLE", candidateHash: "preserved", candidateBase64: Buffer.from("candidate").toString("base64") })
|
||||
.where(eq(agentInstructionWorkingCopies.runId, copy.runId));
|
||||
}
|
||||
const before = await copies.get(companyId, first.runId);
|
||||
const failReservation = () => { throw new Error("fixture retry-reservation write failure"); };
|
||||
const update = vi.spyOn(db, "update").mockImplementationOnce(failReservation).mockImplementationOnce(failReservation);
|
||||
try { await copies.recoverCaptured(); } finally { update.mockRestore(); }
|
||||
expect(await copies.get(companyId, first.runId)).toEqual(before);
|
||||
expect(await fs.readFile(path.join(first.localRoot, entryFile), "utf8")).toBe(initial);
|
||||
expect(await copies.get(companyId, second.runId)).toMatchObject({ state: "unavailable", errorCode: "INSTRUCTION_COLLECTION_UNAVAILABLE", candidateHash: "preserved", receipt: { cleanupPending: false } });
|
||||
await expect(fs.stat(second.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it.each([false, true])("does not execute a cached transport after destruction, including an ambiguous stop-proof commit (%s)", async interrupted => {
|
||||
const runId = randomUUID(), environmentId = randomUUID(), leaseId = randomUUID(), remoteCwd = "/fixture/cached-task";
|
||||
const lease = { id: leaseId, companyId, environmentId, heartbeatRunId: runId, provider: "daytona", providerLeaseId: "cached-fixture" };
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
|
||||
await db.insert(environments).values({ id: environmentId, name: environmentId, driver: "sandbox" });
|
||||
await db.insert(environmentLeases).values(lease);
|
||||
const execute = vi.fn(), restoreWorkspace = vi.fn(), cleanupWorkspaceSnapshot = vi.fn().mockResolvedValue(undefined);
|
||||
const executionTarget = { kind: "remote" as const, transport: "sandbox" as const, environmentId, leaseId, remoteCwd, runner: { execute } };
|
||||
const shell = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "" });
|
||||
const stage = vi.spyOn(executionTargetTools, "prepareAdapterExecutionTargetRuntime").mockImplementation(async () => ({ target: executionTarget,
|
||||
workspaceRemoteDir: path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", agentId, runId), runtimeRootDir: null,
|
||||
assetDirs: {}, additionalSourceDirs: {}, additionalSourceFailures: [], workspaceSyncSnapshot: null, restoreWorkspace, cleanupWorkspaceSnapshot }));
|
||||
try {
|
||||
const copy = (await copies.prepare({ ...target(), runId, cwd: home, target: executionTarget }))!;
|
||||
await copies.reportUnavailable(companyId, runId);
|
||||
await copies.release(companyId, runId);
|
||||
expect(cleanupWorkspaceSnapshot).not.toHaveBeenCalled();
|
||||
shell.mockClear(); stage.mockClear();
|
||||
await db.update(heartbeatRuns).set({ status: "failed" }).where(eq(heartbeatRuns.id, runId));
|
||||
await db.update(environmentLeases).set({ status: "released", releasedAt: new Date(), cleanupStatus: "success",
|
||||
metadata: { remoteExecutionTermination: remoteTerminationReceipt(lease, { providerLeaseId: lease.providerLeaseId, state: "destroyed" }) } }).where(eq(environmentLeases.id, leaseId));
|
||||
if (interrupted) {
|
||||
const directories = agentDirectoryWorkingCopyService(db, copies.get, async (row, values) => {
|
||||
const [updated] = await db.update(agentInstructionWorkingCopies).set(values).where(eq(agentInstructionWorkingCopies.runId, row.runId)).returning();
|
||||
if (values.processStoppedAt) throw new Error("fixture lost update response after commit");
|
||||
return updated!;
|
||||
});
|
||||
await expect(directories.recoverUnavailable((await copies.get(companyId, runId))!)).rejects.toThrow("lost update response");
|
||||
expect((await copies.get(companyId, runId))?.receipt?.cleanupDestroyedOnly).toBe(true);
|
||||
await copies.recoverCaptured();
|
||||
} else await copies.recoverStopped();
|
||||
expect(await copies.get(companyId, runId)).toMatchObject({ state: "unavailable", errorCode: "INSTRUCTION_COLLECTION_UNAVAILABLE", receipt: { cleanupPending: false } });
|
||||
await expect(fs.stat(copy.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(cleanupWorkspaceSnapshot).toHaveBeenCalledOnce();
|
||||
expect(execute).not.toHaveBeenCalled(); expect(restoreWorkspace).not.toHaveBeenCalled(); expect(shell).not.toHaveBeenCalled(); expect(stage).not.toHaveBeenCalled();
|
||||
} finally { shell.mockRestore(); stage.mockRestore(); }
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as codexAdapter from "@paperclipai/adapter-codex-local/server";
|
||||
import { WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE } from "@paperclipai/adapter-utils/workspace-restore-merge";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
@@ -6,8 +8,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, closeRegisteredClients, 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,8 +18,19 @@ 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";
|
||||
import { waitForPendingRunFailureReports } from "../services/run-failure-report.js";
|
||||
|
||||
const captureRunFailure = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../sentry.js", async (importOriginal) => ({
|
||||
...await importOriginal<typeof import("../sentry.js")>(),
|
||||
captureRunFailure,
|
||||
}));
|
||||
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
const externalTestDatabaseUrl = process.env.PAPERCLIP_TEST_DATABASE_URL?.trim();
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let home: string;
|
||||
|
||||
@@ -25,11 +38,18 @@ beforeAll(async () => {
|
||||
home = await mkdtemp(path.join(os.tmpdir(), "paperclip-hire-ai-"));
|
||||
vi.stubEnv("PAPERCLIP_HOME", home);
|
||||
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "hire-ai");
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-hire-ai-db-");
|
||||
db = createDb(database.connectionString);
|
||||
vi.stubEnv("PAPERCLIP_IN_WORKTREE", "false");
|
||||
if (externalTestDatabaseUrl) db = createDb(externalTestDatabaseUrl);
|
||||
else {
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-hire-ai-db-");
|
||||
db = createDb(database.connectionString);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await waitForPendingRunFailureReports();
|
||||
if (externalTestDatabaseUrl) await closeRegisteredClients(externalTestDatabaseUrl);
|
||||
await db?.$client.end();
|
||||
await database?.cleanup();
|
||||
vi.unstubAllEnvs();
|
||||
if (home) await rm(home, { recursive: true, force: true });
|
||||
@@ -67,7 +87,227 @@ 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");
|
||||
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "agents:configure" });
|
||||
const original = getServerAdapter(f.adapterType);
|
||||
registerServerAdapter({ ...original, testEnvironment: async () => ({ adapterType: f.adapterType, status: "pass", checks: [], testedAt: new Date().toISOString() }) });
|
||||
const network = vi.spyOn(globalThis, "fetch");
|
||||
if (status === null) network.mockRejectedValue(new Error("Network unavailable"));
|
||||
else network.mockResolvedValue(new Response(null, { status }));
|
||||
try {
|
||||
const response = operation === "test"
|
||||
? await request(f.app).post(`/api/companies/${f.companyId}/adapters/${f.adapterType}/test-environment`).send({ agentId: f.agentId, aiConnection: f.binding, adapterConfig: {} })
|
||||
: await request(f.app).patch(`/api/agents/${f.agentId}`).send({ adapterConfig: { model: "changed-model" } });
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(operation === "test" ? 200 : 422);
|
||||
const rejected = status === 401 || status === 403;
|
||||
expect(await aiConnectionService(db).list(f.companyId, f.userId)).toEqual([expect.objectContaining({ status: rejected ? "needs_attention" : "connected" })]);
|
||||
} finally { network.mockRestore(); unregisterServerAdapter(f.adapterType); }
|
||||
});
|
||||
}
|
||||
|
||||
it.each(["test", "save"] as const)("%s marks a hello-test authentication rejection as needing attention and reconnect repairs the same default", async (operation) => {
|
||||
const f = await fixture("anthropic", "subscription");
|
||||
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "agents:configure" });
|
||||
const original = getServerAdapter(f.adapterType);
|
||||
registerServerAdapter({ ...original, testEnvironment: async () => ({
|
||||
adapterType: f.adapterType, status: "fail", testedAt: new Date().toISOString(),
|
||||
checks: [{ code: "claude_hello_probe_auth_required", level: "error", message: "The account needs sign-in." }],
|
||||
}) });
|
||||
try {
|
||||
const response = operation === "test"
|
||||
? await request(f.app).post(`/api/companies/${f.companyId}/adapters/${f.adapterType}/test-environment`).send({ agentId: f.agentId, aiConnection: f.binding, adapterConfig: {} })
|
||||
: await request(f.app).patch(`/api/agents/${f.agentId}`).send({ adapterConfig: { model: "changed-model" } });
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(operation === "test" ? 200 : 422);
|
||||
if (operation === "test") expect(response.body.status).toBe("fail");
|
||||
const service = aiConnectionService(db);
|
||||
expect(await service.list(f.companyId, f.userId)).toEqual([expect.objectContaining({ id: f.account.connectionId, isDefault: true, status: "needs_attention" })]);
|
||||
await expect(service.select({ companyId: f.companyId, userId: f.userId, agentId: f.agentId, adapterType: f.adapterType, binding: f.binding })).rejects.toThrow("Reconnect");
|
||||
const repaired = await service.save(f.companyId, f.userId, {
|
||||
provider: "anthropic", method: "subscription", name: "Ignored reconnect name", ownership: "personal",
|
||||
connectionId: f.account.connectionId, allAgents: true, agentIds: [], loginSessionId: "fixture",
|
||||
}, "repaired-token");
|
||||
expect(repaired).toEqual(f.account);
|
||||
expect(await service.list(f.companyId, f.userId)).toEqual([expect.objectContaining({ id: f.account.connectionId, isDefault: true, status: "connected" })]);
|
||||
const installs = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, f.account.connectionId));
|
||||
expect(installs).toEqual([expect.objectContaining({ targetType: "agent", targetId: f.agentId })]);
|
||||
registerServerAdapter({ ...original, testEnvironment: async () => ({
|
||||
adapterType: f.adapterType, status: "pass", testedAt: new Date().toISOString(),
|
||||
checks: [{ code: "claude_hello_probe_passed", level: "info", message: "hello" }],
|
||||
}) });
|
||||
const saved = await request(f.app).patch(`/api/agents/${f.agentId}`).send({ adapterConfig: { model: "changed-model" } });
|
||||
expect(saved.status, JSON.stringify(saved.body)).toBe(200);
|
||||
const runtime = await prepareManagedAiRuntime(db, { companyId: f.companyId, agentId: f.agentId, responsibleUserId: f.userId, adapterType: f.adapterType, binding: f.binding, config: saved.body.adapterConfig });
|
||||
try {
|
||||
expect(runtime.attribution.grantId).toBe(f.account.grantId);
|
||||
expect((runtime.config.env as Record<string, string>).CLAUDE_CODE_OAUTH_TOKEN).toBe("repaired-token");
|
||||
} finally { await runtime.cleanup(); }
|
||||
} finally { unregisterServerAdapter(f.adapterType); }
|
||||
});
|
||||
|
||||
it.each([false, true])("a failed environment test preserves connection health for a runtime failure or a newer reconnect (reconnected: %s)", async (reconnected) => {
|
||||
const f = await fixture("anthropic", "subscription");
|
||||
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "agents:configure" });
|
||||
const original = getServerAdapter(f.adapterType);
|
||||
registerServerAdapter({ ...original, testEnvironment: async () => {
|
||||
if (reconnected) await aiConnectionService(db).save(f.companyId, f.userId, {
|
||||
provider: "anthropic", method: "subscription", name: "My Claude", ownership: "personal",
|
||||
connectionId: f.account.connectionId, allAgents: false, agentIds: [f.agentId], loginSessionId: "fixture",
|
||||
}, "newer-token");
|
||||
return {
|
||||
adapterType: f.adapterType, status: "fail", testedAt: new Date().toISOString(),
|
||||
checks: [{ code: reconnected ? "claude_hello_probe_auth_required" : "claude_cli_not_found", level: "error", message: "Test failed." }],
|
||||
};
|
||||
} });
|
||||
try {
|
||||
const response = await request(f.app).post(`/api/companies/${f.companyId}/adapters/${f.adapterType}/test-environment`).send({ agentId: f.agentId, aiConnection: f.binding, adapterConfig: {} });
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
||||
expect(response.body.status).toBe("fail");
|
||||
expect(await aiConnectionService(db).list(f.companyId, f.userId)).toEqual([expect.objectContaining({ status: "connected" })]);
|
||||
} finally { unregisterServerAdapter(f.adapterType); }
|
||||
});
|
||||
|
||||
for (const endpoint of ["agent-hires", "agents"]) {
|
||||
it.each([
|
||||
["anthropic", "api_key"], ["anthropic", "subscription"],
|
||||
@@ -89,15 +329,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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -145,13 +385,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" });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -242,9 +486,54 @@ describe("agent-created hires use managed AI connections", () => {
|
||||
});
|
||||
|
||||
describe("hired agents sharing a subscription", () => {
|
||||
it("keeps a credential-lock timeout on automatic retry without blocking the task or starting a provider", async () => {
|
||||
const f = await fixture("openai", "subscription");
|
||||
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Waiting teammate", role: "engineer", adapterType: f.adapterType, reportsTo: f.agentId, adapterConfig: { engine: "cli" }, runtimeConfig: { heartbeat: { enabled: false } } }));
|
||||
// Only an operator may set host execution paths after the agent is hired.
|
||||
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: "Wait for credential rotation", status: "todo", assigneeAgentId: agent.id, responsibleUserId: f.userId, createdByUserId: f.userId }).returning();
|
||||
const execute = vi.fn(async () => ({ exitCode: 0, signal: null, timedOut: false, resultJson: {} }));
|
||||
registerServerAdapter({ ...getServerAdapter(f.adapterType), execute });
|
||||
const lock = vi.spyOn(codexAdapter, "withAccountHomeSecretMutationLock")
|
||||
.mockRejectedValueOnce(Object.assign(new Error("quota refresh holds company lock"), { code: WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE }));
|
||||
const heartbeat = heartbeatService(db);
|
||||
try {
|
||||
const run = await heartbeat.invoke(agent.id, "assignment", { issueId: issue.id, wakeReason: "issue_assigned", responsibleUserId: f.userId }, "system");
|
||||
expect(run).not.toBeNull();
|
||||
await expect.poll(async () => (await heartbeat.getRun(run!.id))?.status, { timeout: 20_000 }).toBe("cancelled");
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
const cancelled = await heartbeat.getRun(run!.id);
|
||||
expect(cancelled).toMatchObject({ errorCode: "ai_connection_busy", resultJson: {
|
||||
executionRecovery: { kind: "ai_connection_wait", providerWorkStarted: false },
|
||||
cancellation: {
|
||||
source: "control_plane", expected: true, initiator: { type: "system" },
|
||||
reason: "Waiting for shared AI credentials",
|
||||
recordedAt: cancelled!.finishedAt!.toISOString(),
|
||||
},
|
||||
} });
|
||||
await waitForPendingRunFailureReports();
|
||||
expect(captureRunFailure.mock.calls.filter(([report]) => report.runId === run!.id)).toHaveLength(0);
|
||||
const retries = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, run!.id));
|
||||
expect(retries).toHaveLength(1);
|
||||
expect(retries[0]).toMatchObject({ status: "scheduled_retry", scheduledRetryReason: "ai_connection_busy" });
|
||||
expect(retries[0].resultJson?.cancellation).toBeUndefined();
|
||||
const [savedIssue] = await db.select().from(issues).where(eq(issues.id, issue.id));
|
||||
expect(savedIssue.status).not.toBe("blocked");
|
||||
expect(savedIssue.executionRunId).toBe(retries[0].id);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
expect(await aiConnectionService(db).list(f.companyId, f.userId)).toEqual([expect.objectContaining({ status: "connected" })]);
|
||||
} finally {
|
||||
lock.mockRestore();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
unregisterServerAdapter(f.adapterType);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -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***");
|
||||
});
|
||||
});
|
||||
@@ -221,6 +221,20 @@ describe("registered run instruction copies", () => {
|
||||
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
|
||||
});
|
||||
|
||||
it("preserves the specific low-trust denial in the instruction-save receipt", async () => {
|
||||
const copy = await run();
|
||||
await fs.writeFile(path.join(copy.localRoot, entryFile), "Unauthorized persistent edit");
|
||||
await db.update(agents).set({ permissions: {
|
||||
trustPreset: "low_trust_review",
|
||||
authorizationPolicy: { trustBoundary: { mode: "low_trust_review", companyId, rootIssueId: randomUUID() } },
|
||||
} }).where(eq(agents.id, agentId));
|
||||
const denied = await copies.collectStopped({ companyId, runId: copy.runId });
|
||||
expect(denied?.state).toBe("conflict");
|
||||
expect(denied?.errorMessage).toContain("This low-trust run cannot change persistent agent instructions, including AGENTS.md");
|
||||
expect(denied?.candidateBase64).not.toBeNull();
|
||||
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
|
||||
});
|
||||
|
||||
it("does not import a symlink or confuse a removed entry with an empty file", async () => {
|
||||
const linked = await run();
|
||||
await fs.unlink(path.join(linked.localRoot, entryFile));
|
||||
|
||||
@@ -211,7 +211,7 @@ function makeReflectionCoachAgent(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
describe("agent instructions bundle routes", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/agents.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
@@ -288,7 +288,13 @@ describe("agent instructions bundle routes", () => {
|
||||
instructionsFilePath: "/tmp/agent-1/AGENTS.md",
|
||||
},
|
||||
});
|
||||
});
|
||||
// Cold route compilation belongs to fixture setup. Timing it as a request
|
||||
// let a timed-out import resume under the next case's reset mocks.
|
||||
await Promise.all([
|
||||
vi.importActual("../routes/agents.js"),
|
||||
vi.importActual("../middleware/index.js"),
|
||||
]);
|
||||
}, 60_000);
|
||||
|
||||
it("returns bundle metadata", async () => {
|
||||
const res = await requestApp(
|
||||
|
||||
@@ -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,
|
||||
@@ -348,6 +354,8 @@ describe("agent live run routes", () => {
|
||||
mockHeartbeatService.getRunLogAccess.mockResolvedValue({
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
scopeKind: "company",
|
||||
issueId: null,
|
||||
logStore: "local_file",
|
||||
logRef: "logs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.ndjson",
|
||||
});
|
||||
@@ -592,6 +600,8 @@ describe("agent live run routes", () => {
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
scopeKind: "company",
|
||||
issueId: null,
|
||||
logStore: "local_file",
|
||||
logRef: "logs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.ndjson",
|
||||
},
|
||||
@@ -681,7 +691,8 @@ describe("agent live run routes", () => {
|
||||
lastOutputStream: null,
|
||||
lastOutputBytes: 0,
|
||||
processStartedAt: null,
|
||||
issueId: "issue-1",
|
||||
scopeKind: "company",
|
||||
issueId: null,
|
||||
}));
|
||||
const { db, limit } = createLiveRunsDbStub(rows);
|
||||
|
||||
@@ -725,7 +736,8 @@ describe("agent live run routes", () => {
|
||||
lastOutputStream: null,
|
||||
lastOutputBytes: 0,
|
||||
processStartedAt: null,
|
||||
issueId: "issue-1",
|
||||
scopeKind: "company",
|
||||
issueId: null,
|
||||
}));
|
||||
const { db, limit } = createLiveRunsDbStub(rows);
|
||||
|
||||
@@ -768,7 +780,8 @@ describe("agent live run routes", () => {
|
||||
lastOutputStream: null,
|
||||
lastOutputBytes: 0,
|
||||
processStartedAt: null,
|
||||
issueId: "issue-1",
|
||||
scopeKind: "company",
|
||||
issueId: null,
|
||||
}));
|
||||
|
||||
const selectCalls: Array<ReturnType<typeof vi.fn>> = [];
|
||||
@@ -828,7 +841,8 @@ describe("agent live run routes", () => {
|
||||
lastOutputStream: null,
|
||||
lastOutputBytes: 0,
|
||||
processStartedAt: null,
|
||||
issueId: "issue-1",
|
||||
scopeKind: "company",
|
||||
issueId: null,
|
||||
}));
|
||||
const recentRows = Array.from({ length: 4 }, (_, index) => ({
|
||||
id: `run-recent-${index}`,
|
||||
@@ -855,7 +869,8 @@ describe("agent live run routes", () => {
|
||||
lastOutputStream: null,
|
||||
lastOutputBytes: 0,
|
||||
processStartedAt: null,
|
||||
issueId: "issue-1",
|
||||
scopeKind: "company",
|
||||
issueId: null,
|
||||
}));
|
||||
|
||||
let selectCallCount = 0;
|
||||
@@ -966,6 +981,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 +1240,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",
|
||||
@@ -1040,7 +1251,7 @@ describe.sequential("agent permission routes", () => {
|
||||
expect.objectContaining({
|
||||
status: "idle",
|
||||
}),
|
||||
{ claudeLogin: { storedSessionId: null, ownerUserId: "agent-admin-user", applyExistingWithoutClaim: false } },
|
||||
{ createdByUserId: "agent-admin-user", claudeLogin: { storedSessionId: null, ownerUserId: "agent-admin-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
@@ -1170,7 +1381,7 @@ describe.sequential("agent permission routes", () => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
{ createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1206,7 +1417,7 @@ describe.sequential("agent permission routes", () => {
|
||||
model: DEFAULT_OPENCODE_LOCAL_MODEL,
|
||||
}),
|
||||
}),
|
||||
{ claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
{ createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1244,7 +1455,7 @@ describe.sequential("agent permission routes", () => {
|
||||
model: "anthropic/claude-sonnet-4-5",
|
||||
}),
|
||||
}),
|
||||
{ claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
{ createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1284,6 +1495,7 @@ describe.sequential("agent permission routes", () => {
|
||||
},
|
||||
}),
|
||||
{
|
||||
createdByUserId: "board-user",
|
||||
claudeLogin: {
|
||||
storedSessionId: null,
|
||||
ownerUserId: "board-user",
|
||||
@@ -1514,7 +1726,7 @@ describe.sequential("agent permission routes", () => {
|
||||
expect.objectContaining({
|
||||
defaultEnvironmentId: environmentId,
|
||||
}),
|
||||
{ claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
{ createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1600,7 +1812,7 @@ describe.sequential("agent permission routes", () => {
|
||||
adapterType: adapterCase.adapterType,
|
||||
defaultEnvironmentId: environmentId,
|
||||
}),
|
||||
{ claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
{ createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
@@ -1000,7 +1005,7 @@ describe.sequential("agent skill routes", () => {
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
{ claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } },
|
||||
{ createdByUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
expect(mockTrackAgentCreated).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
@@ -1050,7 +1055,7 @@ describe.sequential("agent skill routes", () => {
|
||||
expect.objectContaining({
|
||||
role: "security",
|
||||
}),
|
||||
{ claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } },
|
||||
{ createdByUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
expect(mockTrackAgentCreated).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1480,6 +1491,7 @@ describe.sequential("agent skill routes", () => {
|
||||
}),
|
||||
}),
|
||||
{
|
||||
createdByUserId: "local-board",
|
||||
claudeLogin: {
|
||||
storedSessionId: null,
|
||||
ownerUserId: "local-board",
|
||||
|
||||
@@ -110,12 +110,21 @@ const mockPrepareManagedAiRuntime = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../services/ai-connection-runtime.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../services/ai-connection-runtime.js")>()),
|
||||
prepareManagedAiRuntime: mockPrepareManagedAiRuntime,
|
||||
withManagedAiProbe: async (_db: unknown, input: unknown, probe: (runtime: unknown) => Promise<unknown>) => {
|
||||
const runtime = await mockPrepareManagedAiRuntime(_db, input);
|
||||
try { return await probe(runtime); } finally { await runtime.cleanup(); }
|
||||
},
|
||||
}));
|
||||
const mockValidateAiApiKey = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
vi.mock("../routes/ai-connections.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../routes/ai-connections.js")>()),
|
||||
validateAiApiKey: mockValidateAiApiKey,
|
||||
}));
|
||||
const mockMarkAuthenticationFailed = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
vi.mock("../services/ai-connections.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../services/ai-connections.js")>()),
|
||||
aiConnectionService: () => ({ markAuthenticationFailed: mockMarkAuthenticationFailed }),
|
||||
}));
|
||||
|
||||
function mockManagedRuntime(method: "api_key" | "subscription") {
|
||||
mockPrepareManagedAiRuntime.mockImplementation(
|
||||
@@ -451,7 +460,10 @@ describe("agent test-environment route", () => {
|
||||
|
||||
it("fails adoption of an api_key connection the provider no longer accepts", async () => {
|
||||
mockManagedRuntime("api_key");
|
||||
mockValidateAiApiKey.mockRejectedValueOnce(Object.assign(new Error("The provider rejected this API key."), { status: 422 }));
|
||||
const { unprocessable } = await import("../errors.js");
|
||||
mockValidateAiApiKey.mockRejectedValueOnce(unprocessable("The provider rejected this API key.", {
|
||||
code: "ai_connection_api_key_rejected",
|
||||
}));
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
||||
@@ -462,6 +474,10 @@ describe("agent test-environment route", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe("fail");
|
||||
expect(res.body.checks.map((check: { code: string }) => check.code)).toContain("ai_connection_api_key_rejected");
|
||||
expect(mockMarkAuthenticationFailed).toHaveBeenCalledWith(expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
attribution: expect.objectContaining({ connectionId: "conn-1", grantId: "grant-1" }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("still fails subscription adoption when no hello probe can run", async () => {
|
||||
|
||||
@@ -24,6 +24,43 @@ const message = (extra = {}) =>
|
||||
...extra,
|
||||
});
|
||||
describe("AgentMail protocol boundary", () => {
|
||||
it("checks the visible inbox list without probing an uncreated address", async () => {
|
||||
const fetcher = vi.fn(async () => Response.json({ inboxes: [{ inbox_id: "Ralph@agentmail.to" }] }));
|
||||
await expect(agentmailApi("private-key", fetcher).checkAddress("ralph@agentmail.to"))
|
||||
.resolves.toEqual({ address: "ralph@agentmail.to", status: "taken" });
|
||||
expect(fetcher).toHaveBeenCalledOnce();
|
||||
expect(fetcher).toHaveBeenCalledWith("https://api.agentmail.to/v0/inboxes?limit=100", expect.objectContaining({ method: "GET", body: undefined }));
|
||||
});
|
||||
it("never treats an unlisted inbox as proof that an address is available", async () => {
|
||||
const fetcher = vi.fn(async () => Response.json({ inboxes: [], next_page_token: "more-inboxes" }));
|
||||
await expect(agentmailApi("private-key", fetcher).checkAddress("ralph@agentmail.to"))
|
||||
.resolves.toEqual({ address: "ralph@agentmail.to", status: "unknown" });
|
||||
});
|
||||
it("can create the checked address and its access key when the provider caches missing inbox lookups", async () => {
|
||||
let created = false;
|
||||
let negativeLookupCached = false;
|
||||
const fetcher = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const path = new URL(String(url)).pathname;
|
||||
if (path === "/v0/inboxes" && init?.method === "GET") return Response.json({ inboxes: [] });
|
||||
if (path === "/v0/inboxes" && init?.method === "POST") {
|
||||
created = true;
|
||||
return Response.json({ inbox_id: "ralph@agentmail.to" });
|
||||
}
|
||||
if (path === "/v0/inboxes/ralph%40agentmail.to" && !created) negativeLookupCached = true;
|
||||
if (!created || negativeLookupCached) return Response.json({ code: "not_found" }, { status: 404 });
|
||||
return Response.json({ api_key: "test-runtime-key", api_key_id: "runtime-id" });
|
||||
});
|
||||
const api = agentmailApi("test-account-key", fetcher);
|
||||
await expect(api.checkAddress("ralph@agentmail.to")).resolves.toMatchObject({ status: "unknown" });
|
||||
const inbox = await api.createInbox({ username: "ralph" });
|
||||
await expect(api.createInboxKey(inbox.inbox_id)).resolves.toMatchObject({ api_key_id: "runtime-id" });
|
||||
expect(negativeLookupCached).toBe(false);
|
||||
});
|
||||
it.each([401, 403, 404, 429, 503])("preserves lookup errors (%s) rather than claiming an address is taken or free", async status => {
|
||||
const fetcher = vi.fn(async () => Response.json({ code: "missing_permission" }, { status }));
|
||||
await expect(agentmailApi("private-key", fetcher).checkAddress("ralph@agentmail.to"))
|
||||
.rejects.toMatchObject({ status, operation: "list_inboxes" });
|
||||
});
|
||||
it("verifies the exact raw body and rejects forged or stale Svix signatures", () => {
|
||||
const secret = `whsec_${Buffer.from("a-test-secret-only").toString("base64")}`;
|
||||
const body = JSON.stringify({
|
||||
@@ -322,6 +359,10 @@ describe("AgentMail protocol boundary", () => {
|
||||
expect(send?.responses).toHaveProperty("202");
|
||||
const setup = operations.find(o => o.path === "/api/companies/{companyId}/email/inspect");
|
||||
expect(JSON.stringify(setup?.authorization)).toContain("board");
|
||||
const check = operations.find(o => o.path === "/api/companies/{companyId}/email/connections/{connectionId}/check-address");
|
||||
expect(check?.requestBody).toBeDefined();
|
||||
expect(JSON.stringify(check?.authorization)).toContain("board");
|
||||
expect(check?.responses).toHaveProperty("429");
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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,55 @@
|
||||
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", consumerId: "failed-agent", responsibleUserId: "new-teammate",
|
||||
};
|
||||
const failure = (missingBindings: unknown, reason = "secret_binding_missing") => ({
|
||||
errorCode: "configuration_incomplete",
|
||||
resultJson: { configurationIncomplete: { reason, agentId: "failed-agent", 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" }]],
|
||||
...["project", "environment", "routine", "plugin"].map(consumerType => [
|
||||
`${consumerType} binding`, [{ ...missingClaudeKey, consumerType }],
|
||||
]),
|
||||
["different agent", [{ ...missingClaudeKey, consumerId: "another-agent" }]],
|
||||
["missing consumer", [{ ...missingClaudeKey, consumerType: undefined, consumerId: undefined }]],
|
||||
["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);
|
||||
expect(aiBindingForAuthRecovery("claude_local", {}, failure(bindings))).toBeUndefined();
|
||||
});
|
||||
|
||||
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,450 @@
|
||||
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, closeRegisteredClients, 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";
|
||||
import { waitForPendingRunFailureReports } from "../services/run-failure-report.js";
|
||||
|
||||
const captureRunFailure = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../sentry.js", async (importOriginal) => ({
|
||||
...await importOriginal<typeof import("../sentry.js")>(),
|
||||
captureRunFailure,
|
||||
}));
|
||||
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
const externalTestDatabaseUrl = process.env.PAPERCLIP_TEST_DATABASE_URL?.trim();
|
||||
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");
|
||||
if (externalTestDatabaseUrl) db = createDb(externalTestDatabaseUrl);
|
||||
else { 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 waitForPendingRunFailureReports();
|
||||
if (externalTestDatabaseUrl) await closeRegisteredClients(externalTestDatabaseUrl);
|
||||
await db?.$client.end();
|
||||
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(cancelled?.resultJson).toMatchObject({
|
||||
executionRecovery: { kind: "ai_connection_wait", providerWorkStarted: false },
|
||||
cancellation: {
|
||||
source: "control_plane", expected: true, initiator: { type: "system" },
|
||||
reason: "Waiting for an AI connection pool account",
|
||||
recordedAt: cancelled!.finishedAt!.toISOString(),
|
||||
},
|
||||
});
|
||||
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")));
|
||||
await waitForPendingRunFailureReports();
|
||||
expect(captureRunFailure.mock.calls.filter(([report]) => report.runId === run!.id)).toHaveLength(0);
|
||||
expect(retry?.resultJson?.cancellation).toBeUndefined();
|
||||
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("keeps unexpected pool policy failures reportable before provider work", 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: "Unexpected router failure", 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: "Router failure fixture", status: "todo", assigneeAgentId: agent!.id, responsibleUserId: "alice", createdByUserId: "alice" }).returning();
|
||||
const execute = vi.fn(); registerServerAdapter({ ...getServerAdapter("claude_local"), execute });
|
||||
proposal.mockRejectedValueOnce(new Error("Synthetic unexpected router policy failure"));
|
||||
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("failed");
|
||||
const failed = await heartbeat.getRun(run!.id);
|
||||
expect(failed?.errorCode).toBe("configuration_incomplete");
|
||||
expect(failed?.resultJson).toMatchObject({ configurationIncomplete: { reason: "ai_connection_unavailable" } });
|
||||
expect(failed?.resultJson?.cancellation).toBeUndefined();
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
await expect.poll(() => captureRunFailure.mock.calls.filter(([report]) => report.runId === run!.id)).toHaveLength(1);
|
||||
expect(captureRunFailure).toHaveBeenCalledWith(expect.objectContaining({ runId: run!.id, runStatus: "failed", errorCode: "configuration_incomplete" }));
|
||||
} finally { 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([]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,10 @@ const mockAccessService = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/authorization.js", async (importActual) => ({
|
||||
...await importActual<typeof import("../services/authorization.js")>(),
|
||||
canActorReadApproval: async () => true,
|
||||
}));
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { approvalService } from "../services/approvals.ts";
|
||||
import { companies } from "@paperclipai/db";
|
||||
|
||||
vi.mock("../services/budgets.js", () => ({
|
||||
budgetService: () => ({ deliverPendingEnforcement: vi.fn(async () => {}) }),
|
||||
budgetServiceInTransaction: () => ({ upsertPolicy: vi.fn(async () => {}) }),
|
||||
}));
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
activatePendingApproval: vi.fn(),
|
||||
@@ -24,6 +30,7 @@ type ApprovalRecord = {
|
||||
status: string;
|
||||
payload: Record<string, unknown>;
|
||||
requestedByAgentId: string | null;
|
||||
requestedByUserId?: string | null;
|
||||
};
|
||||
|
||||
function createApproval(status: string): ApprovalRecord {
|
||||
@@ -39,8 +46,11 @@ function createApproval(status: string): ApprovalRecord {
|
||||
|
||||
function createDbStub(selectResults: ApprovalRecord[][], updateResults: ApprovalRecord[]) {
|
||||
const pendingSelectResults = [...selectResults];
|
||||
const selectWhere = vi.fn(async () => pendingSelectResults.shift() ?? []);
|
||||
const from = vi.fn(() => ({ where: selectWhere }));
|
||||
let inTransaction = false;
|
||||
const selectWhere = vi.fn(async () => (inTransaction ? pendingSelectResults.shift() : pendingSelectResults[0]) ?? []);
|
||||
const from = vi.fn((table) => table === companies
|
||||
? { where: () => ({ for: async () => [{ id: "company-1" }] }) }
|
||||
: { where: selectWhere });
|
||||
const select = vi.fn(() => ({ from }));
|
||||
|
||||
const returning = vi.fn(async () => updateResults);
|
||||
@@ -49,7 +59,10 @@ 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>) => {
|
||||
inTransaction = true;
|
||||
try { return await callback({ select, update }); } finally { inTransaction = false; }
|
||||
}) },
|
||||
selectWhere,
|
||||
returning,
|
||||
};
|
||||
@@ -105,9 +118,25 @@ describe("approvalService resolution idempotency", () => {
|
||||
expect(mockNotifyHireApproved).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("creates the agent from payload when approval does not reference a pending agent", async () => {
|
||||
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.each([
|
||||
{ requestedByAgentId: "requester-1", requestedByUserId: "on-behalf-user", expectedCreator: null },
|
||||
{ requestedByAgentId: null, requestedByUserId: "original-creator", expectedCreator: "original-creator" },
|
||||
])("creates a legacy approved hire with its original human attribution ($expectedCreator)", async ({ requestedByAgentId, requestedByUserId, expectedCreator }) => {
|
||||
const approved = {
|
||||
...createApproval("approved"),
|
||||
requestedByAgentId,
|
||||
requestedByUserId,
|
||||
payload: {
|
||||
name: "New Agent",
|
||||
adapterConfig: {
|
||||
@@ -132,6 +161,7 @@ describe("approvalService resolution idempotency", () => {
|
||||
expect.objectContaining({
|
||||
adapterConfig: approved.payload.adapterConfig,
|
||||
}),
|
||||
{ createdByUserId: expectedCreator },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,7 +103,7 @@ async function createApp(storage: ReturnType<typeof createStorageService>) {
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", assetRoutes({} as any, storage));
|
||||
app.use("/api", assetRoutes({ select: () => ({ from: () => ({ leftJoin: () => ({ where: async () => [] }), where: async () => [] }) }) } as any, storage));
|
||||
return app;
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +172,9 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
createdAt?: Date;
|
||||
unblockDescriptor?: { owner: { userId: string } | "board"; action: string } | null;
|
||||
blockedTransitionAt?: Date | null;
|
||||
visibility?: "open" | "private";
|
||||
privacyRootIssueId?: string | null;
|
||||
responsibleUserId?: string | null;
|
||||
harnessKind?: string | null;
|
||||
reviewPolicy?: "anyone" | "not_creator" | "human_only" | null;
|
||||
}) {
|
||||
@@ -195,6 +198,9 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
executionState: input.executionState ?? null,
|
||||
unblockDescriptor: input.unblockDescriptor ?? null,
|
||||
blockedTransitionAt: input.blockedTransitionAt ?? null,
|
||||
visibility: input.visibility ?? "open",
|
||||
privacyRootIssueId: input.privacyRootIssueId ?? null,
|
||||
responsibleUserId: input.responsibleUserId ?? null,
|
||||
harnessKind: input.harnessKind ?? null,
|
||||
createdAt: input.createdAt,
|
||||
updatedAt: input.updatedAt,
|
||||
@@ -2040,6 +2046,35 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
await request(app(agent)).get(`/api/companies/${companyId}/attention`).expect(403);
|
||||
});
|
||||
|
||||
it("omits private issue review items for a non-member board user", async () => {
|
||||
const { companyId } = await seedCompany("ATP");
|
||||
const privateIssueId = randomUUID();
|
||||
await insertIssue({
|
||||
id: privateIssueId,
|
||||
companyId,
|
||||
identifier: "ATP-1",
|
||||
title: "Confidential review",
|
||||
status: "in_review",
|
||||
assigneeUserId: "private-owner",
|
||||
executionState: pendingUserExecutionState("private-owner"),
|
||||
visibility: "private",
|
||||
privacyRootIssueId: privateIssueId,
|
||||
responsibleUserId: "private-owner",
|
||||
});
|
||||
|
||||
const actor = {
|
||||
type: "board" as const,
|
||||
source: "session" as const,
|
||||
userId: "non-member",
|
||||
companyIds: [companyId],
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
const feed = await attentionService(db).list(companyId, { userId: actor.userId, actor });
|
||||
|
||||
expect(feed.items.map((item) => item.subject.title)).not.toContain("Confidential review");
|
||||
expect(JSON.stringify(feed)).not.toContain(privateIssueId);
|
||||
});
|
||||
|
||||
it("computes the aging shelf uniformly across approval, interaction, and review sources", async () => {
|
||||
const { companyId, workerId } = await seedCompany("AGE");
|
||||
const now = Date.parse("2026-08-02T12:00:00.000Z");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
authUsers,
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
companyMemberships,
|
||||
createDb,
|
||||
instanceUserRoles,
|
||||
issueAccessGrants,
|
||||
issueComments,
|
||||
issues,
|
||||
principalPermissionGrants,
|
||||
projectAccessMembers,
|
||||
projects,
|
||||
userInboxAgentPolicies,
|
||||
} from "@paperclipai/db";
|
||||
@@ -19,7 +21,13 @@ import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { authorizationService } from "../services/authorization.js";
|
||||
import {
|
||||
authorizationService,
|
||||
canActorReadIssuePrivacy,
|
||||
issuePrivacyMode,
|
||||
type AuthorizationActor,
|
||||
} from "../services/authorization.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
@@ -56,12 +64,18 @@ async function createAgent(
|
||||
.then((rows) => rows[0]!);
|
||||
}
|
||||
|
||||
async function createProject(db: ReturnType<typeof createDb>, companyId: string, label: string) {
|
||||
async function createProject(
|
||||
db: ReturnType<typeof createDb>,
|
||||
companyId: string,
|
||||
label: string,
|
||||
visibility: "open" | "private" = "open",
|
||||
) {
|
||||
return db
|
||||
.insert(projects)
|
||||
.values({
|
||||
companyId,
|
||||
name: `Project ${label} ${randomUUID()}`,
|
||||
visibility,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
@@ -76,6 +90,12 @@ async function createIssue(
|
||||
projectId?: string | null;
|
||||
parentId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
responsibleUserId?: string | null;
|
||||
visibility?: "open" | "private";
|
||||
privacyRootIssueId?: string | null;
|
||||
privacyParentIssueId?: string | null;
|
||||
originKind?: string | null;
|
||||
originId?: string | null;
|
||||
} = {},
|
||||
@@ -91,6 +111,12 @@ async function createIssue(
|
||||
projectId: input.projectId ?? null,
|
||||
parentId: input.parentId ?? null,
|
||||
assigneeAgentId: input.assigneeAgentId ?? null,
|
||||
assigneeUserId: input.assigneeUserId ?? null,
|
||||
createdByUserId: input.createdByUserId ?? null,
|
||||
responsibleUserId: input.responsibleUserId ?? null,
|
||||
visibility: input.visibility ?? "open",
|
||||
privacyRootIssueId: input.privacyRootIssueId ?? null,
|
||||
privacyParentIssueId: input.privacyParentIssueId ?? null,
|
||||
originKind: input.originKind ?? "manual",
|
||||
originId: input.originId ?? null,
|
||||
})
|
||||
@@ -167,6 +193,21 @@ describeEmbeddedPostgres("authorization service", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
it("defaults issue privacy to enforce while retaining explicit rollout overrides", () => {
|
||||
const previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
try {
|
||||
delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
expect(issuePrivacyMode()).toBe("enforce");
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "shadow";
|
||||
expect(issuePrivacyMode()).toBe("shadow");
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "off";
|
||||
expect(issuePrivacyMode()).toBe("off");
|
||||
} finally {
|
||||
if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode;
|
||||
}
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-authorization-service-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
@@ -178,6 +219,7 @@ describeEmbeddedPostgres("authorization service", () => {
|
||||
await db.delete(principalPermissionGrants);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(instanceUserRoles);
|
||||
await db.delete(issueAccessGrants);
|
||||
await db.delete(issues);
|
||||
await db.delete(agents);
|
||||
await db.delete(projects);
|
||||
@@ -2606,6 +2648,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);
|
||||
@@ -2706,6 +2786,199 @@ describeEmbeddedPostgres("authorization service", () => {
|
||||
})).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" });
|
||||
});
|
||||
|
||||
it("enforces private issue implicit principals, grants, revocation, and subtree inheritance", async () => {
|
||||
const previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
const previousTtl = process.env.PAPERCLIP_ISSUE_PRIVACY_CACHE_TTL_MS;
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_CACHE_TTL_MS = "5000";
|
||||
try {
|
||||
const company = await createCompany(db, "IssuePrivacy");
|
||||
const ownerId = await createUser(db);
|
||||
const memberId = await createUser(db);
|
||||
const granteeId = await createUser(db);
|
||||
await db.insert(companyMemberships).values([ownerId, memberId, granteeId].map((principalId) => ({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId,
|
||||
status: "active",
|
||||
membershipRole: "operator",
|
||||
})));
|
||||
const assignedAgent = await createAgent(db, company.id);
|
||||
const otherAgent = await createAgent(db, company.id);
|
||||
const authz = authorizationService(db);
|
||||
const userActor = (userId: string): AuthorizationActor => ({ type: "board", userId, source: "session" });
|
||||
const agentActor = (agentId: string): AuthorizationActor => ({
|
||||
type: "agent",
|
||||
agentId,
|
||||
companyId: company.id,
|
||||
source: "agent_key",
|
||||
});
|
||||
const decide = (actor: AuthorizationActor, issue: Awaited<ReturnType<typeof createIssue>>) => authz.decide({
|
||||
actor,
|
||||
action: "issue:read",
|
||||
resource: { type: "issue", companyId: company.id, issueId: issue.id },
|
||||
});
|
||||
|
||||
const openIssue = await createIssue(db, company.id);
|
||||
await expect(decide(userActor(memberId), openIssue)).resolves.toMatchObject({ allowed: true });
|
||||
|
||||
const privateRootId = randomUUID();
|
||||
const privateRoot = await createIssue(db, company.id, {
|
||||
id: privateRootId,
|
||||
visibility: "private",
|
||||
privacyRootIssueId: privateRootId,
|
||||
responsibleUserId: ownerId,
|
||||
assigneeAgentId: assignedAgent.id,
|
||||
});
|
||||
await expect(decide(userActor(memberId), privateRoot)).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_issue_private",
|
||||
});
|
||||
await expect(decide(agentActor(otherAgent.id), privateRoot)).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_issue_private",
|
||||
});
|
||||
await expect(decide(userActor(ownerId), privateRoot)).resolves.toMatchObject({ allowed: true });
|
||||
await expect(decide(agentActor(assignedAgent.id), privateRoot)).resolves.toMatchObject({ allowed: true });
|
||||
|
||||
const grant = await db.insert(issueAccessGrants).values({
|
||||
issueId: privateRoot.id,
|
||||
subjectType: "user",
|
||||
subjectId: granteeId,
|
||||
source: "explicit",
|
||||
grantedByUserId: ownerId,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await expect(decide(userActor(granteeId), privateRoot)).resolves.toMatchObject({ allowed: true });
|
||||
|
||||
const child = await createIssue(db, company.id, {
|
||||
parentId: privateRoot.id,
|
||||
privacyParentIssueId: privateRoot.id,
|
||||
visibility: "private",
|
||||
privacyRootIssueId: privateRoot.id,
|
||||
});
|
||||
await expect(decide(userActor(granteeId), child)).resolves.toMatchObject({ allowed: true });
|
||||
|
||||
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id));
|
||||
await expect(decide(userActor(granteeId), privateRoot)).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_issue_private",
|
||||
});
|
||||
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "shadow";
|
||||
const warn = vi.spyOn(logger, "warn").mockImplementation(() => logger);
|
||||
await expect(decide(userActor(memberId), privateRoot)).resolves.toMatchObject({ allowed: true });
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authzMode: "shadow",
|
||||
issueId: privateRoot.id,
|
||||
reason: "deny_issue_private",
|
||||
}),
|
||||
"issue privacy would deny read",
|
||||
);
|
||||
warn.mockRestore();
|
||||
} finally {
|
||||
if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode;
|
||||
if (previousTtl === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_CACHE_TTL_MS;
|
||||
else process.env.PAPERCLIP_ISSUE_PRIVACY_CACHE_TTL_MS = previousTtl;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses private-project membership as the same issue-read predicate while preserving issue grants", async () => {
|
||||
const previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
||||
try {
|
||||
const company = await createCompany(db, "ProjectPrivacy");
|
||||
const memberId = await createUser(db);
|
||||
const granteeId = await createUser(db);
|
||||
const outsiderId = await createUser(db);
|
||||
await db.insert(companyMemberships).values([memberId, granteeId, outsiderId].map((principalId) => ({
|
||||
companyId: company.id,
|
||||
principalType: "user" as const,
|
||||
principalId,
|
||||
status: "active",
|
||||
membershipRole: "operator",
|
||||
})));
|
||||
const project = await createProject(db, company.id, "Private", "private");
|
||||
const memberAgent = await createAgent(db, company.id);
|
||||
const outsiderAgent = await createAgent(db, company.id);
|
||||
await db.insert(companyMemberships).values([memberAgent.id, outsiderAgent.id].map((principalId) => ({
|
||||
companyId: company.id,
|
||||
principalType: "agent" as const,
|
||||
principalId,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
})));
|
||||
await db.insert(projectAccessMembers).values({
|
||||
companyId: company.id,
|
||||
projectId: project.id,
|
||||
subjectType: "user",
|
||||
subjectId: memberId,
|
||||
});
|
||||
await db.insert(projectAccessMembers).values({
|
||||
companyId: company.id,
|
||||
projectId: project.id,
|
||||
subjectType: "agent",
|
||||
subjectId: memberAgent.id,
|
||||
});
|
||||
const issue = await createIssue(db, company.id, { projectId: project.id });
|
||||
await db.insert(issueAccessGrants).values({
|
||||
issueId: issue.id,
|
||||
subjectType: "user",
|
||||
subjectId: granteeId,
|
||||
source: "explicit",
|
||||
grantedByUserId: memberId,
|
||||
});
|
||||
const authz = authorizationService(db);
|
||||
const decideProject = (userId: string) => authz.decide({
|
||||
actor: { type: "board", userId, source: "session" },
|
||||
action: "project:read",
|
||||
resource: { type: "project", companyId: company.id, projectId: project.id },
|
||||
});
|
||||
const decideIssue = (userId: string) => authz.decide({
|
||||
actor: { type: "board", userId, source: "session" },
|
||||
action: "issue:read",
|
||||
resource: { type: "issue", companyId: company.id, issueId: issue.id },
|
||||
});
|
||||
const decideProjectAsAgent = (agentId: string) => authz.decide({
|
||||
actor: { type: "agent", agentId, companyId: company.id, source: "agent_key" },
|
||||
action: "project:read",
|
||||
resource: { type: "project", companyId: company.id, projectId: project.id },
|
||||
});
|
||||
const decideIssueAsAgent = (agentId: string) => authz.decide({
|
||||
actor: { type: "agent", agentId, companyId: company.id, source: "agent_key" },
|
||||
action: "issue:read",
|
||||
resource: { type: "issue", companyId: company.id, issueId: issue.id },
|
||||
});
|
||||
|
||||
await expect(decideProject(memberId)).resolves.toMatchObject({ allowed: true });
|
||||
await expect(decideProject(outsiderId)).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_project_private",
|
||||
});
|
||||
await expect(decideIssue(memberId)).resolves.toMatchObject({ allowed: true });
|
||||
await expect(decideIssue(granteeId)).resolves.toMatchObject({ allowed: true });
|
||||
await expect(decideIssue(outsiderId)).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_issue_private",
|
||||
});
|
||||
await expect(decideProjectAsAgent(memberAgent.id)).resolves.toMatchObject({ allowed: true });
|
||||
await expect(decideIssueAsAgent(memberAgent.id)).resolves.toMatchObject({ allowed: true });
|
||||
await expect(decideProjectAsAgent(outsiderAgent.id)).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_project_private",
|
||||
});
|
||||
await expect(decideIssueAsAgent(outsiderAgent.id)).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_issue_private",
|
||||
});
|
||||
} finally {
|
||||
if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
it("denies secrets proposals for scoped tokens regardless of request source", async () => {
|
||||
const company = await createCompany(db, "SecretProposalScopes");
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
@@ -22,308 +23,8 @@ vi.mock("../services/activity-log.js", () => ({
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
type SelectResult = unknown[];
|
||||
|
||||
function createDbStub(selectResults: SelectResult[]) {
|
||||
const pendingSelects = [...selectResults];
|
||||
const selectWhere = vi.fn(async () => pendingSelects.shift() ?? []);
|
||||
const selectThen = vi.fn((resolve: (value: unknown[]) => unknown) => Promise.resolve(resolve(pendingSelects.shift() ?? [])));
|
||||
const selectOrderBy = vi.fn(async () => pendingSelects.shift() ?? []);
|
||||
const selectFrom = vi.fn(() => ({
|
||||
where: selectWhere,
|
||||
then: selectThen,
|
||||
orderBy: selectOrderBy,
|
||||
}));
|
||||
const select = vi.fn(() => ({
|
||||
from: selectFrom,
|
||||
}));
|
||||
|
||||
const insertValues = vi.fn();
|
||||
const insertReturning = vi.fn(async () => pendingInserts.shift() ?? []);
|
||||
const insert = vi.fn(() => ({
|
||||
values: insertValues.mockImplementation(() => ({
|
||||
returning: insertReturning,
|
||||
})),
|
||||
}));
|
||||
|
||||
const updateSet = vi.fn();
|
||||
const updateWhere = vi.fn(async () => pendingUpdates.shift() ?? []);
|
||||
const update = vi.fn(() => ({
|
||||
set: updateSet.mockImplementation(() => ({
|
||||
where: updateWhere,
|
||||
})),
|
||||
}));
|
||||
|
||||
const pendingInserts: unknown[][] = [];
|
||||
const pendingUpdates: unknown[][] = [];
|
||||
|
||||
return {
|
||||
db: {
|
||||
select,
|
||||
insert,
|
||||
update,
|
||||
},
|
||||
queueInsert: (rows: unknown[]) => {
|
||||
pendingInserts.push(rows);
|
||||
},
|
||||
queueUpdate: (rows: unknown[] = []) => {
|
||||
pendingUpdates.push(rows);
|
||||
},
|
||||
selectWhere,
|
||||
insertValues,
|
||||
updateSet,
|
||||
};
|
||||
}
|
||||
|
||||
describe("budgetService", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("creates a hard-stop incident and pauses an agent when spend exceeds a budget", async () => {
|
||||
const policy = {
|
||||
id: "policy-1",
|
||||
companyId: "company-1",
|
||||
scopeType: "agent",
|
||||
scopeId: "agent-1",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 100,
|
||||
warnPercent: 80,
|
||||
hardStopEnabled: true,
|
||||
notifyEnabled: false,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const dbStub = createDbStub([
|
||||
[policy],
|
||||
[{ total: 150 }],
|
||||
[],
|
||||
[{
|
||||
companyId: "company-1",
|
||||
name: "Budget Agent",
|
||||
status: "running",
|
||||
pauseReason: null,
|
||||
}],
|
||||
]);
|
||||
|
||||
dbStub.queueInsert([{
|
||||
id: "approval-1",
|
||||
companyId: "company-1",
|
||||
status: "pending",
|
||||
}]);
|
||||
dbStub.queueInsert([{
|
||||
id: "incident-1",
|
||||
companyId: "company-1",
|
||||
policyId: "policy-1",
|
||||
approvalId: "approval-1",
|
||||
}]);
|
||||
dbStub.queueUpdate([]);
|
||||
const cancelWorkForScope = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const service = budgetService(dbStub.db as any, { cancelWorkForScope });
|
||||
await service.evaluateCostEvent({
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
projectId: null,
|
||||
} as any);
|
||||
|
||||
expect(dbStub.insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
type: "budget_override_required",
|
||||
status: "pending",
|
||||
}),
|
||||
);
|
||||
expect(dbStub.insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
policyId: "policy-1",
|
||||
thresholdType: "hard",
|
||||
amountLimit: 100,
|
||||
amountObserved: 150,
|
||||
approvalId: "approval-1",
|
||||
}),
|
||||
);
|
||||
expect(dbStub.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: "paused",
|
||||
pauseReason: "budget",
|
||||
pausedAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "budget.hard_threshold_crossed",
|
||||
entityId: "incident-1",
|
||||
}),
|
||||
);
|
||||
expect(cancelWorkForScope).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
scopeType: "agent",
|
||||
scopeId: "agent-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks new work when an agent hard-stop remains exceeded even if the agent is not paused yet", async () => {
|
||||
const agentPolicy = {
|
||||
id: "policy-agent-1",
|
||||
companyId: "company-1",
|
||||
scopeType: "agent",
|
||||
scopeId: "agent-1",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 100,
|
||||
warnPercent: 80,
|
||||
hardStopEnabled: true,
|
||||
notifyEnabled: true,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const dbStub = createDbStub([
|
||||
[{
|
||||
status: "running",
|
||||
pauseReason: null,
|
||||
companyId: "company-1",
|
||||
name: "Budget Agent",
|
||||
}],
|
||||
[{
|
||||
status: "active",
|
||||
name: "Paperclip",
|
||||
}],
|
||||
[],
|
||||
[agentPolicy],
|
||||
[{ total: 120 }],
|
||||
]);
|
||||
|
||||
const service = budgetService(dbStub.db as any);
|
||||
const block = await service.getInvocationBlock("company-1", "agent-1");
|
||||
|
||||
expect(block).toEqual({
|
||||
scopeType: "agent",
|
||||
scopeId: "agent-1",
|
||||
scopeName: "Budget Agent",
|
||||
reason: "Agent cannot start because its budget hard-stop is still exceeded.",
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces a budget-owned company pause distinctly from a manual pause", async () => {
|
||||
const dbStub = createDbStub([
|
||||
[{
|
||||
status: "idle",
|
||||
pauseReason: null,
|
||||
companyId: "company-1",
|
||||
name: "Budget Agent",
|
||||
}],
|
||||
[{
|
||||
status: "paused",
|
||||
pauseReason: "budget",
|
||||
name: "Paperclip",
|
||||
}],
|
||||
]);
|
||||
|
||||
const service = budgetService(dbStub.db as any);
|
||||
const block = await service.getInvocationBlock("company-1", "agent-1");
|
||||
|
||||
expect(block).toEqual({
|
||||
scopeType: "company",
|
||||
scopeId: "company-1",
|
||||
scopeName: "Paperclip",
|
||||
reason: "Company is paused because its budget hard-stop was reached.",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses live observed spend when raising a budget incident", async () => {
|
||||
const dbStub = createDbStub([
|
||||
[{
|
||||
id: "incident-1",
|
||||
companyId: "company-1",
|
||||
policyId: "policy-1",
|
||||
amountObserved: 120,
|
||||
approvalId: "approval-1",
|
||||
}],
|
||||
[{
|
||||
id: "policy-1",
|
||||
companyId: "company-1",
|
||||
scopeType: "company",
|
||||
scopeId: "company-1",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
}],
|
||||
[{ total: 150 }],
|
||||
]);
|
||||
|
||||
const service = budgetService(dbStub.db as any);
|
||||
|
||||
await expect(
|
||||
service.resolveIncident(
|
||||
"company-1",
|
||||
"incident-1",
|
||||
{ action: "raise_budget_and_resume", amount: 140 },
|
||||
"board-user",
|
||||
),
|
||||
).rejects.toThrow("New budget must exceed current observed spend");
|
||||
});
|
||||
|
||||
it("syncs company monthly budget when raising and resuming a company incident", async () => {
|
||||
const now = new Date();
|
||||
const dbStub = createDbStub([
|
||||
[{
|
||||
id: "incident-1",
|
||||
companyId: "company-1",
|
||||
policyId: "policy-1",
|
||||
scopeType: "company",
|
||||
scopeId: "company-1",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
windowStart: now,
|
||||
windowEnd: now,
|
||||
thresholdType: "hard",
|
||||
amountLimit: 100,
|
||||
amountObserved: 120,
|
||||
status: "open",
|
||||
approvalId: "approval-1",
|
||||
resolvedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}],
|
||||
[{
|
||||
id: "policy-1",
|
||||
companyId: "company-1",
|
||||
scopeType: "company",
|
||||
scopeId: "company-1",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 100,
|
||||
}],
|
||||
[{ total: 120 }],
|
||||
[{ id: "approval-1", status: "approved" }],
|
||||
[{
|
||||
companyId: "company-1",
|
||||
name: "Paperclip",
|
||||
status: "paused",
|
||||
pauseReason: "budget",
|
||||
pausedAt: now,
|
||||
}],
|
||||
]);
|
||||
|
||||
const service = budgetService(dbStub.db as any);
|
||||
await service.resolveIncident(
|
||||
"company-1",
|
||||
"incident-1",
|
||||
{ action: "raise_budget_and_resume", amount: 175 },
|
||||
"board-user",
|
||||
);
|
||||
|
||||
expect(dbStub.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
budgetMonthlyCents: 175,
|
||||
updatedAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
// Budget behavior is exercised against PostgreSQL below and in
|
||||
// cost-accounting-reliability.test.ts, including transaction and retry paths.
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
@@ -488,7 +189,7 @@ describeEmbeddedPostgres("budgetService release gate enforcement", () => {
|
||||
expect(agentAfterHardStop).toMatchObject({ status: "paused", pauseReason: "budget" });
|
||||
expect(agentAfterHardStop?.pausedAt).toBeInstanceOf(Date);
|
||||
expect(cancelWorkForScope).toHaveBeenCalledTimes(2);
|
||||
expect(cancelWorkForScope).toHaveBeenCalledWith({ companyId, scopeType: "agent", scopeId: agentId });
|
||||
expect(cancelWorkForScope).toHaveBeenCalledWith({ companyId, scopeType: "agent", scopeId: agentId, createdBefore: expect.any(Date), enforcement: { policyId: expect.any(String), version: expect.any(Number) } });
|
||||
|
||||
const block = await service.getInvocationBlock(companyId, agentId);
|
||||
expect(block).toEqual({
|
||||
@@ -533,6 +234,23 @@ describeEmbeddedPostgres("budgetService release gate enforcement", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("distinguishes a company budget pause from a manual company pause", async () => {
|
||||
const { companyId, agentId } = await createBudgetFixture();
|
||||
const service = budgetService(db);
|
||||
await db.update(companies).set({ status: "paused", pauseReason: "budget" }).where(eq(companies.id, companyId));
|
||||
expect(await service.getInvocationBlock(companyId, agentId)).toMatchObject({
|
||||
scopeType: "company",
|
||||
scopeId: companyId,
|
||||
reason: "Budget pause requires a policy or an explicit operator resume.",
|
||||
});
|
||||
await db.update(companies).set({ pauseReason: null }).where(eq(companies.id, companyId));
|
||||
expect(await service.getInvocationBlock(companyId, agentId)).toMatchObject({
|
||||
scopeType: "company",
|
||||
scopeId: companyId,
|
||||
reason: "Company is paused and cannot start new work.",
|
||||
});
|
||||
});
|
||||
|
||||
it("hard-stops project work until a valid budget raise resumes it and overview reconciles ledger spend", async () => {
|
||||
const { companyId, agentId, projectId } = await createBudgetFixture();
|
||||
const cancelWorkForScope = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -573,7 +291,7 @@ describeEmbeddedPostgres("budgetService release gate enforcement", () => {
|
||||
.from(projects);
|
||||
expect(projectAfterHardStop?.pauseReason).toBe("budget");
|
||||
expect(projectAfterHardStop?.pausedAt).toBeInstanceOf(Date);
|
||||
expect(cancelWorkForScope).toHaveBeenCalledWith({ companyId, scopeType: "project", scopeId: projectId });
|
||||
expect(cancelWorkForScope).toHaveBeenCalledWith({ companyId, scopeType: "project", scopeId: projectId, createdBefore: expect.any(Date), enforcement: { policyId: expect.any(String), version: expect.any(Number) } });
|
||||
|
||||
const overviewWhileBlocked = await service.overview(companyId);
|
||||
expect(overviewWhileBlocked.pausedProjectCount).toBe(1);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,13 +1,15 @@
|
||||
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";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { issues, heartbeatRuns } from "@paperclipai/db";
|
||||
import { agents, issues, heartbeatRuns, projects, companyMemberships, executionWorkspaces } from "@paperclipai/db";
|
||||
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { documentService } from "../services/documents.js";
|
||||
import { activityService } from "../services/activity.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
@@ -18,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)!;
|
||||
@@ -72,6 +123,215 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect((await documentService(server.db).getIssueDocumentByKey(result.id, "plan"))?.body).toContain("Implement in the execution task");
|
||||
});
|
||||
|
||||
it("allows low-trust chat handoffs and self-assigned subtasks through both creation endpoints", async () => {
|
||||
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
|
||||
const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { mode: "low_trust_review", companyId: f.companyId, projectIds: [f.projectId] } } };
|
||||
await server.db.update(agents).set({ permissions: policy }).where(eq(agents.id, f.agentId));
|
||||
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
||||
const task = await callProjectTool({ name: "create_task", arguments: { title: "Contained MCP handoff", projectId: f.projectId, initialPlan: "Do the requested work", idempotencyKey: "contained-mcp" },
|
||||
apiUrl: server.apiUrl, token, companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, conversation: true });
|
||||
expect(task).toMatchObject({ parentId: null, projectId: f.projectId, assigneeAgentId: f.agentId, sourceTrust: { disposition: "quarantined" } });
|
||||
expect(task.executionPolicy.authorizationPolicy).toMatchObject({ trustPreset: "low_trust_review", ...policy.authorizationPolicy });
|
||||
expect((await documentService(server.db).getIssueDocumentByKey(task.id, "plan"))?.sourceTrust).toMatchObject({ disposition: "quarantined" });
|
||||
const native = await call(f, "create_task", { title: "Contained native handoff", projectId: f.projectId, idempotencyKey: "contained-native" }) as any;
|
||||
expect(native.task).toMatchObject({ parentId: null, projectId: f.projectId, assigneeActorId: f.agentId });
|
||||
const [nativeTask] = await server.db.select().from(issues).where(eq(issues.id, native.task.id));
|
||||
expect(nativeTask.executionPolicy).toMatchObject({ authorizationPolicy: { trustPreset: "low_trust_review", ...policy.authorizationPolicy } });
|
||||
expect(nativeTask.sourceTrust).toMatchObject({ disposition: "quarantined" });
|
||||
|
||||
for (const path of [`/api/issues/${task.id}/children`, `/api/companies/${f.companyId}/issues`]) {
|
||||
const response = await fetch(`${server.apiUrl}${path}`, {
|
||||
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: `Self decomposition ${path}`, parentId: task.id, assigneeAgentId: f.agentId, status: "backlog" }),
|
||||
});
|
||||
const child = await response.json();
|
||||
expect(response.status, JSON.stringify(child)).toBe(201);
|
||||
expect(child).toMatchObject({ parentId: task.id, projectId: f.projectId, assigneeAgentId: f.agentId, sourceTrust: { disposition: "quarantined" } });
|
||||
expect(child.executionPolicy.authorizationPolicy).toMatchObject({ trustPreset: "low_trust_review", ...policy.authorizationPolicy });
|
||||
}
|
||||
});
|
||||
|
||||
it("retains a run-only low-trust boundary on a new task even when standard policy is requested", async () => {
|
||||
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
|
||||
const executionPolicy = { authorizationPolicy: { trustPreset: "low_trust_review", trustBoundary: { mode: "low_trust_review", companyId: f.companyId, projectIds: [f.projectId] } } };
|
||||
const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
||||
await server.db.update(heartbeatRuns).set({ contextSnapshot: { ...run.contextSnapshot, executionPolicy } }).where(eq(heartbeatRuns.id, f.runId));
|
||||
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
||||
const response = await fetch(`${server.apiUrl}/api/companies/${f.companyId}/issues`, {
|
||||
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "Retain containment", projectId: f.projectId, assigneeAgentId: f.agentId, executionPolicy: { authorizationPolicy: { trustPreset: "standard" } }, status: "backlog" }),
|
||||
});
|
||||
const task = await response.json();
|
||||
expect(response.status, JSON.stringify(task)).toBe(201);
|
||||
expect(task.executionPolicy).toMatchObject(executionPolicy);
|
||||
});
|
||||
|
||||
it("enforces the proposed task scope and assignee before creating any low-trust work", async () => {
|
||||
const f = await server.fixture({ disableWakeOnDemand: true });
|
||||
const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { mode: "low_trust_review", companyId: f.companyId, projectIds: [f.projectId] } } };
|
||||
await server.db.update(agents).set({ permissions: policy }).where(eq(agents.id, f.agentId));
|
||||
const [outside] = await server.db.insert(projects).values({ companyId: f.companyId, name: "Outside boundary" }).returning();
|
||||
const [otherAgent] = await server.db.insert(agents).values({ companyId: f.companyId, name: "Unauthorized assignee", status: "active" }).returning();
|
||||
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
||||
const create = async (path: string, fields: Record<string, unknown>) => {
|
||||
const response = await fetch(`${server.apiUrl}${path}`, {
|
||||
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: `Boundary test ${randomUUID()}`, status: "backlog", assigneeAgentId: f.agentId, ...fields }),
|
||||
});
|
||||
return { status: response.status, body: await response.json() };
|
||||
};
|
||||
const before = await server.db.select({ id: issues.id }).from(issues).where(eq(issues.companyId, f.companyId));
|
||||
for (const path of [`/api/companies/${f.companyId}/issues`, `/api/issues/${f.issueId}/children`]) {
|
||||
for (const fields of [
|
||||
{ projectId: outside.id },
|
||||
{ projectId: outside.id, assigneeAgentId: null },
|
||||
{ projectId: f.foreignProjectId },
|
||||
{ projectId: f.projectId, assigneeAgentId: otherAgent.id },
|
||||
{ projectId: f.projectId, assigneeAgentId: null, assigneeUserId: "board-user" },
|
||||
]) {
|
||||
const result = await create(path, fields);
|
||||
const hiddenProject = fields.projectId !== f.projectId && path.startsWith("/api/companies/");
|
||||
expect(result.status, JSON.stringify(result.body)).toBe(hiddenProject ? 404 : 403);
|
||||
expect(result.body.error).toMatch(hiddenProject ? /^Project not found$/ : /outside.*boundary|cannot assign work to board users|different company/i);
|
||||
}
|
||||
}
|
||||
const after = await server.db.select({ id: issues.id }).from(issues).where(eq(issues.companyId, f.companyId));
|
||||
expect(after).toEqual(before);
|
||||
for (const fields of [{ projectId: outside.id }, { projectId: f.projectId, assigneeActorId: otherAgent.id }]) {
|
||||
await expect(call(f, "create_task", { title: "Native scope denial", idempotencyKey: randomUUID(), ...fields })).rejects.toThrow(/outside.*boundary/);
|
||||
}
|
||||
|
||||
await server.db.update(agents).set({ permissions: { ...policy, authorizationPolicy: {
|
||||
...policy.authorizationPolicy, assignmentPolicy: { mode: "protected" }, protectedAgent: { blockAssignment: true },
|
||||
} } }).where(eq(agents.id, f.agentId));
|
||||
const protectedResult = await create(`/api/companies/${f.companyId}/issues`, { projectId: f.projectId });
|
||||
expect(protectedResult.status, JSON.stringify(protectedResult.body)).toBe(403);
|
||||
expect(protectedResult.body.error).toContain("protected-agent policy");
|
||||
});
|
||||
|
||||
it("allows low-trust root-scoped subtasks without granting descendants of an exact issue scope", async () => {
|
||||
const f = await server.fixture({ disableWakeOnDemand: true });
|
||||
const boundary = { mode: "low_trust_review", companyId: f.companyId, rootIssueId: f.issueId };
|
||||
await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: boundary } } }).where(eq(agents.id, f.agentId));
|
||||
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
||||
for (const path of [`/api/companies/${f.companyId}/issues`, `/api/issues/${f.issueId}/children`]) {
|
||||
const response = await fetch(`${server.apiUrl}${path}`, {
|
||||
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: `Root subtask ${randomUUID()}`, parentId: f.issueId, assigneeAgentId: f.agentId, status: "backlog" }),
|
||||
});
|
||||
const child = await response.json();
|
||||
expect(response.status, JSON.stringify(child)).toBe(201);
|
||||
expect(child.executionPolicy.authorizationPolicy.trustBoundary).toMatchObject(boundary);
|
||||
}
|
||||
await expect(call(f, "create_task", { title: "Native root subtask", idempotencyKey: "root-child", status: "backlog" })).resolves.toMatchObject({ task: { parentId: f.issueId, assigneeActorId: f.agentId } });
|
||||
|
||||
const [outside] = await server.db.insert(projects).values({ companyId: f.companyId, name: "Unrelated project" }).returning();
|
||||
const before = await server.db.select({ id: issues.id }).from(issues).where(eq(issues.companyId, f.companyId));
|
||||
for (const path of [`/api/companies/${f.companyId}/issues`, `/api/issues/${f.issueId}/children`]) {
|
||||
const response = await fetch(`${server.apiUrl}${path}`, {
|
||||
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "Root cannot escape into another project", parentId: f.issueId,
|
||||
projectId: outside.id, assigneeAgentId: f.agentId, status: "backlog" }),
|
||||
});
|
||||
const denied = await response.json();
|
||||
const hiddenProject = path.startsWith("/api/companies/");
|
||||
expect(response.status, JSON.stringify(denied)).toBe(hiddenProject ? 404 : 403);
|
||||
expect(denied.error).toMatch(hiddenProject ? /^Project not found$/ : /outside.*boundary/i);
|
||||
}
|
||||
await expect(call(f, "create_task", { title: "Native root cannot escape", projectId: outside.id,
|
||||
idempotencyKey: "root-outside", status: "backlog" })).rejects.toThrow(/outside.*boundary/);
|
||||
expect(await server.db.select({ id: issues.id }).from(issues).where(eq(issues.companyId, f.companyId))).toEqual(before);
|
||||
|
||||
await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { mode: "low_trust_review", issueIds: [f.issueId] } } } }).where(eq(agents.id, f.agentId));
|
||||
await expect(call(f, "create_task", { title: "Exact scope is not a subtree", idempotencyKey: "exact-child" })).rejects.toThrow(/outside.*boundary/);
|
||||
});
|
||||
|
||||
it("authorizes workspace-derived projects before creating children of a projectless root", async () => {
|
||||
const f = await server.fixture({ disableWakeOnDemand: true });
|
||||
const settings = instanceSettingsService(server.db);
|
||||
const previous = await settings.getExperimental();
|
||||
await settings.updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
try {
|
||||
await server.db.update(issues).set({ projectId: null, projectWorkspaceId: null }).where(eq(issues.id, f.issueId));
|
||||
const [executionWorkspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId,
|
||||
projectId: f.projectId, projectWorkspaceId: f.projectWorkspaceId, mode: "shared", strategyType: "project_primary", name: "Outside root" }).returning();
|
||||
const boundary = { mode: "low_trust_review", companyId: f.companyId, rootIssueId: f.issueId };
|
||||
await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: boundary } } }).where(eq(agents.id, f.agentId));
|
||||
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
||||
const before = await server.db.select({ id: issues.id }).from(issues).where(eq(issues.companyId, f.companyId));
|
||||
const paths = [`/api/companies/${f.companyId}/issues`, `/api/issues/${f.issueId}/children`];
|
||||
const selections = [{ projectWorkspaceId: f.projectWorkspaceId }, { executionWorkspaceId: executionWorkspace.id },
|
||||
{ inheritExecutionWorkspaceFromIssueId: f.blockerId }];
|
||||
const create = async (path: string, selection: Record<string, string>) => {
|
||||
const response = await fetch(`${server.apiUrl}${path}`, {
|
||||
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: `Workspace scope ${randomUUID()}`, parentId: f.issueId,
|
||||
assigneeAgentId: f.agentId, status: "backlog", ...selection }),
|
||||
});
|
||||
return { status: response.status, body: await response.json() };
|
||||
};
|
||||
for (const path of paths) {
|
||||
for (const selection of selections) {
|
||||
if (path.endsWith("/children") && selection.inheritExecutionWorkspaceFromIssueId) continue;
|
||||
const response = await create(path, selection);
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(403);
|
||||
}
|
||||
}
|
||||
expect(await server.db.select({ id: issues.id }).from(issues).where(eq(issues.companyId, f.companyId))).toEqual(before);
|
||||
|
||||
await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: {
|
||||
trustBoundary: { ...boundary, projectIds: [f.projectId] },
|
||||
} } }).where(eq(agents.id, f.agentId));
|
||||
for (const path of paths) {
|
||||
for (const selection of selections) {
|
||||
if (path.endsWith("/children") && selection.inheritExecutionWorkspaceFromIssueId) continue;
|
||||
const response = await create(path, selection);
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(201);
|
||||
expect(response.body.projectId).toBe(f.projectId);
|
||||
expect(response.body.executionPolicy.authorizationPolicy.trustBoundary.projectIds).toEqual([f.projectId]);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await settings.updateExperimental({ enableIsolatedWorkspaces: previous.enableIsolatedWorkspaces });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the responsible user's assignment restrictions on low-trust self-assignment", async () => {
|
||||
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
|
||||
await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { mode: "low_trust_review", projectIds: [f.projectId] } } } }).where(eq(agents.id, f.agentId));
|
||||
await server.db.update(companyMemberships).set({ membershipRole: "viewer" }).where(eq(companyMemberships.principalId, f.responsibleUserId!));
|
||||
await expect(call(f, "create_task", { title: "Viewer cannot assign", projectId: f.projectId, idempotencyKey: "viewer-denied" })).rejects.toThrow();
|
||||
expect(await server.db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not reuse an out-of-scope task through a title or idempotency collision", async () => {
|
||||
const f = await server.fixture({ disableWakeOnDemand: true });
|
||||
const [outside] = await server.db.insert(projects).values({ companyId: f.companyId, name: "Private project" }).returning();
|
||||
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
||||
for (const parentId of [null, f.issueId]) {
|
||||
const key = randomUUID();
|
||||
const title = `Existing private work ${randomUUID()}`;
|
||||
const existing = await issueService(server.db).create(f.companyId, { projectId: outside.id, parentId,
|
||||
title, description: "Private contents", assigneeAgentId: f.agentId, status: "backlog", idempotencyKey: key });
|
||||
for (const allowed of [false, true]) {
|
||||
await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: {
|
||||
trustBoundary: { mode: "low_trust_review", projectIds: [f.projectId, ...(allowed ? [outside.id] : [])] },
|
||||
} } }).where(eq(agents.id, f.agentId));
|
||||
for (const collision of [{ idempotencyKey: key, title: "Different proposed task" }, { title }]) {
|
||||
const response = await fetch(`${server.apiUrl}/api/companies/${f.companyId}/issues`, {
|
||||
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ projectId: f.projectId, parentId, assigneeAgentId: f.agentId, status: "backlog", ...collision }),
|
||||
});
|
||||
const body = await response.json();
|
||||
expect(response.status, JSON.stringify(body)).toBe(allowed ? 200 : 403);
|
||||
if (allowed) expect(body).toMatchObject({ id: existing.id, projectId: outside.id, deduplicated: true });
|
||||
else expect(JSON.stringify(body)).not.toContain("Private contents");
|
||||
}
|
||||
}
|
||||
expect((await issueService(server.db).getById(existing.id))?.projectId).toBe(outside.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("retains ordinary child delegation and projectless task creation", async () => {
|
||||
const f = await server.fixture();
|
||||
const result = await call(f, "create_task", { title: "Delegate ordinary work", idempotencyKey: "child" }) as any;
|
||||
|
||||
@@ -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");
|
||||
@@ -678,8 +685,9 @@ describe("claude execute", () => {
|
||||
expect(captured[1]?.appendedSystemPromptFilePath).toContain("agent-instructions.md");
|
||||
expect(captured[1]?.appendedSystemPromptFilePath).not.toBe(instructionsFile);
|
||||
expect(captured[1]?.appendedSystemPromptFileContents).toContain("# Agent instructions");
|
||||
expect(captured[1]?.appendedSystemPromptFileContents).toContain(
|
||||
`The above agent instructions were loaded from ${instructionsFile}. ` +
|
||||
expect(captured[1]?.appendedSystemPromptFileContents).not.toContain(instructionsFile);
|
||||
for (const prompt of prompts) expect(prompt).toContain(
|
||||
`Agent instructions for this run were loaded from ${instructionsFile}. ` +
|
||||
`Resolve any relative file references from ${path.dirname(instructionsFile)}/. ` +
|
||||
`This base directory is authoritative for sibling instruction files such as ` +
|
||||
`./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory.`,
|
||||
@@ -1163,7 +1171,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 +1240,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 +1275,7 @@ describe("claude execute", () => {
|
||||
taskId: "issue-1",
|
||||
wakeReason: "issue_commented",
|
||||
wakeCommentId: "comment-2",
|
||||
refreshTools: change !== "unchanged",
|
||||
paperclipWake: {
|
||||
reason: "issue_commented",
|
||||
issue: {
|
||||
@@ -1296,12 +1307,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 () => {},
|
||||
@@ -1328,10 +1339,19 @@ describe("claude execute", () => {
|
||||
expect(capture1.addDir?.startsWith(expectedRoot)).toBe(true);
|
||||
expect(capture1.instructionsFilePath?.startsWith(expectedRoot)).toBe(true);
|
||||
expect(capture1.instructionsContents).toContain("You are managed instructions.");
|
||||
expect(capture1.instructionsContents).toContain(`The above agent instructions were loaded from ${instructionsPath}.`);
|
||||
expect(capture1.instructionsContents).not.toContain(instructionsPath);
|
||||
expect(capture1.prompt).toContain(`Agent instructions for this run were loaded from ${instructionsPath}.`);
|
||||
expect(capture2.prompt).toContain(`Agent instructions for this run 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 {
|
||||
@@ -1345,6 +1365,81 @@ describe("claude execute", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("resumes across per-run instruction copies and refreshes their location", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-working-copy-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "claude");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
const skillDir = path.join(root, "skill");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.mkdir(skillDir);
|
||||
await fs.writeFile(path.join(skillDir, "SKILL.md"), "First skill version.\n");
|
||||
await writeFakeClaudeCommand(commandPath);
|
||||
vi.stubEnv("PAPERCLIP_HOME", path.join(root, "paperclip-home"));
|
||||
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "default");
|
||||
|
||||
try {
|
||||
let sessionParams: Record<string, unknown> | null = null;
|
||||
let previousBundleKey: unknown;
|
||||
let previousInstructionsPath = "";
|
||||
for (const [index, change] of ["initial", "location", "instructions", "skill"].entries()) {
|
||||
const runId = `run-${index}`;
|
||||
const instructionsPath = path.join(root, "companies", "company-1", "agents", "agent-1",
|
||||
"file-sync", "runs", runId, "live", "AGENTS.md");
|
||||
await fs.mkdir(path.dirname(instructionsPath), { recursive: true });
|
||||
await fs.writeFile(instructionsPath, index < 2 ? "First instructions.\n" : "Changed instructions.\n");
|
||||
if (change === "skill") {
|
||||
await fs.writeFile(path.join(skillDir, "SKILL.md"), "Changed skill version.\n");
|
||||
}
|
||||
const logs: string[] = [];
|
||||
const result = await execute({
|
||||
runId,
|
||||
agent: { id: "agent-1", companyId: "company-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
|
||||
runtime: { sessionId: null, sessionParams, sessionDisplayId: null, taskKey: "issue-1" },
|
||||
config: {
|
||||
engine: "cli",
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
instructionsFilePath: instructionsPath,
|
||||
paperclipRuntimeSkills: [{ key: "test-skill", runtimeName: "test-skill", source: skillDir }],
|
||||
paperclipSkillSync: { desiredSkills: ["test-skill"] },
|
||||
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath, CLAUDE_CONFIG_DIR: path.join(root, "claude-config") },
|
||||
},
|
||||
context: { issueId: "issue-1" },
|
||||
onLog: async (_stream, chunk) => { logs.push(chunk); },
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
if (change === "location") {
|
||||
expect(capture.argv).toContain("--resume");
|
||||
expect(capture.argv).toContain("11111111-1111-4111-8111-111111111111");
|
||||
expect(capture.instructionsFilePath).toBeNull();
|
||||
expect(result.sessionParams?.promptBundleKey).toBe(previousBundleKey);
|
||||
} else {
|
||||
expect(capture.argv).not.toContain("--resume");
|
||||
expect(capture.instructionsContents).toContain(index < 2 ? "First instructions." : "Changed instructions.");
|
||||
expect(result.sessionParams?.promptBundleKey).not.toBe(previousBundleKey);
|
||||
if (index > 0) {
|
||||
expect(capture.instructionsContents).not.toContain(instructionsPath);
|
||||
expect(logs.join("")).toContain("was saved for prompt bundle");
|
||||
expect(logs.join("")).not.toContain("was saved for cwd");
|
||||
}
|
||||
}
|
||||
if (index > 0) {
|
||||
expect(capture.prompt).toContain(instructionsPath);
|
||||
expect(capture.prompt).toContain(`Resolve any relative file references from ${path.dirname(instructionsPath)}/.`);
|
||||
}
|
||||
if (previousInstructionsPath) expect(capture.prompt).not.toContain(previousInstructionsPath);
|
||||
sessionParams = sessionCodec.deserialize(sessionCodec.serialize(result.sessionParams ?? null));
|
||||
previousBundleKey = result.sessionParams?.promptBundleKey;
|
||||
previousInstructionsPath = instructionsPath;
|
||||
}
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("starts a fresh Claude session when the stable prompt bundle changes", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-reset-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -367,6 +368,13 @@ function isTestFile(relPath: string): boolean {
|
||||
|
||||
function listGuidanceFiles(rootDir = repoRoot): string[] {
|
||||
const found: string[] = [];
|
||||
// Include tracked files and new authored files, while respecting gitignored
|
||||
// local settings. A tracked file remains scanned even if an ignore matches.
|
||||
const authored = new Set(execFileSync(
|
||||
"git",
|
||||
["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
|
||||
{ cwd: rootDir, encoding: "utf8" },
|
||||
).split("\0"));
|
||||
|
||||
function walk(absDir: string, relDir: string): void {
|
||||
for (const entry of readdirSync(absDir, { withFileTypes: true })) {
|
||||
@@ -382,7 +390,7 @@ function listGuidanceFiles(rootDir = repoRoot): string[] {
|
||||
if (!SCAN_EXTENSIONS.has(path.extname(entry.name))) continue;
|
||||
if (isTestFile(relPath)) continue;
|
||||
if (SKIP_PATH_PREFIXES.some((prefix) => relPath.startsWith(prefix))) continue;
|
||||
found.push(relPath);
|
||||
if (authored.has(relPath)) found.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,6 +494,7 @@ describe("paperclipai CLI invocation safety", () => {
|
||||
"tests/runner-e2e/catalog.ts",
|
||||
];
|
||||
try {
|
||||
execFileSync("git", ["init", "--quiet"], { cwd: root });
|
||||
for (const relPath of [
|
||||
...sourcePaths,
|
||||
"tests/runner-e2e/results/campaign/attempt-1/snapshots/api-state.json",
|
||||
@@ -503,6 +512,7 @@ describe("paperclipai CLI invocation safety", () => {
|
||||
it("excludes root runtime recordings but still scans unsafe docs and source guidance", () => {
|
||||
const fixtureRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-guidance-"));
|
||||
try {
|
||||
execFileSync("git", ["init", "--quiet"], { cwd: fixtureRoot });
|
||||
const fixtures = [
|
||||
"doc/CLI.md",
|
||||
"src/guidance.ts",
|
||||
@@ -528,6 +538,26 @@ describe("paperclipai CLI invocation safety", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("scans tracked and ordinary untracked guidance but excludes ignored untracked files", () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-guidance-"));
|
||||
try {
|
||||
execFileSync("git", ["init", "--quiet"], { cwd: root });
|
||||
writeFileSync(path.join(root, ".gitignore"), "ignored.md\ntracked.md\n");
|
||||
for (const name of ["ignored.md", "untracked.md", "tracked.md"]) {
|
||||
writeFileSync(path.join(root, name), "pnpm paperclipai issue get issue-1\n");
|
||||
}
|
||||
execFileSync("git", ["add", "--force", "tracked.md"], { cwd: root });
|
||||
|
||||
const files = listGuidanceFiles(root).sort();
|
||||
expect(files).toEqual(["tracked.md", "untracked.md"]);
|
||||
for (const name of files) {
|
||||
expect(scanText(name, readFileSync(path.join(root, name), "utf8"))).toHaveLength(1);
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("allows only exact-allowlist pnpm paperclipai commands on every guidance surface", () => {
|
||||
const offenders = scanForOffenders();
|
||||
expect(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
@@ -16,6 +18,8 @@ import {
|
||||
import { routineWebhookUrl } from "../services/routines.js";
|
||||
import { paperclipCloudConnectorEnrollmentStatus } from "../services/paperclip-cloud-connector-enrollment.js";
|
||||
import { cloudRuntimeIdentityMiddleware } from "../middleware/cloud-runtime-identity.js";
|
||||
import { idleAdmissionMiddleware, trackIdleRequestHandlers } from "../middleware/idle-admission.js";
|
||||
import { idleWorkSnapshot } from "../services/task-admission.js";
|
||||
import { healthRoutes } from "../routes/health.js";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
@@ -106,6 +110,7 @@ describeEmbeddedPostgres("Cloud runtime identity", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const key of [
|
||||
"PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN",
|
||||
"PAPERCLIP_CLOUD_STACK_ID",
|
||||
@@ -183,6 +188,70 @@ describeEmbeddedPostgres("Cloud runtime identity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([false, true])("counts exempt bootstrap writes until they settle (failure=%s)", async (fail) => {
|
||||
let enterWrite!: () => void, finishWrite!: () => void;
|
||||
const entered = new Promise<void>(resolve => { enterWrite = resolve; });
|
||||
const pending = new Promise<void>(resolve => { finishWrite = resolve; });
|
||||
const transaction = db.transaction.bind(db);
|
||||
vi.spyOn(db, "transaction").mockImplementationOnce(async (...args) => {
|
||||
enterWrite();
|
||||
await pending;
|
||||
if (fail) throw new Error("fixture bootstrap write failed");
|
||||
return transaction(...args);
|
||||
});
|
||||
const app = express();
|
||||
app.use(idleAdmissionMiddleware);
|
||||
app.use(cloudRuntimeIdentityMiddleware(db));
|
||||
app.get("/api/health", (_req, res) => res.sendStatus(204));
|
||||
trackIdleRequestHandlers(app);
|
||||
const requestTime = Math.floor(Date.now() / 1000);
|
||||
const accepted = request(app).get("/api/health")
|
||||
.set("x-paperclip-cloud-runtime-identity", assertion({ claims: { iat: requestTime, exp: requestTime + 300 } }))
|
||||
.then(response => response);
|
||||
try {
|
||||
await entered;
|
||||
expect(idleWorkSnapshot().active).toBe(1);
|
||||
} finally {
|
||||
finishWrite();
|
||||
const response = await accepted;
|
||||
expect(response.status).toBe(fail ? 401 : 204);
|
||||
}
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("codex_local parser", () => {
|
||||
expect(parsed.sessionId).toBe("thread-123");
|
||||
expect(parsed.summary).toBe("hello");
|
||||
expect(parsed.usage).toEqual({
|
||||
inputTokens: 10,
|
||||
inputTokens: 8,
|
||||
cachedInputTokens: 2,
|
||||
outputTokens: 4,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -103,7 +103,7 @@ const agentInstructionsSvc = {
|
||||
};
|
||||
|
||||
const instanceSettingsSvc = {
|
||||
getExperimental: vi.fn(async () => ({ enableNativeRunner: false })),
|
||||
getExperimental: vi.fn(async (): Promise<{ enableNativeRunner: boolean; enableOpenAiDot?: boolean }> => ({ enableNativeRunner: false })),
|
||||
};
|
||||
|
||||
const managedAgentProfileSvc = {
|
||||
@@ -1897,7 +1897,7 @@ describe("company portability", () => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
}), { createdByUserId: "user-1" });
|
||||
expect(secretSvc.syncEnvBindingsForTarget).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
{ targetType: "agent", targetId: "agent-imported" },
|
||||
@@ -2889,7 +2889,7 @@ describe("company portability", () => {
|
||||
// a scoped resume; "system" stays for platform-managed pauses.
|
||||
pauseReason: "import",
|
||||
pausedAt: expect.any(Date),
|
||||
}));
|
||||
}), { createdByUserId: "user-1" });
|
||||
expect(routineSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
|
||||
title: "Monday Review",
|
||||
status: "paused",
|
||||
@@ -3204,7 +3204,7 @@ describe("company portability", () => {
|
||||
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
|
||||
name: "ClaudeCoder",
|
||||
adapterType: "process",
|
||||
}));
|
||||
}), { createdByUserId: "user-1" });
|
||||
});
|
||||
|
||||
it("preserves agent role from frontmatter when extension block omits it", async () => {
|
||||
@@ -3383,7 +3383,7 @@ describe("company portability", () => {
|
||||
desiredSkills: [paperclipKey],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
}), { createdByUserId: "user-1" });
|
||||
});
|
||||
|
||||
it("imports a packaged company logo and attaches it to the target company", async () => {
|
||||
@@ -3677,7 +3677,7 @@ describe("company portability", () => {
|
||||
maxConcurrentRuns: 20,
|
||||
},
|
||||
},
|
||||
}));
|
||||
}), { createdByUserId: "user-1" });
|
||||
expect(result.company.action).toBe("unchanged");
|
||||
expect(result.agents).toEqual([
|
||||
{
|
||||
@@ -3748,13 +3748,13 @@ describe("company portability", () => {
|
||||
adapterConfig: expect.objectContaining({
|
||||
dangerouslyBypassApprovalsAndSandbox: true,
|
||||
}),
|
||||
}));
|
||||
}), { createdByUserId: "user-1" });
|
||||
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
|
||||
adapterConfig: expect.not.objectContaining({
|
||||
instructionsFilePath: expect.anything(),
|
||||
promptTemplate: expect.anything(),
|
||||
}),
|
||||
}));
|
||||
}), { createdByUserId: "user-1" });
|
||||
expect(agentInstructionsSvc.materializeManagedBundle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "ClaudeCoder" }),
|
||||
expect.objectContaining({
|
||||
@@ -3856,7 +3856,7 @@ describe("company portability", () => {
|
||||
extraArgs: ["--skip-git-repo-check"],
|
||||
args: ["--legacy-arg"],
|
||||
}),
|
||||
}));
|
||||
}), { createdByUserId: "user-1" });
|
||||
const lastCreateInput = agentSvc.create.mock.calls.at(-1)?.[1] as Record<string, any>;
|
||||
expect(lastCreateInput?.adapterConfig).toBeTruthy();
|
||||
expect(lastCreateInput.adapterConfig?.dangerouslyBypassApprovalsAndSandbox).toBeUndefined();
|
||||
@@ -5785,7 +5785,7 @@ describe("company portability", () => {
|
||||
normalized: true,
|
||||
}),
|
||||
status: "idle",
|
||||
}));
|
||||
}), { createdByUserId: "user-1" });
|
||||
expect(companySvc.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
}));
|
||||
@@ -6030,6 +6030,33 @@ describe("company portability", () => {
|
||||
expect(preview.plan.issuePlans).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("imports an unpaired Dot using its own option without enabling other Runner providers", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
const exported = await portability.exportBundle("company-1", { include: { company: false, agents: true, projects: false, issues: false } });
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({ id: "agent-created", ...input }));
|
||||
const input = {
|
||||
source: { type: "inline" as const, rootPath: exported.rootPath, files: exported.files },
|
||||
include: { company: false, agents: true, projects: false, issues: false },
|
||||
target: { mode: "existing_company" as const, companyId: "company-1" },
|
||||
agents: "all" as const, collisionStrategy: "rename" as const,
|
||||
adapterOverrides: { claudecoder: { adapterType: "paperclip_runner", adapterConfig: { provider: "openai_dot", allowUnmeteredProvider: true, lifecycleMode: "per_turn" } } },
|
||||
};
|
||||
instanceSettingsSvc.getExperimental.mockResolvedValue({ enableNativeRunner: true, enableOpenAiDot: false });
|
||||
await expect(portability.importBundle(input, "user-1")).rejects.toMatchObject({ status: 422, details: { code: "paperclip_runner_dot_disabled" } });
|
||||
expect(agentSvc.create).not.toHaveBeenCalled();
|
||||
instanceSettingsSvc.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true });
|
||||
await expect(portability.importBundle({ ...input, adapterOverrides: { claudecoder: { adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } } } }, "user-1"))
|
||||
.rejects.toMatchObject({ status: 422, details: { code: "paperclip_runner_rollout_disabled" } });
|
||||
expect(agentSvc.create).not.toHaveBeenCalled();
|
||||
await portability.importBundle(input, "user-1");
|
||||
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ adapterType: "paperclip_runner", adapterConfig: expect.objectContaining({ provider: "openai_dot", allowUnmeteredProvider: true }) }), { createdByUserId: "user-1" });
|
||||
const createdConfig = agentSvc.create.mock.calls[0]![1].adapterConfig;
|
||||
expect(createdConfig.dotBindingId).toBeUndefined();
|
||||
const { resolvePaperclipRunnerProviderProfile } = await import("../services/native-runtime/provider-profile.js");
|
||||
expect(() => resolvePaperclipRunnerProviderProfile(createdConfig)).toThrow(expect.objectContaining({ code: "paperclip_runner_dot_config_invalid" }));
|
||||
});
|
||||
|
||||
it("rejects runner imports while disabled and accepts the same selection when enabled", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
@@ -6080,13 +6107,13 @@ describe("company portability", () => {
|
||||
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
adapterType: "paperclip_runner",
|
||||
adapterConfig: expect.objectContaining({ provider: "opencode" }),
|
||||
}));
|
||||
}), { createdByUserId: "user-1" });
|
||||
|
||||
await portability.importBundle(request, "user-1");
|
||||
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
adapterType: "paperclip_runner",
|
||||
adapterConfig: expect.objectContaining({ provider: "codex" }),
|
||||
}));
|
||||
}), { createdByUserId: "user-1" });
|
||||
|
||||
await portability.importBundle({
|
||||
...request,
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
accounts: vi.fn(),
|
||||
credential: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
codex: vi.fn(),
|
||||
claude: vi.fn(),
|
||||
adapters: vi.fn(),
|
||||
}));
|
||||
vi.mock("../services/ai-connections.js", () => ({
|
||||
aiConnectionService: () => ({
|
||||
quotaAccounts: mocks.accounts,
|
||||
credential: mocks.credential,
|
||||
refreshQuotaCredential: mocks.refresh,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../adapters/registry.js", () => ({
|
||||
listServerAdapters: mocks.adapters,
|
||||
}));
|
||||
vi.mock("@paperclipai/adapter-codex-local/server", () => ({
|
||||
fetchCodexQuota: mocks.codex,
|
||||
}));
|
||||
vi.mock("@paperclipai/adapter-claude-local/server", () => ({
|
||||
fetchClaudeQuota: mocks.claude,
|
||||
}));
|
||||
import { fetchCompanyQuotaWindows } from "../services/quota-windows.js";
|
||||
|
||||
let revision = 1;
|
||||
const db = {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
leftJoin: () => ({
|
||||
leftJoin: () => ({
|
||||
where: async () => [
|
||||
{
|
||||
id: "secret",
|
||||
updatedAt: new Date(0),
|
||||
latestVersion: revision,
|
||||
status: "active",
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
} as unknown as Db;
|
||||
const account = (id: string, provider = "openai", status = "connected") => ({
|
||||
connection: { id, updatedAt: new Date(0) },
|
||||
grant: {
|
||||
id,
|
||||
updatedAt: new Date(0),
|
||||
credentialSecretRefs: [{ secretId: "secret" }],
|
||||
},
|
||||
summary: { provider, name: id, status },
|
||||
});
|
||||
describe("connected account quotas", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
revision = 1;
|
||||
mocks.refresh.mockRejectedValue(new Error("authentication_required"));
|
||||
mocks.credential.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
tokens: { access_token: "private-token", account_id: "actual-account" },
|
||||
}),
|
||||
);
|
||||
mocks.codex.mockResolvedValue([
|
||||
{ label: "5h", usedPercent: 0.5, resetsAt: null, valueLabel: null },
|
||||
]);
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
it("uses the selected account without invoking a local CLI or fallback login", async () => {
|
||||
mocks.accounts.mockResolvedValue([account("remote-codex")]);
|
||||
const result = await fetchCompanyQuotaWindows(db, "company-1", "user-1");
|
||||
expect(mocks.accounts).toHaveBeenCalledWith("company-1", "user-1");
|
||||
expect(mocks.codex).toHaveBeenCalledWith("private-token", "actual-account", expect.any(AbortSignal));
|
||||
expect(mocks.adapters).not.toHaveBeenCalled();
|
||||
expect(result[0]).toMatchObject({
|
||||
ok: true,
|
||||
accountLabel: "remote-codex",
|
||||
source: "managed-connection",
|
||||
});
|
||||
expect(result[0].capturedAt).toBeDefined();
|
||||
expect(JSON.stringify(result)).not.toContain("private-token");
|
||||
});
|
||||
it("deduplicates account requests and invalidates on credential revision", async () => {
|
||||
mocks.accounts.mockResolvedValue([account("cached-codex")]);
|
||||
const [a, b] = await Promise.all([
|
||||
fetchCompanyQuotaWindows(db, "company-2", "user-2"),
|
||||
fetchCompanyQuotaWindows(db, "company-2", "user-2"),
|
||||
]);
|
||||
expect(mocks.codex).toHaveBeenCalledTimes(1);
|
||||
expect(a[0].capturedAt).toBe(b[0].capturedAt);
|
||||
revision++;
|
||||
const next = await fetchCompanyQuotaWindows(db, "company-2", "user-2");
|
||||
expect(mocks.codex).toHaveBeenCalledTimes(2);
|
||||
expect(next[0].accountKey).not.toBe(a[0].accountKey);
|
||||
});
|
||||
it("keeps two accounts for the same provider separate", async () => {
|
||||
mocks.accounts.mockResolvedValue([account("first"), account("second")]);
|
||||
const result = await fetchCompanyQuotaWindows(db, "company-3", "user-3");
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].accountKey).not.toBe(result[1].accountKey);
|
||||
});
|
||||
it("does not expose failures and marks authentication failures for stale-window removal", async () => {
|
||||
mocks.accounts.mockResolvedValue([account("expired")]);
|
||||
mocks.codex.mockRejectedValue(
|
||||
new Error("401 private-token raw provider diagnostics"),
|
||||
);
|
||||
const [result] = await fetchCompanyQuotaWindows(db, "company-4", "user-4");
|
||||
expect(result.errorFamily).toBe("authentication_required");
|
||||
expect(result.windows).toEqual([]);
|
||||
expect(JSON.stringify(result)).not.toContain("private-token");
|
||||
});
|
||||
it("does not read revoked credentials or use the server login for an unconnected remote account", async () => {
|
||||
mocks.accounts.mockResolvedValue([account("revoked", "openai", "revoked")]);
|
||||
const [result] = await fetchCompanyQuotaWindows(db, "company-5", "user-5");
|
||||
expect(result.errorFamily).toBe("credentials_unavailable");
|
||||
expect(mocks.credential).not.toHaveBeenCalled();
|
||||
mocks.accounts.mockResolvedValue([]);
|
||||
const empty = await fetchCompanyQuotaWindows(db, "company-5", "user-5");
|
||||
expect(
|
||||
empty.every((row) => row.errorFamily === "credentials_unavailable"),
|
||||
).toBe(true);
|
||||
expect(mocks.adapters).not.toHaveBeenCalled();
|
||||
});
|
||||
it("uses a managed Claude OAuth credential and keeps failed reads private", async () => {
|
||||
mocks.accounts.mockResolvedValue([account("claude-account", "anthropic")]);
|
||||
mocks.credential.mockResolvedValue("claude-private-oauth");
|
||||
mocks.claude.mockResolvedValue([
|
||||
{ label: "Weekly", usedPercent: 12, resetsAt: null, valueLabel: null },
|
||||
]);
|
||||
const [result] = await fetchCompanyQuotaWindows(db, "company-6", "user-6");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(mocks.claude).toHaveBeenCalledWith("claude-private-oauth", expect.any(AbortSignal));
|
||||
mocks.accounts.mockResolvedValue([account("secret-failure")]);
|
||||
mocks.credential.mockRejectedValue(
|
||||
new Error("private credential store failure"),
|
||||
);
|
||||
const [failed] = await fetchCompanyQuotaWindows(db, "company-6", "user-6");
|
||||
expect(failed.errorFamily).toBe("provider_unavailable");
|
||||
expect(JSON.stringify(failed)).not.toContain("private credential");
|
||||
});
|
||||
it("reads quota for a verified login without an optional account ID", async () => {
|
||||
mocks.accounts.mockResolvedValue([account("missing-identity")]);
|
||||
mocks.credential.mockResolvedValue(
|
||||
JSON.stringify({ accessToken: "private" }),
|
||||
);
|
||||
const [result] = await fetchCompanyQuotaWindows(db, "company-7", "user-7");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(mocks.codex).toHaveBeenCalledWith("private", null, expect.any(AbortSignal));
|
||||
});
|
||||
it("distinguishes permission errors from revoked credentials", async () => {
|
||||
mocks.accounts.mockResolvedValue([account("permission-error")]);
|
||||
mocks.codex.mockRejectedValueOnce(new Error("chatgpt wham api returned 403"));
|
||||
const [result] = await fetchCompanyQuotaWindows(db, "permission-company", "user");
|
||||
expect(result.errorFamily).toBe("permission_denied");
|
||||
expect(mocks.refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
it("refreshes an expired selected credential once before retrying quota", async () => {
|
||||
mocks.accounts.mockResolvedValue([account("refresh-account")]);
|
||||
mocks.codex.mockRejectedValueOnce(new Error("chatgpt wham api returned 401"));
|
||||
mocks.refresh.mockImplementationOnce(async () => {
|
||||
const value = JSON.stringify({ tokens: { access_token: "new-token", account_id: "same-account" } });
|
||||
revision++;
|
||||
mocks.credential.mockResolvedValue(value);
|
||||
return value;
|
||||
});
|
||||
const [result] = await fetchCompanyQuotaWindows(db, "refresh-company", "user");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(mocks.refresh).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.codex).toHaveBeenLastCalledWith("new-token", "same-account", expect.any(AbortSignal));
|
||||
expect(JSON.stringify(result)).not.toContain("new-token");
|
||||
const again = await fetchCompanyQuotaWindows(db, "refresh-company", "user");
|
||||
expect(again[0]).toEqual(result);
|
||||
expect(mocks.codex).toHaveBeenCalledTimes(2); // initial 401 and one refreshed read
|
||||
|
||||
});
|
||||
it("does not cache a quota read under a credential revision that changed during resolution", async () => {
|
||||
mocks.accounts.mockResolvedValue([account("rotating-during-refresh")]);
|
||||
mocks.codex.mockRejectedValueOnce(new Error("401"));
|
||||
mocks.refresh.mockResolvedValue(JSON.stringify({ accessToken: "refreshed" }));
|
||||
mocks.credential.mockResolvedValueOnce(JSON.stringify({ accessToken: "expired" })).mockImplementationOnce(async () => {
|
||||
revision++;
|
||||
return JSON.stringify({ accessToken: "changed-again" });
|
||||
});
|
||||
const [result] = await fetchCompanyQuotaWindows(db, "changing-revision", "user");
|
||||
expect(result).toMatchObject({ ok: false, errorFamily: "provider_unavailable" });
|
||||
expect(mocks.codex).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("bounds a stalled credential lookup and preserves its account identity", async () => {
|
||||
vi.useFakeTimers();
|
||||
mocks.accounts.mockResolvedValue([account("stalled")]);
|
||||
mocks.credential.mockReturnValue(new Promise(() => {}));
|
||||
const pending = fetchCompanyQuotaWindows(db, "company-8", "user-8");
|
||||
await vi.advanceTimersByTimeAsync(20_001);
|
||||
const [result] = await pending;
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
accountLabel: "stalled",
|
||||
windows: [],
|
||||
});
|
||||
expect(result.accountKey).toBeTruthy();
|
||||
expect(result.error).not.toContain("timed out");
|
||||
});
|
||||
it("does not start a provider request when a credential resolves after its deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
mocks.accounts.mockResolvedValue([account("late-credential")]);
|
||||
let resolveCredential!: (value: string) => void;
|
||||
mocks.credential.mockReturnValue(new Promise<string>((resolve) => { resolveCredential = resolve; }));
|
||||
const pending = fetchCompanyQuotaWindows(db, "late-company", "late-user");
|
||||
await vi.advanceTimersByTimeAsync(20_001);
|
||||
expect((await pending)[0].ok).toBe(false);
|
||||
resolveCredential(JSON.stringify({ accessToken: "private", accountId: "actual" }));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(mocks.codex).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels an active provider read when the account deadline expires", async () => {
|
||||
vi.useFakeTimers();
|
||||
mocks.accounts.mockResolvedValue([account("slow-provider")]);
|
||||
let signal!: AbortSignal;
|
||||
mocks.codex.mockImplementation((_token, _id, abortSignal) => {
|
||||
signal = abortSignal;
|
||||
return new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(signal.reason), { once: true }));
|
||||
});
|
||||
const pending = fetchCompanyQuotaWindows(db, "slow-company", "slow-user");
|
||||
await vi.advanceTimersByTimeAsync(20_001);
|
||||
expect((await pending)[0].ok).toBe(false);
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("limits account probes to four concurrent requests and preserves account order", async () => {
|
||||
mocks.accounts.mockResolvedValue(
|
||||
Array.from({ length: 9 }, (_, i) => account(`parallel-${i}`)),
|
||||
);
|
||||
mocks.credential.mockResolvedValue(
|
||||
JSON.stringify({ accessToken: "private", accountId: "actual" }),
|
||||
);
|
||||
let active = 0,
|
||||
maximum = 0;
|
||||
mocks.codex.mockImplementation(async () => {
|
||||
active++;
|
||||
maximum = Math.max(maximum, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
active--;
|
||||
return [];
|
||||
});
|
||||
const results = await fetchCompanyQuotaWindows(db, "company-9", "user-9");
|
||||
expect(maximum).toBe(4);
|
||||
expect(results.map((row) => row.accountLabel)).toEqual(
|
||||
Array.from({ length: 9 }, (_, i) => `parallel-${i}`),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -67,13 +67,17 @@ describe("company extract-search route", () => {
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(extract).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
expect(extract).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
contains: "github.com/example/repo/pull",
|
||||
kind: "url",
|
||||
scope: "comments",
|
||||
limit: 200,
|
||||
matchesPerIssue: 200,
|
||||
}));
|
||||
}),
|
||||
expect.objectContaining({ issueReadCondition: expect.anything() }),
|
||||
);
|
||||
expect(response.body).toMatchObject({ kind: "url", scope: "comments", truncated: false });
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
documents,
|
||||
issueComments,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { companySearchExtractService } from "../services/company-search-extract.js";
|
||||
import { issueReadSqlCondition, type AuthorizationActor } from "../services/authorization.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
@@ -73,6 +75,7 @@ describeEmbeddedPostgres("companySearchExtractService", () => {
|
||||
await db.delete(documents);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issues);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
@@ -266,4 +269,44 @@ describeEmbeddedPostgres("companySearchExtractService", () => {
|
||||
|
||||
expect(result.results).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not extract content from a private issue for a non-member", async () => {
|
||||
const companyId = await createCompany();
|
||||
const viewerId = randomUUID();
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId: viewerId,
|
||||
membershipRole: "operator",
|
||||
status: "active",
|
||||
});
|
||||
const visibleIssueId = await createIssue(companyId, {
|
||||
identifier: "EXT-PUBLIC",
|
||||
description: "privacy-leak-needle",
|
||||
});
|
||||
const privateIssueId = randomUUID();
|
||||
await createIssue(companyId, {
|
||||
id: privateIssueId,
|
||||
identifier: "EXT-PRIVATE",
|
||||
description: "privacy-leak-needle private payload",
|
||||
visibility: "private",
|
||||
privacyRootIssueId: privateIssueId,
|
||||
responsibleUserId: randomUUID(),
|
||||
});
|
||||
const actor: AuthorizationActor = {
|
||||
type: "board",
|
||||
userId: viewerId,
|
||||
source: "session",
|
||||
};
|
||||
|
||||
const result = await svc.extract(
|
||||
companyId,
|
||||
companySearchExtractQuerySchema.parse({ contains: "privacy-leak-needle" }),
|
||||
{ issueReadCondition: await issueReadSqlCondition(db, actor) },
|
||||
);
|
||||
|
||||
expect(result.results.map((row) => row.issueId)).toEqual([visibleIssueId]);
|
||||
expect(JSON.stringify(result)).not.toContain(privateIssueId);
|
||||
expect(JSON.stringify(result)).not.toContain("private payload");
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
documents,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
issueAccessGrants,
|
||||
issueLabels,
|
||||
issues,
|
||||
labels,
|
||||
projectAccessMembers,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import { companySearchQuerySchema, COMPANY_SEARCH_MAX_QUERY_LENGTH } from "@paperclipai/shared";
|
||||
@@ -23,6 +25,8 @@ import {
|
||||
companySearchBranchFetchLimit,
|
||||
companySearchService,
|
||||
} from "../services/company-search.js";
|
||||
import { issueReadSqlCondition, projectReadSqlCondition } from "../services/authorization.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
@@ -87,6 +91,7 @@ describeEmbeddedPostgres("companySearchService", () => {
|
||||
await db.delete(documents);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueLabels);
|
||||
await db.delete(issueAccessGrants);
|
||||
await db.delete(issues);
|
||||
await db.delete(labels);
|
||||
await db.delete(projects);
|
||||
@@ -814,4 +819,99 @@ describeEmbeddedPostgres("companySearchService", () => {
|
||||
expect(ids, `query=${query} should not match unrelated issue`).not.toContain(rejected);
|
||||
}
|
||||
});
|
||||
|
||||
it("pushes private issue filtering into list, count, and company search queries", async () => {
|
||||
const previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
||||
try {
|
||||
const companyId = await createCompany();
|
||||
const ownerId = `owner-${randomUUID()}`;
|
||||
const otherId = `other-${randomUUID()}`;
|
||||
const granteeId = `grantee-${randomUUID()}`;
|
||||
const openId = await createIssue(companyId, { title: "privacy needle open" });
|
||||
const privateId = randomUUID();
|
||||
await createIssue(companyId, {
|
||||
id: privateId,
|
||||
title: "privacy needle private",
|
||||
visibility: "private",
|
||||
privacyRootIssueId: privateId,
|
||||
responsibleUserId: ownerId,
|
||||
});
|
||||
await db.insert(issueAccessGrants).values({
|
||||
issueId: privateId,
|
||||
subjectType: "user",
|
||||
subjectId: granteeId,
|
||||
source: "explicit",
|
||||
grantedByUserId: ownerId,
|
||||
});
|
||||
|
||||
const issuesSvc = issueService(db);
|
||||
const conditionFor = (userId: string) => issueReadSqlCondition(db, {
|
||||
type: "board",
|
||||
userId,
|
||||
source: "session",
|
||||
});
|
||||
const otherCondition = await conditionFor(otherId);
|
||||
const ownerCondition = await conditionFor(ownerId);
|
||||
const granteeCondition = await conditionFor(granteeId);
|
||||
|
||||
await expect(issuesSvc.list(companyId, { readCondition: otherCondition })).resolves.toMatchObject([
|
||||
{ id: openId },
|
||||
]);
|
||||
await expect(issuesSvc.count(companyId, { readCondition: otherCondition })).resolves.toBe(1);
|
||||
await expect(issuesSvc.count(companyId, { readCondition: ownerCondition })).resolves.toBe(2);
|
||||
await expect(issuesSvc.count(companyId, { readCondition: granteeCondition })).resolves.toBe(2);
|
||||
|
||||
const result = await svc.search(
|
||||
companyId,
|
||||
companySearchQuerySchema.parse({ q: "privacy needle", scope: "issues" }),
|
||||
{ issueReadCondition: otherCondition },
|
||||
);
|
||||
expect(result.results.map((row) => row.id)).toEqual([openId]);
|
||||
} finally {
|
||||
if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode;
|
||||
}
|
||||
});
|
||||
|
||||
it("pushes private project filtering into project search and counts", async () => {
|
||||
const previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
||||
try {
|
||||
const companyId = await createCompany();
|
||||
const memberId = `member-${randomUUID()}`;
|
||||
const outsiderId = `outsider-${randomUUID()}`;
|
||||
const openId = await createProject(companyId, { name: "privacy project open" });
|
||||
const privateId = await createProject(companyId, {
|
||||
name: "privacy project private",
|
||||
visibility: "private",
|
||||
});
|
||||
await db.insert(projectAccessMembers).values({
|
||||
companyId,
|
||||
projectId: privateId,
|
||||
subjectType: "user",
|
||||
subjectId: memberId,
|
||||
});
|
||||
const conditionFor = (userId: string) => projectReadSqlCondition(db, {
|
||||
type: "board",
|
||||
userId,
|
||||
source: "session",
|
||||
});
|
||||
const query = companySearchQuerySchema.parse({ q: "privacy project", scope: "projects" });
|
||||
const outsiderResult = await svc.search(companyId, query, {
|
||||
projectReadCondition: await conditionFor(outsiderId),
|
||||
});
|
||||
expect(outsiderResult.results.map((row) => row.id)).toEqual([openId]);
|
||||
expect(outsiderResult.countsByType.project).toBe(1);
|
||||
|
||||
const memberResult = await svc.search(companyId, query, {
|
||||
projectReadCondition: await conditionFor(memberId),
|
||||
});
|
||||
expect(memberResult.results.map((row) => row.id)).toEqual(expect.arrayContaining([openId, privateId]));
|
||||
expect(memberResult.countsByType.project).toBe(2);
|
||||
} finally {
|
||||
if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { companies, companySkills, createDb, projects, projectWorkspaces } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { companySkillService } from "../services/company-skills.js";
|
||||
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
@@ -83,4 +84,63 @@ describeEmbeddedPostgres("company skill local import boundary", () => {
|
||||
details: { code: "skill_source_validation_failed" },
|
||||
});
|
||||
});
|
||||
|
||||
// Regression for LOOA-850: a `managed_checkout` project (no registered workspace row) exposes
|
||||
// only a server-derived `codebase.managedFolder`; before the fix that folder was never an
|
||||
// approved root, so every managed_checkout project's in-place skill re-import was denied.
|
||||
it("allows managed_checkout project managedFolder imports and rejects prefix-adjacent siblings", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
// Redirect the instance root at temp so the server-derived managedFolder is under our control.
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-managed-home-"));
|
||||
cleanupDirs.add(home);
|
||||
const previousHome = process.env.PAPERCLIP_HOME;
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
try {
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Managed Co",
|
||||
issuePrefix: `M${companyId.replaceAll("-", "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
// No projectWorkspaces row => origin "managed_checkout", primaryWorkspace null,
|
||||
// managedFolder = resolveManagedProjectWorkspaceDir({ companyId, projectId }) (server-derived).
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Managed project" });
|
||||
|
||||
const managedFolder = resolveManagedProjectWorkspaceDir({ companyId, projectId });
|
||||
const allowedSkill = path.join(managedFolder, ".agents", "skills", "managed-allowed");
|
||||
await fs.mkdir(allowedSkill, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(allowedSkill, "SKILL.md"),
|
||||
"---\nname: managed-allowed\ndescription: managed-allowed\n---\n# Managed Allowed\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// Prefix-adjacent sibling: its path is a string-prefix match of managedFolder but it is NOT
|
||||
// inside the subtree. Confirms the boundary uses `${root}${sep}`-anchored matching, not a bare
|
||||
// startsWith, so the added root cannot be widened by an adjacent directory name.
|
||||
const prefixAdjacent = `${managedFolder}-evil`;
|
||||
await fs.mkdir(prefixAdjacent, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(prefixAdjacent, "SKILL.md"),
|
||||
"---\nname: managed-evil\ndescription: managed-evil\n---\n# Managed Evil\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const service = companySkillService(db);
|
||||
await expect(service.importFromSource(companyId, allowedSkill)).resolves.toMatchObject({
|
||||
imported: [expect.objectContaining({ slug: "managed-allowed" })],
|
||||
});
|
||||
await expect(service.importFromSource(companyId, prefixAdjacent)).rejects.toMatchObject({
|
||||
status: 403,
|
||||
details: { code: "skill_workspace_boundary_denied" },
|
||||
});
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.PAPERCLIP_HOME;
|
||||
} else {
|
||||
process.env.PAPERCLIP_HOME = previousHome;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,10 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect(await pendingNativeGovernance({ db, ...f, executionState: null })).toBeNull();
|
||||
expect((await readCard(f.card.id)).status).toBe("pending");
|
||||
await db.update(issueThreadInteractions).set({ sourceRunId: f.runId }).where(eq(issueThreadInteractions.id, f.card.id));
|
||||
expect(await pendingNativeGovernance({ db, ...f, executionState: null })).toEqual({ kind: "interaction", id: f.card.id });
|
||||
// A later human message makes ordinary questions historical even when
|
||||
// they came from the current run; confirmations still require a decision.
|
||||
expect(await pendingNativeGovernance({ db, ...f, executionState: null }))
|
||||
.toEqual(kind === "ask_user_questions" ? null : { kind: "interaction", id: f.card.id });
|
||||
});
|
||||
|
||||
it.each(["ordinary-task", "human_only", "toolAction", "secretProposal", "connectionAuthorization"])("preserves the existing %s completion gate", async kind => {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import Ajv2020 from "ajv/dist/2020.js";
|
||||
import { requestHumanInputAction } from "../../../packages/paperclip-runner/src/protocol-actions/request-human-input.js";
|
||||
import { questionSetToAskUserQuestionsPayload } from "@paperclipai/shared";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
@@ -281,17 +284,24 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: false });
|
||||
}
|
||||
});
|
||||
it("discovers all purposes with truthful setup actions and the chat feature gate", async () => {
|
||||
it.each([false, true])("discovers AgentMail with truthful setup actions regardless of the chat setting (%s)", async (enabled) => {
|
||||
await resetQuestions();
|
||||
const service = connectionIntentService(db);
|
||||
expect((await service.search(claims, "agentmail")).results.some(item => item.service === "agentmail")).toBe(false);
|
||||
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true });
|
||||
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: enabled });
|
||||
try {
|
||||
const result = await service.search(claims, "agentmail");
|
||||
expect(result.results[0]?.methods).toEqual([expect.objectContaining({
|
||||
key: "email-agent", purpose: "channel", setupPath: `/AGG/apps/chat/connect?provider=agentmail&purpose=chat&agentId=${claims.sub}`,
|
||||
})]);
|
||||
expect(result.instruction).toContain("setupPath");
|
||||
expect(result.instruction).toContain("connection_request");
|
||||
const requested = await service.request(claims, "agentmail");
|
||||
expect(requested).toMatchObject({ state: "needs_user_action", interactionId: expect.any(String) });
|
||||
expect(await service.request(claims, "agentmail")).toMatchObject({ interactionId: requested.interactionId });
|
||||
const options = await service.setupOptions(requested.interactionId!);
|
||||
expect(options.interaction.payload).toMatchObject({ purpose: "channel", serviceSlug: "agentmail", requestingAgentId: claims.sub });
|
||||
expect(options.emailSetup).toEqual({ credentialConnectionId: null, readyConnectionId: null });
|
||||
await service.decline(requested.interactionId!, "responsible-user");
|
||||
await expect(service.request(claims, "agentmail")).rejects.toThrow(/already been resolved/);
|
||||
const browse = await service.search(claims, "");
|
||||
expect(browse.results.some(item => item.service === "agentmail")).toBe(true);
|
||||
expect(browse.results.some(item => item.service === "anthropic")).toBe(true);
|
||||
@@ -307,9 +317,34 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect(result.results.some(item => item.service === "google-sheets")).toBe(true);
|
||||
expect(result.instruction).not.toContain("search its exact name");
|
||||
});
|
||||
it("keeps installed capability matches when a generic query resembles an external app name", async () => {
|
||||
await resetQuestions();
|
||||
const installed = await seedProvider("Studio library", "notion:list_pages");
|
||||
await db.update(toolCatalogEntries).set({ description: "Read recent pages and return their titles and verification code" })
|
||||
.where(eq(toolCatalogEntries.connectionId, installed.id));
|
||||
const service = connectionIntentService(db);
|
||||
const result = await service.search(claims,
|
||||
"connected page service that can find or list recent pages and return page titles plus a verification code");
|
||||
expect(result.results).toEqual(expect.arrayContaining([expect.objectContaining({
|
||||
service: `connection:${installed.id}`, connectionId: installed.id, state: "ready",
|
||||
})]));
|
||||
expect(result.providerQuestion).toBeUndefined();
|
||||
expect(result.instruction).not.toContain("Ask the responsible user with providerQuestion");
|
||||
// A deliberate app name still reaches the existing governed provider choice.
|
||||
const named = await service.search(claims, "Page X");
|
||||
expect(named.providerQuestion?.id).toBe("connection-provider:page-x");
|
||||
expect(named.results[0]?.service).toBe("via:composio:page-x");
|
||||
});
|
||||
it("keeps an external typo as a discovery suggestion until its app is selected", async () => {
|
||||
await resetQuestions();
|
||||
const result = await connectionIntentService(db).search(claims,
|
||||
"Find Circlebak meeting transcripts and action items from yesterday");
|
||||
expect(result.results.some(item => item.service === "via:composio:circleback-mcp")).toBe(true);
|
||||
expect(result.providerQuestion).toBeUndefined();
|
||||
expect(result.instruction).toContain("aggregator.targetService");
|
||||
});
|
||||
it.each([
|
||||
["help me find tools for circle back", "circleback-mcp"],
|
||||
["Find Circlebak meeting transcripts and action items from yesterday", "circleback-mcp"],
|
||||
["Can you connect Circleback MCP to get all our meeting notes?", "circleback-mcp"],
|
||||
["Find Attio tools to review all our customer contacts before next week's meeting", "attio"],
|
||||
["Help me find a ClickUp connection to organize our team's projects", "clickup"],
|
||||
@@ -378,6 +413,18 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
"via:zapier:hubspot",
|
||||
]);
|
||||
expect(result.providerQuestion?.prompt).toContain("external service");
|
||||
const ajv = new Ajv2020({ allErrors: true, allowUnionTypes: true, strict: false });
|
||||
const input = { idempotencyKey: "provider-choice", interactionKind: "questions", title: "Choose a provider",
|
||||
prompt: result.providerQuestion!.prompt, continuationPolicy: "wake_assignee" };
|
||||
for (const payload of [{ version: 1, questionSet: result.providerQuestionSet }, { version: 1, questions: [result.providerQuestion] }]) {
|
||||
expect(ajv.validate(requestHumanInputAction.live.descriptor.inputSchema, { ...input, payload }), JSON.stringify(ajv.errors)).toBe(true);
|
||||
}
|
||||
expect(questionSetToAskUserQuestionsPayload(result.providerQuestionSet!).questions).toEqual([result.providerQuestion]);
|
||||
expect(result.instruction).toContain("questionSet:providerQuestionSet");
|
||||
expect(result.instruction).not.toContain("do not add questionSet");
|
||||
expect(ajv.validate(requestHumanInputAction.live.descriptor.inputSchema, { ...input, payload: { version: 1 } })).toBe(false);
|
||||
expect(ajv.validate(requestHumanInputAction.live.descriptor.inputSchema, { ...input, payload: { version: 1,
|
||||
questionSet: { ...result.providerQuestionSet, questions: [{ ...result.providerQuestionSet!.questions[0], answerMode: "text" }] } } })).toBe(false);
|
||||
expect(result.results.every((item) => item.state !== "ready")).toBe(true);
|
||||
expect(result.results.every((item) => item.aggregator?.evidenceUrl)).toBe(
|
||||
true,
|
||||
@@ -561,6 +608,51 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
);
|
||||
expect(result.instruction).toContain("Arcade");
|
||||
});
|
||||
it("returns exact eligible catalog names after a guessed tool without granting access", async () => {
|
||||
await resetQuestions();
|
||||
const connection = await seedProvider("arcade", "Hubspot_ListContacts", "responsible-user", false);
|
||||
await db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id));
|
||||
await seedProvider("composio", "Unrelated_PrivateTool", "other-user", false);
|
||||
await db.insert(toolCatalogEntries).values({ companyId: claims.company_id, connectionId: connection.id,
|
||||
toolName: "Hubspot_RemovedTool", name: "Removed", versionHash: "old", entryKind: "tool", status: "removed" });
|
||||
const answer = await selectProvider("via:arcade:hubspot");
|
||||
const service = connectionIntentService(db);
|
||||
const error = await service.request(claims, "via:arcade:hubspot", {
|
||||
selectionInteractionId: answer.id, toolNames: ["hubspot_list_contacts"],
|
||||
}).catch(error => error);
|
||||
expect(error).toMatchObject({ status: 422 });
|
||||
expect(error.message).toContain('"Hubspot_ListContacts"');
|
||||
expect(error.message).toContain("Request only the needed exact names");
|
||||
expect(error.message).not.toMatch(/Unrelated_PrivateTool|Hubspot_RemovedTool/);
|
||||
expect(await db.select().from(toolProfiles).where(eq(toolProfiles.companyId, claims.company_id))).toEqual([]);
|
||||
const interactions = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.companyId, claims.company_id));
|
||||
expect(interactions.map(row => [row.id, row.status])).toEqual([[answer.id, "answered"]]);
|
||||
const requested = await service.request(claims, "via:arcade:hubspot", {
|
||||
selectionInteractionId: answer.id, toolNames: ["Hubspot_ListContacts"],
|
||||
});
|
||||
expect(requested.state).toBe("needs_user_action");
|
||||
const loaded = await service.loadIntent(requested.interactionId!);
|
||||
expect(loaded.interaction.payload.accessRequest?.tools.map(tool => tool.toolName)).toEqual(["Hubspot_ListContacts"]);
|
||||
expect(loaded.interaction.status).toBe("pending");
|
||||
expect(await db.select().from(toolProfiles).where(eq(toolProfiles.companyId, claims.company_id))).toEqual([]);
|
||||
});
|
||||
it("bounds catalog-name recovery and does not disclose an ineligible connection", async () => {
|
||||
await resetQuestions();
|
||||
const connection = await seedProvider("arcade", "Read_00", "responsible-user", false);
|
||||
await db.insert(toolCatalogEntries).values(Array.from({ length: 24 }, (_, index) => ({
|
||||
companyId: claims.company_id, connectionId: connection.id, toolName: `Read_${String(index + 1).padStart(2, "0")}`,
|
||||
name: `Read_${String(index + 1).padStart(2, "0")}`, versionHash: "v1", entryKind: "tool" as const, status: "active" as const,
|
||||
})));
|
||||
const service = connectionIntentService(db);
|
||||
const error = await service.request(claims, "arcade", { connectionId: connection.id, toolNames: ["guessed"] }).catch(error => error);
|
||||
expect(error).toMatchObject({ status: 422 });
|
||||
expect(error.message).toContain("first 20 of 25");
|
||||
expect(error.message.match(/Read_\d{2}/g)).toHaveLength(20);
|
||||
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.connectionId, connection.id));
|
||||
const denied = await service.request(claims, "arcade", { connectionId: connection.id, toolNames: ["guessed"] }).catch(error => error);
|
||||
expect(denied.message).not.toContain("Read_");
|
||||
expect(denied.message).toContain("not eligible");
|
||||
});
|
||||
it("does not switch providers when the chosen route loses permission", async () => {
|
||||
await resetQuestions();
|
||||
const answer = await selectProvider("via:arcade:hubspot");
|
||||
|
||||
@@ -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({ provider: { kind: "codex" }, 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);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { and, eq, sql } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
authUsers,
|
||||
aiProviderDefaults,
|
||||
agentWakeupRequests,
|
||||
issueComments,
|
||||
@@ -24,6 +25,8 @@ import {
|
||||
toolConnections,
|
||||
toolProfileBindings,
|
||||
toolProfiles,
|
||||
toolPolicies,
|
||||
toolProfileEntries,
|
||||
userSecretDefinitions,
|
||||
} from "@paperclipai/db";
|
||||
import type { RuntimeToolsTokenClaims } from "../runtime-tools-token.js";
|
||||
@@ -32,6 +35,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 +47,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 +81,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,6 +111,7 @@ describeEmbeddedPostgres("connectionIntentService", () => {
|
||||
issuePrefix: "CONN",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(authUsers).values({ id: "responsible-user", name: "Responsible user", email: "responsible@example.test", createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
@@ -858,4 +875,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([]);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -221,6 +221,21 @@ describe("cost routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("requires an explicit all-time request and rejects conflicting ranges", () => {
|
||||
const { parseCostDateRange } = loadCostParsers();
|
||||
expect(parseCostDateRange({})).toBeUndefined();
|
||||
expect(parseCostDateRange({ period: "all" })).toEqual({ allTime: true });
|
||||
for (const input of [
|
||||
{ from: "2026-09-02", to: "2026-09-01" },
|
||||
{ from: ["2026-09-01"] },
|
||||
{ from: "2026-02-30" },
|
||||
{ from: "" },
|
||||
{ from: "09/01/2026" },
|
||||
{ period: "all", from: "2026-09-01" },
|
||||
{ period: "unknown" },
|
||||
]) expect(() => parseCostDateRange(input)).toThrow();
|
||||
});
|
||||
|
||||
it("returns 400 for an invalid 'from' date string", async () => {
|
||||
const { parseCostDateRange } = loadCostParsers();
|
||||
expect(() => parseCostDateRange({ from: "not-a-date" })).toThrow(/invalid 'from' date/i);
|
||||
@@ -270,7 +285,7 @@ describe("cost routes", () => {
|
||||
|
||||
it("returns 400 for invalid finance event list limits", async () => {
|
||||
const { parseCostLimit } = loadCostParsers();
|
||||
expect(() => parseCostLimit({ limit: "0" })).toThrow(/invalid 'limit'/i);
|
||||
for (const limit of ["0", "25garbage", "1.5", ["25"], "501"]) expect(() => parseCostLimit({ limit })).toThrow(/invalid 'limit'/i);
|
||||
});
|
||||
|
||||
it("accepts valid finance event list limits", async () => {
|
||||
@@ -352,7 +367,8 @@ describe("cost routes", () => {
|
||||
});
|
||||
|
||||
it("allows authorized board users to update an agent budget and budget policy", async () => {
|
||||
mockAgentService.update.mockResolvedValueOnce({
|
||||
mockAgentService.getById.mockResolvedValueOnce({ id: "agent-1", companyId: "company-1", name: "Budget Agent", budgetMonthlyCents: 100, spentMonthlyCents: 0 });
|
||||
mockAgentService.getById.mockResolvedValueOnce({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Budget Agent",
|
||||
@@ -373,30 +389,22 @@ describe("cost routes", () => {
|
||||
.send({ budgetMonthlyCents: 2500 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith("agent-1", { budgetMonthlyCents: 2500 });
|
||||
expect(res.body.budgetMonthlyCents).toBe(2500);
|
||||
expect(mockAgentService.update).not.toHaveBeenCalled();
|
||||
expect(mockBudgetService.upsertPolicy).toHaveBeenCalledTimes(1);
|
||||
expect(mockBudgetService.upsertPolicy).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
{
|
||||
scopeType: "agent",
|
||||
scopeId: "agent-1",
|
||||
amount: 2500,
|
||||
isActive: true,
|
||||
windowKind: "calendar_month_utc",
|
||||
},
|
||||
"board-user",
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
actorType: "user",
|
||||
actorId: "board-user",
|
||||
agentId: null,
|
||||
action: "agent.budget_updated",
|
||||
entityType: "agent",
|
||||
entityId: "agent-1",
|
||||
details: { budgetMonthlyCents: 2500 },
|
||||
}),
|
||||
);
|
||||
// Policy mutation owns the atomic entity update and activity log.
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -543,7 +551,9 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => {
|
||||
const [byAgentModelRow] = await costs.byAgentModel(companyId, range);
|
||||
|
||||
expect(byAgentRow?.costCents).toBe(4_000_000_000);
|
||||
expect(byAgentRow?.inputTokens).toBe(4_000_000_000);
|
||||
// Historical OpenAI input includes these ten cached tokens.
|
||||
expect(byAgentRow?.inputTokens).toBe(3_999_999_990);
|
||||
expect(byAgentRow!.inputTokens + byAgentRow!.cachedInputTokens).toBe(4_000_000_000);
|
||||
expect(byProjectRow?.costCents).toBe(4_000_000_000);
|
||||
expect(byAgentModelRow?.costCents).toBe(4_000_000_000);
|
||||
});
|
||||
@@ -692,7 +702,8 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => {
|
||||
issueCount: 3,
|
||||
includeDescendants: true,
|
||||
costCents: 600,
|
||||
inputTokens: 60,
|
||||
costCentsExact: "600.0000000",
|
||||
inputTokens: 54,
|
||||
cachedInputTokens: 6,
|
||||
outputTokens: 12,
|
||||
runCount: 0,
|
||||
|
||||
@@ -4,6 +4,39 @@ import { parseCursorStdoutLine } from "@paperclipai/adapter-cursor-local/ui";
|
||||
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
|
||||
|
||||
describe("cursor parser", () => {
|
||||
it.each([
|
||||
{ type: "result" },
|
||||
{ type: "result", usage: {} },
|
||||
{ type: "result", usage: { input_tokens: 0 } },
|
||||
{ type: "result", usage: { input_tokens: -1, output_tokens: 0 } },
|
||||
{ type: "step_finish", part: {} },
|
||||
])("does not treat absent or malformed counters as reported zeroes (%j)", event => {
|
||||
const parsed = parseCursorJsonl(JSON.stringify(event));
|
||||
expect(parsed.usageReported).toBe(false);
|
||||
expect(parsed.usageComplete).toBe(false);
|
||||
expect(parsed.costUsd).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ type: "result", usage: { input_tokens: 0, output_tokens: 0 } },
|
||||
{ type: "result", usage: { inputTokens: 0, outputTokens: 0 } },
|
||||
{ type: "step_finish", part: { tokens: { input: 0, output: 0 } } },
|
||||
])("preserves explicitly reported zero counters (%j)", event => {
|
||||
expect(parseCursorJsonl(JSON.stringify(event))).toMatchObject({
|
||||
usageReported: true, usageComplete: true,
|
||||
usage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps known partial usage without claiming a complete total", () => {
|
||||
const parsed = parseCursorJsonl([
|
||||
{ type: "step_finish", part: { tokens: { input: 20, output: 5 } } },
|
||||
{ type: "result" },
|
||||
].map(event => JSON.stringify(event)).join("\n"));
|
||||
expect(parsed).toMatchObject({ usageReported: true, usageComplete: false,
|
||||
usage: { inputTokens: 20, outputTokens: 5 }, costUsd: null });
|
||||
});
|
||||
|
||||
it("extracts session, summary, usage, cost, and terminal error message", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({ type: "system", subtype: "init", session_id: "chat_123", model: "gpt-5" }),
|
||||
@@ -403,3 +436,12 @@ describe("cursor cli formatter", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("Cursor price completeness", () => {
|
||||
it("preserves reported zero and keeps a partially priced stream unpriced", () => {
|
||||
expect(parseCursorJsonl(JSON.stringify({ type: "result", cost_usd: 0 })).costUsd).toBe(0);
|
||||
expect(parseCursorJsonl(JSON.stringify({ type: "result" })).costUsd).toBeNull();
|
||||
expect(parseCursorJsonl([JSON.stringify({ type: "step_finish", part: { cost: 1 } }), JSON.stringify({ type: "step_finish", part: {} })].join("\n")).costUsd).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { runChildProcess } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { execute } from "@paperclipai/adapter-cursor-local/server";
|
||||
|
||||
// These fixtures verify command discovery, environment and real CLI execution.
|
||||
// Callback bridge startup/teardown has its own execution-target coverage; starting
|
||||
// that server here adds unrelated process and polling work to command assertions.
|
||||
vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/execution-target")>(
|
||||
"@paperclipai/adapter-utils/execution-target",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
startAdapterExecutionTargetPaperclipBridge: async () => null,
|
||||
};
|
||||
});
|
||||
|
||||
async function writeFakeCursorCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
@@ -88,6 +101,11 @@ function createLocalSandboxRunner() {
|
||||
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
}) => {
|
||||
counter += 1;
|
||||
// Both sandbox fixtures install their fake CLI up front. Never fall back
|
||||
// to a network install if discovery regresses on another host or shell.
|
||||
if (input.args?.some(arg => arg.includes("curl https://cursor.com/install"))) {
|
||||
throw new Error("Sandbox fixture did not discover its preinstalled Cursor CLI");
|
||||
}
|
||||
return await runChildProcess(`cursor-sandbox-execute-${counter}`, input.command, input.args ?? [], {
|
||||
cwd: input.cwd ?? process.cwd(),
|
||||
env: input.env ?? {},
|
||||
@@ -388,7 +406,7 @@ describe("cursor execute", () => {
|
||||
else process.env.HOME = previousHome;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
it("keeps explicit command overrides for remote sandbox execution", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-sandbox-explicit-"));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import express from "express";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
createDb,
|
||||
agents,
|
||||
companies,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
} from "@paperclipai/db";
|
||||
import { customerSuccessRoutes } from "../routes/customer-success.js";
|
||||
import { agentIdentityService } from "../services/agent-identity.js";
|
||||
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
import { execute } from "../adapters/process/execute.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
|
||||
// Coordinated qualification against the sibling Cloud build, never a customer
|
||||
// stack or hosted provider. Default unit runs have no cross-repository dependency.
|
||||
const cloudDist = process.env.PAPERCLIP_INSPECTION_CLOUD_DIST;
|
||||
describe.skipIf(!cloudDist)("customer-success managed agent / Cloud / tenant qualification", () => {
|
||||
it("uses the existing managed-runtime identity, active JWT, PostgreSQL broker, HTTP permits, and audited tenant reads", async () => {
|
||||
const load = (module: string) => import(pathToFileURL(resolve(cloudDist!, module)).href);
|
||||
const [
|
||||
{ CustomerSuccessInspection },
|
||||
{ InspectionSigner },
|
||||
{ PostgresInspectionStore },
|
||||
{ inspectionRoute },
|
||||
{ InMemoryCloudHarnessRegistry },
|
||||
{ CloudStackSleepController },
|
||||
pg,
|
||||
] = await Promise.all([
|
||||
load("customer-success/service.js"),
|
||||
load("customer-success/crypto.js"),
|
||||
load("customer-success/store.js"),
|
||||
load("customer-success/routes.js"),
|
||||
load("provisioner/memory.js"),
|
||||
load("sleep/controller.js"),
|
||||
load("../node_modules/pg/lib/index.js"),
|
||||
]);
|
||||
const home = await startEmbeddedPostgresTestDatabase("inspection-home-");
|
||||
const tenant = await startEmbeddedPostgresTestDatabase("inspection-customer-");
|
||||
const directory = await mkdtemp(join(tmpdir(), "inspection-journey-"));
|
||||
const servers: ReturnType<typeof createServer>[] = [];
|
||||
const serverErrors: string[] = [];
|
||||
const pool = new pg.default.Pool({
|
||||
connectionString: home.connectionString,
|
||||
max: 2,
|
||||
connectionTimeoutMillis: 1000,
|
||||
});
|
||||
try {
|
||||
vi.stubEnv("PAPERCLIP_HOME", directory);
|
||||
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "qualification-home");
|
||||
vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", "isolated-qualification-jwt");
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", join(directory, "master.key"));
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "");
|
||||
vi.stubEnv("PAPERCLIP_CUSTOMER_SUCCESS_AUTHORITY_ENABLED", "true");
|
||||
vi.stubEnv("PAPERCLIP_CUSTOMER_SUCCESS_INSPECTION_ENABLED", "true");
|
||||
vi.stubEnv("PAPERCLIP_CLOUD_STACK_ID", "qualification-stack");
|
||||
const homeDb = createDb(home.connectionString);
|
||||
const tenantDb = createDb(tenant.connectionString);
|
||||
const [homeCompany] = await homeDb
|
||||
.insert(companies)
|
||||
.values({ name: "Internal", issuePrefix: "INT" })
|
||||
.returning();
|
||||
const [agent] = await homeDb
|
||||
.insert(agents)
|
||||
.values({
|
||||
companyId: homeCompany.id,
|
||||
name: "Success qualification",
|
||||
adapterType: "process",
|
||||
})
|
||||
.returning();
|
||||
const identity = await agentIdentityService(homeDb).ensureAgentIdentity(
|
||||
homeCompany.id,
|
||||
agent.id,
|
||||
);
|
||||
const [run] = await homeDb
|
||||
.insert(heartbeatRuns)
|
||||
.values({
|
||||
companyId: homeCompany.id,
|
||||
agentId: agent.id,
|
||||
status: "running",
|
||||
startedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
const [customer] = await tenantDb
|
||||
.insert(companies)
|
||||
.values({ name: "Synthetic customer", issuePrefix: "SYN" })
|
||||
.returning();
|
||||
const [project] = await tenantDb
|
||||
.insert(projects)
|
||||
.values({ companyId: customer.id, name: "Qualification project" })
|
||||
.returning();
|
||||
const [workspace] = await tenantDb
|
||||
.insert(projectWorkspaces)
|
||||
.values({
|
||||
companyId: customer.id,
|
||||
projectId: project.id,
|
||||
name: "Qualification files",
|
||||
cwd: directory,
|
||||
})
|
||||
.returning();
|
||||
const filePath = join(directory, "result.bin");
|
||||
await writeFile(filePath, Buffer.from([0, 1, 2, 3, 4, 5]));
|
||||
const fileBefore = await stat(filePath);
|
||||
const [task] = await tenantDb
|
||||
.insert(issues)
|
||||
.values({ companyId: customer.id, projectId: project.id, title: "First successful task" })
|
||||
.returning();
|
||||
const storage = { provider: "local_disk" } as StorageService;
|
||||
const start = async (handler: Parameters<typeof createServer>[0]) => {
|
||||
const server = createServer(handler);
|
||||
servers.push(server);
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
return `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
};
|
||||
const homeApp = express();
|
||||
homeApp.use(express.json());
|
||||
homeApp.use("/api/customer-success/v1", customerSuccessRoutes(homeDb, storage));
|
||||
homeApp.use(errorHandler);
|
||||
const sourceOrigin = await start(homeApp);
|
||||
const tenantApp = express();
|
||||
tenantApp.use(express.json());
|
||||
tenantApp.use("/api/customer-success/v1", customerSuccessRoutes(tenantDb, storage));
|
||||
tenantApp.use(errorHandler);
|
||||
const tenantOrigin = await start(tenantApp);
|
||||
await pool.query("CREATE SCHEMA cloud_harness");
|
||||
await pool.query(
|
||||
await readFile(
|
||||
resolve(cloudDist!, "../migrations/0058_customer_success_inspection.sql"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const registry = new InMemoryCloudHarnessRegistry();
|
||||
let transactionReads = 0;
|
||||
const registryForClient = (client: any) => ({
|
||||
getStack: async (id: string) => {
|
||||
await client.query("SELECT 1");
|
||||
transactionReads++;
|
||||
return registry.getStack(id);
|
||||
},
|
||||
getAccount: async (id: string) => {
|
||||
await client.query("SELECT 1");
|
||||
transactionReads++;
|
||||
return registry.getCustomerSuccessAccount(id);
|
||||
},
|
||||
listStackIds: async () => registry.listCustomerSuccessStackIds(),
|
||||
});
|
||||
const store = new PostgresInspectionStore(pool, registryForClient);
|
||||
const now = new Date();
|
||||
registry.accountGroups.set("fixture-account", {
|
||||
id: "fixture-account",
|
||||
kind: "customer",
|
||||
billingStatus: "unconfigured",
|
||||
});
|
||||
registry.stacks.set("qualification-stack", {
|
||||
id: "qualification-stack",
|
||||
accountGroupId: "fixture-account",
|
||||
displayName: "Synthetic customer",
|
||||
primaryHost: "fixture.example.test",
|
||||
kind: "customer_production",
|
||||
lifecycleState: "sleeping",
|
||||
sleepState: "sleeping",
|
||||
sleepReason: "idle",
|
||||
version: 1,
|
||||
updatedAt: now,
|
||||
rolloutMetadata: { track: "stable" },
|
||||
providerRefs: [
|
||||
{
|
||||
provider: "railway",
|
||||
resourceType: "service_domain:web",
|
||||
externalId: "fixture",
|
||||
metadata: { upstreamOrigin: tenantOrigin },
|
||||
},
|
||||
],
|
||||
createdAt: now,
|
||||
});
|
||||
const signer = new InspectionSigner(
|
||||
"qualification",
|
||||
generateKeyPairSync("ed25519").privateKey.export({ format: "jwk" }),
|
||||
);
|
||||
vi.stubEnv("PAPERCLIP_CUSTOMER_SUCCESS_INSPECTION_JWKS", signer.publicJwks);
|
||||
// Use the production wake controller with a disposable local provider.
|
||||
// The tenant HTTP process is already listening; no hosted stack is woken.
|
||||
const sleep = new CloudStackSleepController({
|
||||
registry,
|
||||
provider: {
|
||||
name: "fake",
|
||||
wakeStack: async (
|
||||
_id: string,
|
||||
context: { stack: Record<string, unknown>; providerRefs: unknown[] },
|
||||
) => ({
|
||||
stack: { ...context.stack, lifecycleState: "active", sleepState: "awake" },
|
||||
resources: context.providerRefs,
|
||||
secretRefs: { providerAdminCredentials: {} },
|
||||
operations: [],
|
||||
}),
|
||||
inspectStack: async () => ({
|
||||
stackId: "qualification-stack",
|
||||
lifecycleState: "active",
|
||||
sleepState: "awake",
|
||||
resources: registry.stacks.get("qualification-stack").providerRefs,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const broker = new CustomerSuccessInspection({
|
||||
store,
|
||||
registry,
|
||||
signer,
|
||||
allowLoopback: true,
|
||||
wakeStack: async (stackId: string) => {
|
||||
await sleep.wakeStack({ stackId });
|
||||
},
|
||||
});
|
||||
const cloudOrigin = await start(async (req, res) => {
|
||||
try {
|
||||
const handled = await inspectionRoute({
|
||||
req,
|
||||
res,
|
||||
url: new URL(req.url!, "http://localhost"),
|
||||
service: broker,
|
||||
authenticateHuman: async () => {
|
||||
throw new Error("Qualification never uses human access");
|
||||
},
|
||||
readJson: async (request: AsyncIterable<Buffer>, max: number) => {
|
||||
const parts: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
for await (const part of request) {
|
||||
bytes += part.length;
|
||||
if (bytes > max) throw new Error("Too large");
|
||||
parts.push(part);
|
||||
}
|
||||
return JSON.parse(Buffer.concat(parts).toString());
|
||||
},
|
||||
});
|
||||
if (!handled) {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
}
|
||||
} catch (error) {
|
||||
serverErrors.push(String(error));
|
||||
res.statusCode = 503;
|
||||
res.end("{}");
|
||||
}
|
||||
});
|
||||
vi.stubEnv("PAPERCLIP_CUSTOMER_SUCCESS_CLOUD_ORIGIN", cloudOrigin);
|
||||
await broker.configure("qualification-operator", {
|
||||
binding: {
|
||||
sourceOrigin,
|
||||
instanceId: "qualification-home",
|
||||
companyId: homeCompany.id,
|
||||
agentId: agent.id,
|
||||
keyId: identity.keyId,
|
||||
publicKeyPem: identity.publicKeyPem,
|
||||
},
|
||||
});
|
||||
await broker.configure("qualification-operator", { enabled: true });
|
||||
const program = `
|
||||
const {inspectionAgentClient}=await import(${JSON.stringify(pathToFileURL(resolve(cloudDist!, "customer-success/client.js")).href)});
|
||||
const call=inspectionAgentClient();
|
||||
const discovery=await call('discover',{});
|
||||
const grant=await call('grant',{stackId:'qualification-stack'});
|
||||
const tasks=await call('read',{stackId:'qualification-stack',grantToken:grant.token,query:{operation:'list',resource:'tasks',companyId:${JSON.stringify(customer.id)}}});
|
||||
const file=await call('read',{stackId:'qualification-stack',grantToken:grant.token,query:{operation:'files.download',companyId:${JSON.stringify(customer.id)},resourceId:${JSON.stringify(task.id)},context:{projectId:${JSON.stringify(project.id)},workspaceId:${JSON.stringify(workspace.id)}},path:'result.bin',range:{start:2,end:5}}});
|
||||
console.log(JSON.stringify({stackCount:discovery.items.length,titles:tasks.items.map(t=>t.title),fileBytes:[...Buffer.from(file.content,'base64')]}));
|
||||
`;
|
||||
const programFile = join(directory, "qualify.mjs");
|
||||
await writeFile(programFile, program);
|
||||
const output: string[] = [];
|
||||
const result = await execute({
|
||||
runId: run.id,
|
||||
agent,
|
||||
agentIdentity: identity,
|
||||
authToken: createLocalAgentJwt(agent.id, homeCompany.id, "process", run.id)!,
|
||||
config: {
|
||||
command: process.execPath,
|
||||
args: [programFile],
|
||||
cwd: directory,
|
||||
env: { PAPERCLIP_CUSTOMER_SUCCESS_CLOUD_ORIGIN: cloudOrigin },
|
||||
},
|
||||
context: {},
|
||||
runtime: { sessionId: null, sessionParams: null, taskKey: null },
|
||||
onLog: async (_stream, chunk) => {
|
||||
output.push(chunk);
|
||||
},
|
||||
onMeta: async () => {},
|
||||
});
|
||||
expect(result.exitCode, [...output, ...serverErrors].join("\n")).toBe(0);
|
||||
expect(output.join("")).toContain("First successful task");
|
||||
expect(output.join("")).toContain('"fileBytes":[2,3,4,5]');
|
||||
expect((await stat(filePath)).mtimeMs).toBe(fileBefore.mtimeMs);
|
||||
expect(await readFile(filePath)).toEqual(Buffer.from([0, 1, 2, 3, 4, 5]));
|
||||
const audit = await pool.query(
|
||||
"SELECT event FROM cloud_harness.customer_success_audit ORDER BY occurred_at",
|
||||
);
|
||||
expect(audit.rows.map((r: { event: string }) => r.event)).toContain("permit.consumed");
|
||||
expect(audit.rows.map((r: { event: string }) => r.event)).toContain("read.completed");
|
||||
expect(audit.rows.map((r: { event: string }) => r.event)).toContain("stack.woken");
|
||||
expect(await tenantDb.select().from(issues)).toEqual([task]);
|
||||
// The production SQL reader scopes before pagination and hides mixed
|
||||
// approval cohorts and global policy records from tenant approvers.
|
||||
await store.transaction(async (tx: any) => {
|
||||
const localId = randomUUID();
|
||||
const mixedId = randomUUID();
|
||||
const foreignId = randomUUID();
|
||||
for (const [id, detail, stackId] of [
|
||||
[localId, { stackIds: ["qualification-stack"] }, undefined],
|
||||
[mixedId, { stackIds: ["qualification-stack", "foreign-stack"] }, undefined],
|
||||
[foreignId, {}, "foreign-stack"],
|
||||
])
|
||||
await tx.audit({ id, at: tx.now, event: "scope.fixture", detail, stackId });
|
||||
const scoped = await tx.audits(0, 100, ["qualification-stack"]);
|
||||
expect(scoped.some((event: any) => event.id === localId)).toBe(true);
|
||||
expect(scoped.some((event: any) => event.id === mixedId || event.id === foreignId)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(scoped.some((event: any) => event.event === "policy.changed")).toBe(false);
|
||||
expect(await tx.audits(0, 100, [])).toEqual([]);
|
||||
const last = await tx.audits(scoped.length - 1, 100, ["qualification-stack"]);
|
||||
expect(last).toHaveLength(1);
|
||||
});
|
||||
// Two independent pool connections prove consumption is durable, not a
|
||||
// process-local Set: exactly one replica can consume a signed challenge.
|
||||
const token = createLocalAgentJwt(agent.id, homeCompany.id, "process", run.id)!;
|
||||
const c = await broker.challenge(token, "discover", {});
|
||||
const { sign } = await import("node:crypto");
|
||||
const sig = sign(null, Buffer.from(c.bytes), identity.privateKeyPem).toString("base64url");
|
||||
const replica = new CustomerSuccessInspection({
|
||||
store: new PostgresInspectionStore(pool, registryForClient),
|
||||
registry,
|
||||
signer,
|
||||
allowLoopback: true,
|
||||
});
|
||||
const competing = await Promise.allSettled([
|
||||
broker.execute(token, c.challengeId, sig),
|
||||
replica.execute(token, c.challengeId, sig),
|
||||
]);
|
||||
expect(competing.filter((r) => r.status === "fulfilled")).toHaveLength(1);
|
||||
const grants = await Promise.all(
|
||||
Array.from({ length: 4 }, () =>
|
||||
broker.challenge(token, "grant", { stackId: "qualification-stack" }),
|
||||
),
|
||||
);
|
||||
const concurrentGrants = await Promise.all(
|
||||
grants.map((proof) =>
|
||||
broker.execute(
|
||||
token,
|
||||
proof.challengeId,
|
||||
sign(null, Buffer.from(proof.bytes), identity.privateKeyPem).toString("base64url"),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(concurrentGrants).toHaveLength(4);
|
||||
expect(transactionReads).toBeGreaterThan(0);
|
||||
// Prove append-only enforcement with a runtime role, even if someone
|
||||
// accidentally grants it UPDATE/DELETE privileges on the audit table.
|
||||
const client = await pool.connect();
|
||||
const runtimeRole = `inspection_runtime_${randomUUID().replaceAll("-", "")}`;
|
||||
try {
|
||||
await client.query(`CREATE ROLE ${runtimeRole}`);
|
||||
await client.query(`GRANT USAGE ON SCHEMA cloud_harness TO ${runtimeRole}`);
|
||||
await client.query(
|
||||
`GRANT SELECT, UPDATE, DELETE ON cloud_harness.customer_success_audit TO ${runtimeRole}`,
|
||||
);
|
||||
await client.query(`SET ROLE ${runtimeRole}`);
|
||||
await expect(
|
||||
client.query("UPDATE cloud_harness.customer_success_audit SET event = 'rewritten'"),
|
||||
).rejects.toThrow("append-only");
|
||||
await expect(
|
||||
client.query("DELETE FROM cloud_harness.customer_success_audit"),
|
||||
).rejects.toThrow("append-only");
|
||||
} finally {
|
||||
await client.query("RESET ROLE");
|
||||
client.release();
|
||||
}
|
||||
const { applyInspectionRetention } = await load("customer-success/retention.js");
|
||||
await expect(applyInspectionRetention(pool, 364)).rejects.toThrow("at least one year");
|
||||
await pool.query(
|
||||
"INSERT INTO cloud_harness.customer_success_audit VALUES ($1,clock_timestamp() - interval '366 days','old.fixture','{}')",
|
||||
[randomUUID()],
|
||||
);
|
||||
expect((await applyInspectionRetention(pool)).auditDeleted).toBe(1);
|
||||
expect(
|
||||
(await pool.query("SELECT count(*) FROM cloud_harness.customer_success_audit")).rows[0]
|
||||
.count,
|
||||
).not.toBe("0");
|
||||
} finally {
|
||||
for (const server of servers.reverse())
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await pool.end();
|
||||
await tenant.cleanup();
|
||||
await home.cleanup();
|
||||
vi.unstubAllEnvs();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
@@ -0,0 +1,605 @@
|
||||
import { randomUUID, generateKeyPairSync, createHash, createHmac, sign } from "node:crypto";
|
||||
import { mkdtemp, writeFile, rm, symlink, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { Readable } from "node:stream";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import * as schema from "@paperclipai/db";
|
||||
import {
|
||||
INSPECTION_AUDIENCE,
|
||||
INSPECTION_HEADER,
|
||||
INSPECTION_PERMIT_TYPE,
|
||||
INSPECTION_RESOURCES,
|
||||
parseInspectionQuery,
|
||||
type InspectionQuery,
|
||||
type InspectionPermit,
|
||||
} from "@paperclipai/shared";
|
||||
import { customerSuccessRunAuthority } from "../services/customer-success-authority.js";
|
||||
import {
|
||||
readCustomerSuccessResource,
|
||||
inspectionReaders,
|
||||
} from "../services/customer-success-inspection.js";
|
||||
import { customerSuccessRoutes, verifyInspectionPermit } from "../routes/customer-success.js";
|
||||
import { agentIdentityService } from "../services/agent-identity.js";
|
||||
import { createLocalAgentJwt, verifyLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { redactAgentAdapterConfig } from "../redaction.js";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
describe("customer-success read authority", () => {
|
||||
let db: schema.Db;
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let directory: string;
|
||||
let companyId: string;
|
||||
let otherCompanyId: string;
|
||||
let agentId: string;
|
||||
let runId: string;
|
||||
let bearer: string;
|
||||
const storage: StorageService = {
|
||||
provider: "local_disk",
|
||||
putFile: async () => {
|
||||
throw new Error("write forbidden");
|
||||
},
|
||||
deleteObject: async () => {
|
||||
throw new Error("write forbidden");
|
||||
},
|
||||
headObject: async () => ({ exists: true }),
|
||||
getObject: async (_companyId, _objectKey, opts) => ({
|
||||
stream: Readable.from([
|
||||
Buffer.from([0, 1, 2, 3, 4]).subarray(
|
||||
opts?.range?.start ?? 0,
|
||||
opts?.range ? opts.range.end + 1 : undefined,
|
||||
),
|
||||
]),
|
||||
contentType: "application/octet-stream",
|
||||
}),
|
||||
};
|
||||
beforeAll(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "inspection-test-"));
|
||||
vi.stubEnv("PAPERCLIP_HOME", directory);
|
||||
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "inspection-home");
|
||||
vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", "inspection-local-fixture");
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", join(directory, "master.key"));
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "");
|
||||
database = await startEmbeddedPostgresTestDatabase("inspection-db-");
|
||||
db = schema.createDb(database.connectionString);
|
||||
const companies = await db
|
||||
.insert(schema.companies)
|
||||
.values([
|
||||
{ name: "Customer", issuePrefix: "CUS" },
|
||||
{ name: "Other", issuePrefix: "OTH" },
|
||||
])
|
||||
.returning();
|
||||
companyId = companies[0].id;
|
||||
otherCompanyId = companies[1].id;
|
||||
const [agent] = await db
|
||||
.insert(schema.agents)
|
||||
.values({ companyId, name: "Managed inspection fixture", adapterType: "process" })
|
||||
.returning();
|
||||
agentId = agent.id;
|
||||
const [run] = await db
|
||||
.insert(schema.heartbeatRuns)
|
||||
.values({ companyId, agentId, status: "running", startedAt: new Date() })
|
||||
.returning();
|
||||
runId = run.id;
|
||||
bearer = createLocalAgentJwt(agentId, companyId, "process", runId)!;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await database?.cleanup();
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("unprovisioned reads never generate a key, strict authority requires a current managed run", async () => {
|
||||
await expect(customerSuccessRunAuthority(db, bearer)).rejects.toThrow("unprovisioned");
|
||||
expect(await db.select().from(schema.agentIdentityKeys)).toHaveLength(0);
|
||||
const key = await agentIdentityService(db).ensureAgentIdentity(companyId, agentId);
|
||||
expect(await customerSuccessRunAuthority(db, bearer)).toEqual({
|
||||
version: 1,
|
||||
active: true,
|
||||
instanceId: "inspection-home",
|
||||
companyId,
|
||||
agentId,
|
||||
runId,
|
||||
keyId: key.keyId,
|
||||
});
|
||||
await expect(customerSuccessRunAuthority(db, "ordinary-api-key")).rejects.toThrow(
|
||||
"managed-run",
|
||||
);
|
||||
await db.update(schema.agents).set({ status: "paused" }).where(eq(schema.agents.id, agentId));
|
||||
await expect(customerSuccessRunAuthority(db, bearer)).rejects.toThrow("not active");
|
||||
await db.update(schema.agents).set({ status: "idle" }).where(eq(schema.agents.id, agentId));
|
||||
await db
|
||||
.update(schema.heartbeatRuns)
|
||||
.set({ resultJson: { executionCancellation: { state: "requested" } } })
|
||||
.where(eq(schema.heartbeatRuns.id, runId));
|
||||
await expect(customerSuccessRunAuthority(db, bearer)).rejects.toThrow("not active");
|
||||
await db
|
||||
.update(schema.heartbeatRuns)
|
||||
.set({ resultJson: null, status: "succeeded" })
|
||||
.where(eq(schema.heartbeatRuns.id, runId));
|
||||
await expect(customerSuccessRunAuthority(db, bearer)).rejects.toThrow("not active");
|
||||
await db
|
||||
.update(schema.heartbeatRuns)
|
||||
.set({ status: "running" })
|
||||
.where(eq(schema.heartbeatRuns.id, runId));
|
||||
});
|
||||
it("rejects legacy signatures, missing instance claims and foreign-instance derived tokens", async () => {
|
||||
const [header, payload] = bearer.split(".");
|
||||
const input = `${header}.${payload}`;
|
||||
const legacy = `${input}.${createHmac("sha256", "inspection-local-fixture").update(input).digest("base64url")}`;
|
||||
expect(verifyLocalAgentJwt(legacy)).not.toBeNull();
|
||||
expect(verifyLocalAgentJwt(legacy, { strictRunAuthority: true })).toBeNull();
|
||||
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "development-clone");
|
||||
const foreign = createLocalAgentJwt(agentId, companyId, "process", runId)!;
|
||||
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "inspection-home");
|
||||
await expect(customerSuccessRunAuthority(db, foreign)).rejects.toThrow("managed-run");
|
||||
});
|
||||
it("every catalog reader is company scoped, paginated, and leaves the complete database unchanged", async () => {
|
||||
const snapshot = async () => {
|
||||
const tables = await db.execute(
|
||||
sql`select tablename from pg_tables where schemaname = 'public' order by tablename`,
|
||||
);
|
||||
const data: Record<string, unknown> = {};
|
||||
for (const table of tables) {
|
||||
const name = String(table.tablename).replaceAll('"', '""');
|
||||
data[name] = await db.execute(
|
||||
sql.raw(
|
||||
`SELECT coalesce(jsonb_agg(to_jsonb(t) ORDER BY to_jsonb(t)::text),'[]'::jsonb) AS rows FROM "${name}" t`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return JSON.stringify(data);
|
||||
};
|
||||
const before = await snapshot();
|
||||
for (const resource of INSPECTION_RESOURCES) {
|
||||
const result = (await readCustomerSuccessResource(db, storage, {
|
||||
operation: "list",
|
||||
resource,
|
||||
companyId,
|
||||
limit: 1,
|
||||
})) as { items: unknown[] };
|
||||
expect(result.items.length).toBeLessThanOrEqual(1);
|
||||
}
|
||||
expect(await snapshot()).toEqual(before);
|
||||
await expect(
|
||||
db.transaction(
|
||||
(tx) =>
|
||||
tx.update(schema.agents).set({ name: "forbidden" }).where(eq(schema.agents.id, agentId)),
|
||||
{ accessMode: "read only" },
|
||||
),
|
||||
).rejects.toThrow();
|
||||
expect(inspectionReaders.agentIdentities.omit).toContain("privateKeyMaterial");
|
||||
const identity = (await readCustomerSuccessResource(db, storage, {
|
||||
operation: "get",
|
||||
resource: "agentIdentities",
|
||||
companyId,
|
||||
resourceId: agentId,
|
||||
})) as Record<string, unknown>;
|
||||
expect(identity).not.toHaveProperty("privateKeyMaterial");
|
||||
await expect(
|
||||
readCustomerSuccessResource(db, storage, {
|
||||
operation: "get",
|
||||
resource: "agentIdentities",
|
||||
companyId: otherCompanyId,
|
||||
resourceId: agentId,
|
||||
}),
|
||||
).rejects.toThrow("not found");
|
||||
});
|
||||
it("returns tasks/documents verbatim and uses existing adapter config redaction", async () => {
|
||||
await db
|
||||
.update(schema.agents)
|
||||
.set({
|
||||
adapterConfig: {
|
||||
env: { API_KEY: "hidden", NOTE: "same existing env policy" },
|
||||
promptTemplate: "Do useful work",
|
||||
},
|
||||
})
|
||||
.where(eq(schema.agents.id, agentId));
|
||||
const agent = (await readCustomerSuccessResource(db, storage, {
|
||||
operation: "get",
|
||||
resource: "agents",
|
||||
companyId,
|
||||
resourceId: agentId,
|
||||
})) as { adapterConfig: { env: Record<string, unknown>; promptTemplate: string } };
|
||||
expect(JSON.stringify(agent)).not.toContain("hidden");
|
||||
expect(agent.adapterConfig.promptTemplate).toEqual("Do useful work");
|
||||
const [task] = await db
|
||||
.insert(schema.issues)
|
||||
.values({
|
||||
companyId,
|
||||
title: "A pasted instruction",
|
||||
description: "Keep prose unchanged: arbitrary pasted material",
|
||||
})
|
||||
.returning();
|
||||
const taskResult = (await readCustomerSuccessResource(db, storage, {
|
||||
operation: "get",
|
||||
resource: "tasks",
|
||||
companyId,
|
||||
resourceId: task.id,
|
||||
})) as { description: string };
|
||||
expect(taskResult.description).toEqual(task.description);
|
||||
await expect(
|
||||
readCustomerSuccessResource(db, storage, {
|
||||
operation: "get",
|
||||
resource: "tasks",
|
||||
companyId: otherCompanyId,
|
||||
resourceId: task.id,
|
||||
}),
|
||||
).rejects.toThrow("not found");
|
||||
});
|
||||
it("reads existing instruction files without recovery, adoption, or filesystem writes", async () => {
|
||||
const file = join(directory, "AGENTS.md");
|
||||
await writeFile(file, "# Investigate carefully\n");
|
||||
await db
|
||||
.update(schema.agents)
|
||||
.set({ adapterConfig: { instructionsFilePath: file } })
|
||||
.where(eq(schema.agents.id, agentId));
|
||||
const before = await stat(file);
|
||||
const count = (await db.select().from(schema.agentInstructionRevisions)).length;
|
||||
const result = (await readCustomerSuccessResource(db, storage, {
|
||||
operation: "instructions.file",
|
||||
companyId,
|
||||
resourceId: agentId,
|
||||
})) as { content: string };
|
||||
expect(Buffer.from(result.content, "base64").toString()).toEqual("# Investigate carefully\n");
|
||||
expect((await stat(file)).mtimeMs).toEqual(before.mtimeMs);
|
||||
expect((await db.select().from(schema.agentInstructionRevisions)).length).toEqual(count);
|
||||
await writeFile(join(directory, ".env"), "CREDENTIAL=do-not-return");
|
||||
await expect(
|
||||
readCustomerSuccessResource(db, storage, {
|
||||
operation: "instructions.file",
|
||||
companyId,
|
||||
resourceId: agentId,
|
||||
path: ".env",
|
||||
}),
|
||||
).rejects.toThrow("denied by policy");
|
||||
await symlink(file, join(directory, "linked.md"));
|
||||
await expect(
|
||||
readCustomerSuccessResource(db, storage, {
|
||||
operation: "instructions.file",
|
||||
companyId,
|
||||
resourceId: agentId,
|
||||
path: "linked.md",
|
||||
}),
|
||||
).rejects.toThrow("symlink");
|
||||
});
|
||||
it("reuses environment redaction for project and routine configuration and revisions", async () => {
|
||||
const env = {
|
||||
CONFIG: { type: "plain" as const, value: "configuration-credential" },
|
||||
REFERENCE: {
|
||||
type: "secret_ref" as const,
|
||||
secretId: randomUUID(),
|
||||
version: "latest" as const,
|
||||
},
|
||||
};
|
||||
const [project] = await db
|
||||
.insert(schema.projects)
|
||||
.values({ companyId, name: "Configured project", env })
|
||||
.returning();
|
||||
const [routine] = await db
|
||||
.insert(schema.routines)
|
||||
.values({ companyId, title: "Configured routine", env })
|
||||
.returning();
|
||||
const [revision] = await db
|
||||
.insert(schema.routineRevisions)
|
||||
.values({
|
||||
companyId,
|
||||
routineId: routine.id,
|
||||
revisionNumber: 1,
|
||||
title: routine.title,
|
||||
snapshot: {
|
||||
version: 1,
|
||||
routine: { ...routine },
|
||||
triggers: [],
|
||||
} as typeof schema.routineRevisions.$inferInsert.snapshot,
|
||||
})
|
||||
.returning();
|
||||
const expected = redactAgentAdapterConfig({ env }).env;
|
||||
for (const [resource, resourceId] of [
|
||||
["projects", project.id],
|
||||
["routines", routine.id],
|
||||
["routineRevisions", revision.id],
|
||||
] as const) {
|
||||
const row = (await readCustomerSuccessResource(db, storage, {
|
||||
operation: "get",
|
||||
resource,
|
||||
companyId,
|
||||
resourceId,
|
||||
})) as { env?: unknown; snapshot?: { routine: { env: unknown } } };
|
||||
expect(row.snapshot?.routine.env ?? row.env).toEqual(expected);
|
||||
expect(JSON.stringify(row)).not.toContain("configuration-credential");
|
||||
}
|
||||
await expect(
|
||||
readCustomerSuccessResource(db, storage, {
|
||||
operation: "get",
|
||||
resource: "users",
|
||||
companyId,
|
||||
resourceId: "missing",
|
||||
}),
|
||||
).rejects.toThrow("not found");
|
||||
});
|
||||
it("asset bytes and bounded byte ranges are company scoped, without bypass URLs", async () => {
|
||||
const [asset] = await db
|
||||
.insert(schema.assets)
|
||||
.values({
|
||||
companyId,
|
||||
provider: "local_disk",
|
||||
objectKey: "test/object",
|
||||
contentType: "application/octet-stream",
|
||||
byteSize: 5,
|
||||
sha256: "fixture",
|
||||
})
|
||||
.returning();
|
||||
const result = (await readCustomerSuccessResource(db, storage, {
|
||||
operation: "assets.content",
|
||||
companyId,
|
||||
resourceId: asset.id,
|
||||
range: { start: 1, end: 3 },
|
||||
})) as { content: string; bytes: number };
|
||||
expect(Buffer.from(result.content, "base64")).toEqual(Buffer.from([1, 2, 3]));
|
||||
expect(result.bytes).toEqual(3);
|
||||
await expect(
|
||||
readCustomerSuccessResource(db, storage, {
|
||||
operation: "assets.content",
|
||||
companyId: otherCompanyId,
|
||||
resourceId: asset.id,
|
||||
}),
|
||||
).rejects.toThrow("not found");
|
||||
});
|
||||
it("tenant requests verify exact Cloud permit parameters, consume centrally, reject cookies and unknown paths", async () => {
|
||||
vi.stubEnv("PAPERCLIP_CUSTOMER_SUCCESS_INSPECTION_ENABLED", "true");
|
||||
vi.stubEnv("PAPERCLIP_CLOUD_STACK_ID", "fixture-stack");
|
||||
vi.stubEnv("PAPERCLIP_CUSTOMER_SUCCESS_CLOUD_ORIGIN", "https://cloud.example.test");
|
||||
vi.stubEnv("PAPERCLIP_CUSTOMER_SUCCESS_AUTHORITY_ENABLED", "true");
|
||||
const keys = generateKeyPairSync("ed25519");
|
||||
const jwks = { keys: [{ ...keys.publicKey.export({ format: "jwk" }), kid: "cloud" }] };
|
||||
vi.stubEnv("PAPERCLIP_CUSTOMER_SUCCESS_INSPECTION_JWKS", JSON.stringify(jwks));
|
||||
const query: InspectionQuery = {
|
||||
operation: "get",
|
||||
resource: "companies",
|
||||
companyId,
|
||||
resourceId: companyId,
|
||||
};
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const claims: InspectionPermit = {
|
||||
v: 1,
|
||||
iss: "paperclip-cloud",
|
||||
aud: INSPECTION_AUDIENCE,
|
||||
sub: "fixture-stack",
|
||||
iat: now,
|
||||
exp: now + 60,
|
||||
jti: randomUUID(),
|
||||
bindingVersion: 1,
|
||||
grantId: randomUUID(),
|
||||
requestId: randomUUID(),
|
||||
agentId,
|
||||
keyId: "sha256:fixture",
|
||||
runId,
|
||||
query,
|
||||
};
|
||||
function token(value = claims, privateKey = keys.privateKey) {
|
||||
const header = Buffer.from(
|
||||
JSON.stringify({ alg: "EdDSA", typ: INSPECTION_PERMIT_TYPE, kid: "cloud" }),
|
||||
).toString("base64url");
|
||||
const body = Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
const input = `${header}.${body}`;
|
||||
return `${input}.${sign(null, Buffer.from(input), privateKey).toString("base64url")}`;
|
||||
}
|
||||
const fetcher = vi.fn().mockResolvedValue(Response.json({ consumed: true }));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api/customer-success/v1", customerSuccessRoutes(db, storage));
|
||||
app.use(errorHandler);
|
||||
const response = await request(app)
|
||||
.post("/api/customer-success/v1/read")
|
||||
.set(INSPECTION_HEADER, token())
|
||||
.send(query);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.headers["cache-control"]).toEqual("no-store");
|
||||
expect(fetcher).toHaveBeenCalledOnce();
|
||||
expect(fetcher.mock.calls[0][0]).toEqual(
|
||||
"https://cloud.example.test/v1/customer-success/permits/consume",
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await request(app)
|
||||
.post("/api/customer-success/v1/read")
|
||||
.set(INSPECTION_HEADER, token())
|
||||
.send({ ...query, resource: "agents" })
|
||||
).status,
|
||||
).toEqual(403);
|
||||
expect(
|
||||
(
|
||||
await request(app)
|
||||
.post("/api/customer-success/v1/read")
|
||||
.set(INSPECTION_HEADER, token())
|
||||
.set("Cookie", "session=ignored")
|
||||
.send(query)
|
||||
).status,
|
||||
).toEqual(403);
|
||||
expect(
|
||||
(
|
||||
await request(app)
|
||||
.get("/api/customer-success/v1/run-authority")
|
||||
.set("Authorization", `Bearer ${bearer}`)
|
||||
).status,
|
||||
).toEqual(200);
|
||||
expect((await request(app).post("/api/customer-success/v1/unknown").send({})).status).toEqual(
|
||||
404,
|
||||
);
|
||||
fetcher.mockResolvedValue(new Response("", { status: 503 }));
|
||||
expect(
|
||||
(
|
||||
await request(app)
|
||||
.post("/api/customer-success/v1/read")
|
||||
.set(INSPECTION_HEADER, token())
|
||||
.send(query)
|
||||
).status,
|
||||
).toEqual(403);
|
||||
expect(() =>
|
||||
verifyInspectionPermit(token({ ...claims, sub: "other-stack" }), jwks, "fixture-stack"),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
verifyInspectionPermit(token({ ...claims, exp: now }), jwks, "fixture-stack"),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
verifyInspectionPermit(token({ ...claims, exp: now + 61 }), jwks, "fixture-stack"),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
verifyInspectionPermit(
|
||||
token(claims, generateKeyPairSync("ed25519").privateKey),
|
||||
jwks,
|
||||
"fixture-stack",
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
it("reads assigned repository URLs, snapshotted skill files, and workspace downloads with existing file restrictions", async () => {
|
||||
const root = await mkdtemp(join(directory, "workspace-"));
|
||||
await writeFile(join(root, "result.bin"), Buffer.alloc(2 * 1024 * 1024, 7));
|
||||
await writeFile(join(root, "empty.txt"), "");
|
||||
await writeFile(join(root, ".env"), "TOKEN=fixture");
|
||||
await symlink(join(root, "result.bin"), join(root, "link.bin"));
|
||||
const [project] = await db
|
||||
.insert(schema.projects)
|
||||
.values({ companyId, name: "Onboarding repo" })
|
||||
.returning();
|
||||
const [workspace] = await db
|
||||
.insert(schema.projectWorkspaces)
|
||||
.values({
|
||||
companyId,
|
||||
projectId: project.id,
|
||||
name: "Local checkout",
|
||||
cwd: root,
|
||||
repoUrl: "https://github.com/example/onboarding",
|
||||
})
|
||||
.returning();
|
||||
const [issue] = await db
|
||||
.insert(schema.issues)
|
||||
.values({ companyId, projectId: project.id, title: "Workspace outputs" })
|
||||
.returning();
|
||||
const base = {
|
||||
companyId,
|
||||
resourceId: issue.id,
|
||||
context: { projectId: project.id, workspaceId: workspace.id },
|
||||
};
|
||||
const repo = (await readCustomerSuccessResource(db, storage, {
|
||||
operation: "get",
|
||||
resource: "projectWorkspaces",
|
||||
companyId,
|
||||
resourceId: workspace.id,
|
||||
})) as { repoUrl: string };
|
||||
expect(repo.repoUrl).toEqual("https://github.com/example/onboarding");
|
||||
const before = await stat(join(root, "result.bin"));
|
||||
const result = (await readCustomerSuccessResource(db, storage, {
|
||||
...base,
|
||||
operation: "files.download",
|
||||
path: "result.bin",
|
||||
range: { start: 2, end: 7 },
|
||||
})) as { content: string; bytes: number };
|
||||
expect(Buffer.from(result.content, "base64")).toEqual(Buffer.alloc(6, 7));
|
||||
expect(result.bytes).toEqual(6);
|
||||
expect((await stat(join(root, "result.bin"))).mtimeMs).toEqual(before.mtimeMs);
|
||||
for (const path of [".env", "../result.bin", "link.bin"])
|
||||
await expect(
|
||||
readCustomerSuccessResource(db, storage, { ...base, operation: "files.download", path }),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
readCustomerSuccessResource(db, storage, {
|
||||
...base,
|
||||
operation: "files.download",
|
||||
path: "result.bin",
|
||||
}),
|
||||
).rejects.toThrow("bounded byte range");
|
||||
const listed = await readCustomerSuccessResource(db, storage, {
|
||||
...base,
|
||||
operation: "files.list",
|
||||
limit: 5,
|
||||
});
|
||||
expect(JSON.stringify(listed)).toContain("result.bin");
|
||||
expect(
|
||||
await readCustomerSuccessResource(db, storage, {
|
||||
...base,
|
||||
operation: "files.download",
|
||||
path: "empty.txt",
|
||||
}),
|
||||
).toMatchObject({ available: true, bytes: 0, content: "" });
|
||||
const [unassigned] = await db
|
||||
.insert(schema.issues)
|
||||
.values({ companyId, title: "No workspace" })
|
||||
.returning();
|
||||
expect(
|
||||
await readCustomerSuccessResource(db, storage, {
|
||||
companyId,
|
||||
resourceId: unassigned.id,
|
||||
operation: "files.read",
|
||||
path: "result.txt",
|
||||
}),
|
||||
).toMatchObject({ available: false });
|
||||
const [skill] = await db
|
||||
.insert(schema.companySkills)
|
||||
.values({
|
||||
companyId,
|
||||
key: "example",
|
||||
slug: "example",
|
||||
name: "Example",
|
||||
markdown: "# Example",
|
||||
fileInventory: [{ path: "SKILL.md", kind: "markdown", sizeBytes: 9 }],
|
||||
})
|
||||
.returning();
|
||||
const [version] = await db
|
||||
.insert(schema.companySkillVersions)
|
||||
.values({
|
||||
companyId,
|
||||
companySkillId: skill.id,
|
||||
revisionNumber: 1,
|
||||
fileInventory: [{ path: "SKILL.md", kind: "markdown", sizeBytes: 9, content: "# Example" }],
|
||||
})
|
||||
.returning();
|
||||
await db
|
||||
.update(schema.companySkills)
|
||||
.set({ currentVersionId: version.id })
|
||||
.where(eq(schema.companySkills.id, skill.id));
|
||||
const snapshot = (await readCustomerSuccessResource(db, storage, {
|
||||
operation: "skills.file",
|
||||
companyId,
|
||||
resourceId: skill.id,
|
||||
})) as { content: string };
|
||||
expect(Buffer.from(snapshot.content, "base64").toString()).toEqual("# Example");
|
||||
await expect(
|
||||
readCustomerSuccessResource(db, storage, {
|
||||
operation: "skills.file",
|
||||
companyId,
|
||||
resourceId: skill.id,
|
||||
path: ".env",
|
||||
}),
|
||||
).rejects.toThrow("denied by policy");
|
||||
const versions = (await readCustomerSuccessResource(db, storage, {
|
||||
operation: "list",
|
||||
resource: "skillVersions",
|
||||
companyId,
|
||||
parentId: skill.id,
|
||||
})) as { items: unknown[] };
|
||||
expect(versions.items).toHaveLength(1);
|
||||
});
|
||||
it("the operation contract has no writes, credentials or unknown parameters", () => {
|
||||
for (const value of [
|
||||
{ operation: "write", companyId },
|
||||
{ operation: "list", resource: "secrets", companyId },
|
||||
{ operation: "get", resource: "agents", companyId, resourceId: agentId, key: "replacement" },
|
||||
{
|
||||
operation: "assets.content",
|
||||
companyId,
|
||||
resourceId: agentId,
|
||||
range: { start: 0, end: 1048576 },
|
||||
},
|
||||
])
|
||||
expect(() => parseInspectionQuery(value)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getTableName, type SQL } from "drizzle-orm";
|
||||
import { PgDialect } from "drizzle-orm/pg-core";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { dashboardService } from "../services/dashboard.js";
|
||||
|
||||
const { overview } = vi.hoisted(() => ({ overview: vi.fn() }));
|
||||
vi.mock("../services/budgets.js", () => ({
|
||||
budgetService: () => ({ overview }),
|
||||
}));
|
||||
|
||||
const companyId = "00000000-0000-4000-8000-000000000001";
|
||||
const dialect = new PgDialect();
|
||||
|
||||
function disconnected(code = "CONNECTION_CLOSED") {
|
||||
const cause = Object.assign(new Error(`write ${code}`), { code });
|
||||
return new Error("Failed query", { cause });
|
||||
}
|
||||
|
||||
function fixture(failures: Error[] = [], options: {
|
||||
missingCompany?: boolean;
|
||||
readFailures?: Record<string, Error[]>;
|
||||
activityFailures?: Error[];
|
||||
} = {}) {
|
||||
const reads: string[] = [];
|
||||
const companyParameters: unknown[][] = [];
|
||||
const parameters = new Map<string, unknown[][]>();
|
||||
let companyAttempts = 0;
|
||||
const select = vi.fn(() => ({
|
||||
from(table: Parameters<typeof getTableName>[0]) {
|
||||
const name = getTableName(table);
|
||||
let condition: SQL | undefined;
|
||||
const query = {
|
||||
where(value: SQL) { condition = value; return query; },
|
||||
groupBy() { return query; },
|
||||
then(resolve: (value: unknown[]) => unknown, reject: (error: unknown) => unknown) {
|
||||
return Promise.resolve().then(() => {
|
||||
reads.push(name);
|
||||
const attempts = parameters.get(name) ?? [];
|
||||
const failure = options.readFailures?.[name]?.[attempts.length];
|
||||
attempts.push(dialect.sqlToQuery(condition!).params);
|
||||
parameters.set(name, attempts);
|
||||
if (failure) throw failure;
|
||||
if (name === "companies") {
|
||||
companyParameters.push(dialect.sqlToQuery(condition!).params);
|
||||
const failure = failures[companyAttempts++];
|
||||
if (failure) throw failure;
|
||||
return options.missingCompany ? [] : [{ id: companyId, budgetMonthlyCents: 10_000 }];
|
||||
}
|
||||
if (name === "agents") return [{ status: "idle", count: 2 }];
|
||||
if (name === "issues") return [{ status: "in_progress", count: 1 }];
|
||||
if (name === "approvals") return [{ count: 3 }];
|
||||
if (name === "cost_events") return [{ monthSpend: 125 }];
|
||||
throw new Error(`Unexpected dashboard read: ${name}`);
|
||||
}).then(resolve, reject);
|
||||
},
|
||||
};
|
||||
return query;
|
||||
},
|
||||
}));
|
||||
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, activityQueries };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
overview.mockReset().mockResolvedValue({
|
||||
activeIncidents: [], pendingApprovalCount: 0, pausedAgentCount: 0, pausedProjectCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboard read connection recovery", () => {
|
||||
it.each(["CONNECT_TIMEOUT", "CONNECTION_CLOSED", "CONNECTION_ENDED", "CONNECTION_DESTROYED"])(
|
||||
"rebuilds the scoped company read after a nested %s cause", async (code) => {
|
||||
const test = fixture([disconnected(code)]);
|
||||
await expect(test.service.summary(companyId)).resolves.toMatchObject({
|
||||
companyId,
|
||||
agents: { active: 2 },
|
||||
tasks: { inProgress: 1 },
|
||||
costs: { monthSpendCents: 125, monthBudgetCents: 10_000 },
|
||||
pendingApprovals: 3,
|
||||
});
|
||||
expect(test.companyParameters).toEqual([[companyId], [companyId]]);
|
||||
expect(test.reads).toEqual(["companies", "companies", "agents", "issues", "approvals", "cost_events"]);
|
||||
expect(test.execute).toHaveBeenCalledTimes(1);
|
||||
expect(overview).toHaveBeenCalledExactlyOnceWith(companyId);
|
||||
},
|
||||
);
|
||||
|
||||
it("can replace two stale pooled connections within the existing retry budget", async () => {
|
||||
const test = fixture([disconnected(), disconnected()]);
|
||||
await expect(test.service.summary(companyId)).resolves.toMatchObject({ companyId });
|
||||
expect(test.companyParameters).toEqual([[companyId], [companyId], [companyId]]);
|
||||
expect(overview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("propagates the final failure after three attempts without starting later work", async () => {
|
||||
const failures = [disconnected(), disconnected(), disconnected()];
|
||||
const test = fixture(failures);
|
||||
await expect(test.service.summary(companyId)).rejects.toBe(failures[2]);
|
||||
expect(test.reads).toEqual(["companies", "companies", "companies"]);
|
||||
expect(test.execute).not.toHaveBeenCalled();
|
||||
expect(overview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([new Error("write CONNECTION_CLOSED"), disconnected("28P01"), disconnected("23505")])(
|
||||
"does not retry message-only, authentication, or constraint errors", async (error) => {
|
||||
const test = fixture([error]);
|
||||
await expect(test.service.summary(companyId)).rejects.toBe(error);
|
||||
expect(test.reads).toEqual(["companies"]);
|
||||
expect(test.execute).not.toHaveBeenCalled();
|
||||
expect(overview).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("retains the missing-company error without retrying", async () => {
|
||||
const test = fixture([], { missingCompany: true });
|
||||
await expect(test.service.summary(companyId)).rejects.toMatchObject({ status: 404 });
|
||||
expect(test.reads).toEqual(["companies"]);
|
||||
expect(overview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not replay the budget workflow when it loses a connection", async () => {
|
||||
const error = disconnected();
|
||||
overview.mockRejectedValue(error);
|
||||
const test = fixture();
|
||||
await expect(test.service.summary(companyId)).rejects.toBe(error);
|
||||
expect(test.companyParameters).toEqual([[companyId]]);
|
||||
expect(overview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const tables = ["companies", "agents", "issues", "approvals", "cost_events"];
|
||||
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);
|
||||
expect(attempts[1]).toEqual(attempts[0]);
|
||||
expect(test.execute).toHaveBeenCalledTimes(1);
|
||||
expect(overview).toHaveBeenCalledExactlyOnceWith(companyId);
|
||||
});
|
||||
|
||||
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]);
|
||||
const prefix = tables.slice(0, tables.indexOf(table));
|
||||
expect(test.reads).toEqual([...prefix, table, table, table]);
|
||||
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();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DECISION_MODELS, DECISION_TEST_REQUEST, decisionProviderForConnection } from "@paperclipai/shared";
|
||||
import { decisionReceipt, runDecisionProvider } from "../services/decision-model-provider.js";
|
||||
|
||||
function openaiResponse() {
|
||||
return { model: "gpt-6-luna", usage: { input_tokens: 123, output_tokens: 0 }, answers: [
|
||||
{ type: "predicate", name: "billing", probability: 0.99 },
|
||||
{ type: "choice", name: "team", choice: "billing", confidence: 0.9, probabilities: [{ value: "billing", probability: 0.9 }, { value: "technical", probability: 0.1 }] },
|
||||
{ type: "score", name: "severity", score: 0.1, probabilities: [{ value: 0, probability: 0.9 }, { value: 1, probability: 0.1 }, { value: 2, probability: 0 }] },
|
||||
] };
|
||||
}
|
||||
function openrouterResponse() {
|
||||
return { id: "decision-fixture", model: "typesafe/jev-1.13", usage: { input_tokens: 123, output_tokens: 0, cost: 0.000005166 }, answers: {
|
||||
billing: { type: "noul", noul: 0.99 }, team: { type: "choice", choice: "billing", confidence: 0.85, probabilities: { billing: 0.9, technical: 0.1 } },
|
||||
severity: { type: "score", score: 0.1, probabilities: { "0": 0.9, "1": 0.1, "2": 0 } },
|
||||
} };
|
||||
}
|
||||
describe("decision provider contract", () => {
|
||||
it.each(["openai", "openrouter"] as const)("uses native %s decisions with the selected credential and preserves answers and fractional costs", async provider => {
|
||||
const transport = vi.fn<typeof fetch>().mockResolvedValue(Response.json(provider === "openai" ? openaiResponse() : openrouterResponse()));
|
||||
const result = await runDecisionProvider({ provider, model: DECISION_MODELS[provider].id, apiKey: "selected-credential", request: DECISION_TEST_REQUEST, signal: new AbortController().signal }, { fetch: transport });
|
||||
expect(transport).toHaveBeenCalledTimes(1);
|
||||
const [url, options] = transport.mock.calls[0];
|
||||
expect(String(url)).toBe(provider === "openai" ? "https://api.openai.com/v1/decisions" : "https://openrouter.ai/api/alpha/decisions");
|
||||
expect(new Headers(options?.headers).get("authorization")).toBe("Bearer selected-credential");
|
||||
expect(result.answers).toMatchObject({ billing: { type: "boolean", probability: 0.99 }, team: { type: "choice", choice: "billing", confidence: provider === "openai" ? 0.9 : 0.85 }, severity: { type: "score", score: 0.1 } });
|
||||
expect(result.receipt.costCents).toBe(provider === "openai" ? "0.0012300" : "0.0005166");
|
||||
expect(JSON.stringify(result.receipt)).not.toContain("customer");
|
||||
expect(JSON.parse(String(options?.body)).model).toBe(DECISION_MODELS[provider].id);
|
||||
});
|
||||
it("keeps usage for a charged refusal and never treats it as an answer", async () => {
|
||||
const transport = vi.fn<typeof fetch>().mockResolvedValue(Response.json({ ...openaiResponse(), answers: [{ type: "refusal", name: null }] }));
|
||||
const result = await runDecisionProvider({ provider: "openai", model: "gpt-6-luna", apiKey: "key", request: DECISION_TEST_REQUEST, signal: new AbortController().signal }, { fetch: transport });
|
||||
expect(result.answers).toBeUndefined(); expect(result.errorCode).toBe("refused"); expect(result.receipt.inputTokens).toBe(123);
|
||||
});
|
||||
it.each([401, 429, 500])("does not retry HTTP %i or retain its response body", async status => {
|
||||
const transport = vi.fn<typeof fetch>().mockResolvedValue(Response.json({ error: { message: "PRIVATE STATE AND KEY", type: "server_error" } }, { status }));
|
||||
const result = await runDecisionProvider({ provider: "openai", model: "gpt-6-luna", apiKey: "key", request: DECISION_TEST_REQUEST, signal: new AbortController().signal }, { fetch: transport });
|
||||
expect(transport).toHaveBeenCalledTimes(1); expect(result.receipt.costStatus).toBe(status === 500 ? "unpriced" : "estimated"); expect(result.noProviderWork).toBe(status !== 500); expect(JSON.stringify(result)).not.toContain("PRIVATE");
|
||||
});
|
||||
it("preserves usage when answer validation fails", async () => {
|
||||
const response = openrouterResponse(); response.answers.team.choice = "invented";
|
||||
const result = await runDecisionProvider({ provider: "openrouter", model: "typesafe/jev-1.13", apiKey: "key", request: DECISION_TEST_REQUEST, signal: new AbortController().signal }, { fetch: async () => Response.json(response) });
|
||||
expect(result.answers).toBeUndefined(); expect(result.receipt.costStatus).toBe("reported");
|
||||
});
|
||||
it.each(["openai", "openrouter"] as const)("retains the %s receipt when the SDK rejects malformed wire answers", async provider => {
|
||||
const wire = provider === "openai" ? { ...openaiResponse(), answers: [{ type: "predicate", name: "billing", probability: 2 }] }
|
||||
: { ...openrouterResponse(), answers: { billing: { type: "noul", noul: "not-a-probability" } } };
|
||||
const result = await runDecisionProvider({ provider, model: DECISION_MODELS[provider].id, apiKey: "key", request: DECISION_TEST_REQUEST, signal: new AbortController().signal }, { fetch: async () => Response.json(wire) });
|
||||
expect(result.answers).toBeUndefined(); expect(result.receipt.inputTokens).toBe(123);
|
||||
expect(result.receipt.costStatus).toBe(provider === "openai" ? "estimated" : "reported");
|
||||
});
|
||||
it("fails closed on an OpenRouter refusal while retaining any reported charge", async () => {
|
||||
const result = await runDecisionProvider({ provider: "openrouter", model: DECISION_MODELS.openrouter.id, apiKey: "key", request: DECISION_TEST_REQUEST, signal: new AbortController().signal },
|
||||
{ fetch: async () => Response.json({ ...openrouterResponse(), answers: { billing: { type: "refusal" } } }) });
|
||||
expect(result.answers).toBeUndefined(); expect(result.errorCode).toBe("refused"); expect(result.receipt.costStatus).toBe("reported");
|
||||
});
|
||||
it("does not invent prices when usage or a supported rate is missing", () => {
|
||||
expect(decisionReceipt("openai", {}).costCents).toBeNull();
|
||||
expect(decisionReceipt("openai", { usage: { inputTokens: 100_000 } }).costStatus).toBe("unpriced");
|
||||
expect(decisionReceipt("openrouter", { usage: { inputTokens: 1 } }).costCents).toBeNull();
|
||||
expect(decisionReceipt("openrouter", { providerMetadata: { openrouter: { usage: { cost: 0 } } } }).costStatus).toBe("reported");
|
||||
});
|
||||
it("rejects subscription, personal endpoint routing and other provider capabilities", () => {
|
||||
expect(decisionProviderForConnection({ provider: "openai", method: "subscription" })).toBeNull();
|
||||
expect(decisionProviderForConnection({ provider: "openai", method: "api_key", routing: { kind: "gateway", baseUrl: "https://example.com" } })).toBeNull();
|
||||
expect(decisionProviderForConnection({ provider: "anthropic", method: "api_key" })).toBeNull();
|
||||
expect(decisionProviderForConnection({ provider: "openrouter", method: "api_key", routing: { kind: "openrouter" } })).toBe("openrouter");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, realpath, rm } 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, companyMemberships, companyDecisionModels, costEvents, decisionInvocations,
|
||||
connectionGrants, connectionGrantMembers, toolConnectionInstalls, budgetPolicies, budgetReservations,
|
||||
heartbeatRuns, issues, projects, runIdentityContexts, secretAccessEvents, companySecrets,
|
||||
} from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres";
|
||||
import { DECISION_TEST_REQUEST, updateDecisionModelSchema, createCostEventSchema } from "@paperclipai/shared";
|
||||
import { aiConnectionService } from "../services/ai-connections.js";
|
||||
import { agentService } from "../services/agents.js";
|
||||
import { decisionModelService, settingsDecisionTest, defineDecisionFeature, type DecisionContext } from "../services/decision-models.js";
|
||||
import { decisionReceipt, type DecisionProviderOutcome } from "../services/decision-model-provider.js";
|
||||
import { costService } from "../services/costs.js";
|
||||
import { billingReconciliationService } from "../services/billing-reconciliation.js";
|
||||
import { withAccountingTransaction } from "../services/accounting-transaction.js";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { decisionModelRoutes } from "../routes/decision-models.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let home: string;
|
||||
const backgroundFeature = defineDecisionFeature("test.background", { background: true });
|
||||
const outcome: DecisionProviderOutcome = { answers: { billing: { type: "boolean", probability: 0.9 } }, receipt: decisionReceipt("openai", { usage: { inputTokens: 100, outputTokens: 0 } }) };
|
||||
beforeAll(async () => {
|
||||
home = await realpath(await mkdtemp(path.join(os.tmpdir(), "paperclip-decisions-")));
|
||||
vi.stubEnv("PAPERCLIP_HOME", home);
|
||||
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "decisions-fixture");
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-decisions-db-");
|
||||
db = createDb(database.connectionString);
|
||||
}, 90000);
|
||||
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); if (home) await rm(home, { recursive: true, force: true }); });
|
||||
let sequence = 0;
|
||||
async function fixture() {
|
||||
const companyId = randomUUID(), agentId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Decision tests", issuePrefix: `DC${++sequence}` });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Test agent", adapterType: "codex_local" });
|
||||
await db.insert(companyMemberships).values(["alice", "bob"].map(principalId => ({ companyId, principalId, principalType: "user", status: "active", membershipRole: "owner" })));
|
||||
const connections = aiConnectionService(db);
|
||||
const binding = await connections.save(companyId, "alice", { provider: "openai", method: "api_key", name: "Decision key", ownership: "shared", apiKey: "fixture-key", agentIds: [], allAgents: true }, "fixture-key");
|
||||
const provider = vi.fn().mockResolvedValue(structuredClone(outcome));
|
||||
const service = decisionModelService(db, { provider });
|
||||
const context: DecisionContext = { companyId, feature: settingsDecisionTest,
|
||||
actor: { type: "board", userId: "alice", source: "session", companyIds: [companyId], memberships: [{ companyId, membershipRole: "owner", status: "active" }] } };
|
||||
const config = { ...binding, enabled: true, allowBackground: true };
|
||||
await service.configure(companyId, "alice", config);
|
||||
return { companyId, agentId, binding, provider, service, context, config, connections };
|
||||
}
|
||||
describe("company decision service", () => {
|
||||
it("starts unconfigured with background sponsorship on and makes availability free", async () => {
|
||||
const f = await fixture();
|
||||
await db.delete(companyDecisionModels).where(eq(companyDecisionModels.companyId, f.companyId));
|
||||
expect(await f.service.settings(f.companyId)).toMatchObject({ enabled: false, allowBackground: true, connectionId: null });
|
||||
expect(await f.service.availability(f.context)).toEqual({ available: false, reason: "not_configured" });
|
||||
await f.service.configure(f.companyId, "alice", updateDecisionModelSchema.parse({ ...f.binding, enabled: true }));
|
||||
const before = await db.select().from(secretAccessEvents).where(eq(secretAccessEvents.companyId, f.companyId));
|
||||
const memo = f.service.availabilityForRequest();
|
||||
expect(memo(f.context)).toBe(memo(f.context));
|
||||
expect(await memo(f.context)).toEqual({ available: true });
|
||||
expect(await db.select().from(secretAccessEvents).where(eq(secretAccessEvents.companyId, f.companyId))).toHaveLength(before.length);
|
||||
expect(f.provider).not.toHaveBeenCalled();
|
||||
});
|
||||
it("records a human service charge without an agent, exact cost and no input or answer content", async () => {
|
||||
const f = await fixture();
|
||||
const result = await f.service.decide(f.context, { ...DECISION_TEST_REQUEST, state: "PRIVATE PAYLOAD" });
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(f.provider.mock.calls[0][0].apiKey).toBe("fixture-key");
|
||||
const [event] = await db.select().from(costEvents).where(eq(costEvents.companyId, f.companyId));
|
||||
expect(event).toMatchObject({ agentId: null, responsibleUserId: "alice", usageKind: "decision", costCents: 0.001, costStatus: "estimated", heartbeatRunId: null });
|
||||
const rows = await db.select().from(decisionInvocations).where(eq(decisionInvocations.companyId, f.companyId));
|
||||
expect(rows[0]).toMatchObject({ status: "succeeded", actorType: "user", responsibleUserId: "alice", costEventId: event.id });
|
||||
expect(JSON.stringify(rows)).not.toContain("PRIVATE PAYLOAD"); expect(JSON.stringify(rows)).not.toContain("probability");
|
||||
expect((await costService(db).byUser(f.companyId)).rows.find(r => r.userId === "alice")?.costCentsExact).toBe("0.0010000");
|
||||
expect((await costService(db).byAgent(f.companyId))[0].agentId).toBeNull();
|
||||
expect((await db.select().from(budgetReservations).where(eq(budgetReservations.companyId, f.companyId)))[0].state).toBe("settled");
|
||||
});
|
||||
it("allows real background use, preserves explicit off, and never sponsors a denied human", async () => {
|
||||
const f = await fixture();
|
||||
const context: DecisionContext = { companyId: f.companyId, feature: backgroundFeature, actor: { type: "system" } };
|
||||
expect((await f.service.decide(context, DECISION_TEST_REQUEST)).status).toBe("succeeded");
|
||||
expect((await db.select().from(decisionInvocations).where(eq(decisionInvocations.companyId, f.companyId)))[0]).toMatchObject({ actorType: "system", responsibleUserId: null });
|
||||
await f.service.configure(f.companyId, "alice", { ...f.config, allowBackground: false });
|
||||
expect(await f.service.availability(context)).toEqual({ available: false, reason: "background_disabled" });
|
||||
const second = await f.connections.save(f.companyId, "alice", { provider: "openrouter", method: "api_key", name: "Other key", ownership: "shared", apiKey: "other", agentIds: [], allAgents: true }, "other");
|
||||
await f.service.configure(f.companyId, "alice", { ...second, enabled: true, allowBackground: false });
|
||||
expect((await f.service.settings(f.companyId)).allowBackground).toBe(false);
|
||||
await f.service.configure(f.companyId, "alice", f.config);
|
||||
await db.insert(connectionGrantMembers).values({ companyId: f.companyId, grantId: f.binding.grantId, subjectType: "user", subjectId: "bob" });
|
||||
expect(await f.service.decide({ ...f.context, feature: backgroundFeature }, DECISION_TEST_REQUEST)).toMatchObject({ status: "unavailable", reason: "access_denied" });
|
||||
expect(f.provider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("retains the charge and dispatch snapshot when the task is deleted during provider work", async () => {
|
||||
const f = await fixture(), issueId = randomUUID();
|
||||
await db.insert(issues).values({ id: issueId, companyId: f.companyId, title: "Temporary task" });
|
||||
f.provider.mockImplementationOnce(async () => {
|
||||
await db.delete(issues).where(eq(issues.id, issueId));
|
||||
return structuredClone(outcome);
|
||||
});
|
||||
expect((await f.service.decide({ ...f.context, issueId }, DECISION_TEST_REQUEST)).status).toBe("succeeded");
|
||||
const [event] = await db.select().from(costEvents).where(eq(costEvents.companyId, f.companyId));
|
||||
expect(event).toMatchObject({ issueId: null, costCents: 0.001 });
|
||||
const [invocation] = await db.select().from(decisionInvocations).where(eq(decisionInvocations.companyId, f.companyId));
|
||||
expect(invocation).toMatchObject({ issueId, status: "succeeded", costEventId: event.id });
|
||||
});
|
||||
it("rechecks revoked grants after availability, and can disable a revoked connection", async () => {
|
||||
const f = await fixture(); expect(await f.service.availability(f.context)).toEqual({ available: true });
|
||||
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, f.binding.grantId));
|
||||
expect(await f.service.decide(f.context, DECISION_TEST_REQUEST)).toMatchObject({ status: "unavailable", reason: "connection_unavailable" });
|
||||
await f.service.configure(f.companyId, "alice", { ...f.config, enabled: false });
|
||||
expect(await f.service.availability(f.context)).toEqual({ available: false, reason: "disabled" }); expect(f.provider).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["disabled", "rotated"])("rejects a secret %s while admission waits for accounting", async change => {
|
||||
const f = await fixture();
|
||||
const [secret] = await db.select().from(companySecrets).where(eq(companySecrets.companyId, f.companyId));
|
||||
let release!: () => void, locked!: (pid: number) => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
const acquired = new Promise<number>(resolve => { locked = resolve; });
|
||||
const holding = withAccountingTransaction(db, f.companyId, async tx => {
|
||||
const [row] = await tx.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`);
|
||||
locked(row.pid); await gate;
|
||||
});
|
||||
const pid = await acquired;
|
||||
const pending = f.service.decide(f.context, DECISION_TEST_REQUEST);
|
||||
try {
|
||||
await vi.waitFor(async () => {
|
||||
const [row] = await db.execute<{ count: number }>(sql`select count(*)::int as count from pg_stat_activity where ${pid} = any(pg_blocking_pids(pid))`);
|
||||
expect(row.count).toBeGreaterThan(0);
|
||||
}, { timeout: 5000 });
|
||||
await db.update(companySecrets).set(change === "disabled" ? { status: "disabled" } : { latestVersion: secret.latestVersion + 1 }).where(eq(companySecrets.id, secret.id));
|
||||
} finally { release(); await holding; }
|
||||
expect(await pending).toMatchObject({ status: "unavailable", reason: "connection_unavailable" });
|
||||
expect(f.provider).not.toHaveBeenCalled();
|
||||
expect(await db.select().from(decisionInvocations).where(eq(decisionInvocations.companyId, f.companyId))).toHaveLength(0);
|
||||
});
|
||||
it("preserves sponsorship off when a deleted grant is replaced", async () => {
|
||||
const f = await fixture();
|
||||
await f.service.configure(f.companyId, "alice", { ...f.config, allowBackground: false });
|
||||
await db.delete(connectionGrants).where(eq(connectionGrants.id, f.binding.grantId));
|
||||
expect(await f.service.availability(f.context)).toMatchObject({ available: false, reason: "connection_unavailable" });
|
||||
expect((await f.service.settings(f.companyId)).allowBackground).toBe(false);
|
||||
const next = await f.connections.save(f.companyId, "alice", { provider: "openrouter", method: "api_key", name: "Replacement", ownership: "shared", apiKey: "other", agentIds: [], allAgents: true }, "other");
|
||||
await f.service.configure(f.companyId, "alice", { ...next, enabled: true });
|
||||
expect((await f.service.settings(f.companyId)).allowBackground).toBe(false);
|
||||
expect(f.provider).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects foreign-company credentials, membership loss, and invented feature handles", async () => {
|
||||
const f = await fixture(), other = await fixture();
|
||||
await expect(f.service.configure(f.companyId, "alice", { ...other.config })).rejects.toThrow();
|
||||
await db.update(companyMemberships).set({ status: "inactive" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, "alice")));
|
||||
expect(await f.service.availability(f.context)).toMatchObject({ available: false, reason: "access_denied" });
|
||||
expect(await f.service.decide({ ...f.context, feature: { id: "settings.test", background: true }, actor: { type: "system" } }, DECISION_TEST_REQUEST)).toMatchObject({ status: "unavailable", reason: "access_denied" });
|
||||
});
|
||||
it("captures the active run identity and checks both human and agent access", async () => {
|
||||
const f = await fixture(), issueId = randomUUID(), runId = randomUUID();
|
||||
await db.insert(issues).values({ id: issueId, companyId: f.companyId, title: "Decision task", status: "in_progress", assigneeAgentId: f.agentId });
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.agentId, status: "running", invocationSource: "on_demand", responsibleUserId: "alice", contextSnapshot: { issueId } });
|
||||
const [identity] = await db.insert(runIdentityContexts).values({ companyId: f.companyId, runId, revision: 1, responsibleUserId: "bob", cause: "steering", correlationId: "test", acceptedAt: new Date() }).returning();
|
||||
await db.update(heartbeatRuns).set({ responsibleUserId: "bob", activeIdentityContextId: identity.id }).where(eq(heartbeatRuns.id, runId));
|
||||
const context: DecisionContext = { companyId: f.companyId, feature: settingsDecisionTest, actor: { type: "agent", agentId: f.agentId, runId, companyId: f.companyId, source: "agent_jwt", onBehalfOfUserId: "alice" } };
|
||||
expect((await f.service.decide(context, DECISION_TEST_REQUEST)).status).toBe("succeeded");
|
||||
expect((await db.select().from(decisionInvocations).where(eq(decisionInvocations.companyId, f.companyId)))[0]).toMatchObject({ responsibleUserId: "bob", runId, issueId, identityContextId: identity.id });
|
||||
await db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, f.binding.connectionId));
|
||||
expect(await f.service.availability(context)).toMatchObject({ available: false, reason: "access_denied" });
|
||||
await db.update(heartbeatRuns).set({ responsibleUserId: null }).where(eq(heartbeatRuns.id, runId));
|
||||
expect(await f.service.availability(context)).toMatchObject({ available: false, reason: "responsible_user_missing" });
|
||||
});
|
||||
it("serializes budget admission against concurrent requests", async () => {
|
||||
const f = await fixture();
|
||||
await db.insert(budgetPolicies).values({ companyId: f.companyId, scopeType: "company", scopeId: f.companyId, windowKind: "calendar_month_utc", amount: 1, reservationCents: "1", notifyEnabled: false });
|
||||
let release!: () => void; const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
let entered!: () => void; const started = new Promise<void>(resolve => { entered = resolve; });
|
||||
f.provider.mockImplementationOnce(async () => { entered(); await gate; return structuredClone(outcome); });
|
||||
const first = f.service.decide(f.context, DECISION_TEST_REQUEST);
|
||||
await started;
|
||||
try { expect(await f.service.decide(f.context, DECISION_TEST_REQUEST)).toMatchObject({ status: "unavailable", reason: "budget_blocked" }); }
|
||||
finally { release(); }
|
||||
expect((await first).status).toBe("succeeded"); expect(f.provider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("blocks agent deletion while an in-flight or unknown decision retains a budget hold", async () => {
|
||||
const f = await fixture(), runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.agentId, status: "running", invocationSource: "on_demand", responsibleUserId: "alice" });
|
||||
await db.insert(budgetPolicies).values({ companyId: f.companyId, scopeType: "company", scopeId: f.companyId, windowKind: "calendar_month_utc", amount: 1, reservationCents: "1", notifyEnabled: false });
|
||||
const context: DecisionContext = { companyId: f.companyId, feature: settingsDecisionTest,
|
||||
actor: { type: "agent", agentId: f.agentId, runId, companyId: f.companyId, source: "agent_jwt" } };
|
||||
const remove = () => agentService(db).remove(f.agentId);
|
||||
f.provider.mockImplementationOnce(async () => {
|
||||
await expect(remove()).rejects.toMatchObject({ status: 409, details: { code: "agent_decision_accounting_pending" } });
|
||||
expect(await f.service.decide(f.context, DECISION_TEST_REQUEST)).toMatchObject({ status: "unavailable", reason: "budget_blocked" });
|
||||
return { errorCode: "timeout", receipt: decisionReceipt("openai", null) };
|
||||
});
|
||||
expect((await f.service.decide(context, DECISION_TEST_REQUEST)).status).toBe("failed");
|
||||
await expect(remove()).rejects.toMatchObject({ status: 409, details: { code: "agent_decision_accounting_pending" } });
|
||||
expect((await db.select().from(budgetReservations).where(eq(budgetReservations.companyId, f.companyId)))[0]).toMatchObject({ state: "held", amountCents: "1.0000000" });
|
||||
});
|
||||
it("holds unknown billing until an audited correction, without rerunning the provider", async () => {
|
||||
const f = await fixture(); f.provider.mockResolvedValue({ errorCode: "timeout", receipt: decisionReceipt("openai", null) });
|
||||
expect((await f.service.decide(f.context, DECISION_TEST_REQUEST)).status).toBe("failed");
|
||||
const [event] = await db.select().from(costEvents).where(eq(costEvents.companyId, f.companyId));
|
||||
expect(event.costStatus).toBe("unpriced");
|
||||
expect((await db.select().from(budgetReservations).where(eq(budgetReservations.companyId, f.companyId)))[0].state).toBe("held");
|
||||
await billingReconciliationService(db).adjust(f.companyId, event.id, { idempotencyKey: "correction", expectedCents: "0", correctedCents: "0.002", reason: "Verified provider invoice", pricing: { source: "operator" } }, "alice");
|
||||
expect((await db.select().from(budgetReservations).where(eq(budgetReservations.companyId, f.companyId)))[0].state).toBe("settled");
|
||||
expect(f.provider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("recovers an interrupted dispatch once without paying for a retry", async () => {
|
||||
const f = await fixture();
|
||||
const [invocation] = await db.insert(decisionInvocations).values({ companyId: f.companyId, feature: "test.background", actorType: "system", actorId: "paperclip-decisions",
|
||||
...f.binding, provider: "openai", model: "gpt-6-luna", questionTypes: ["boolean"], startedAt: new Date(Date.now() - 180_000) }).returning();
|
||||
await db.insert(budgetReservations).values({ companyId: f.companyId, decisionInvocationId: invocation.id, amountCents: "0.01", providerStartedAt: invocation.startedAt });
|
||||
await Promise.all([f.service.recoverInterrupted(), f.service.recoverInterrupted()]);
|
||||
const events = await db.select().from(costEvents).where(eq(costEvents.companyId, f.companyId));
|
||||
expect(events).toHaveLength(1); expect(events[0].costStatus).toBe("unpriced"); expect(f.provider).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps public cost reporting agent-scoped and bounds decision input before payment", async () => {
|
||||
expect(createCostEventSchema.safeParse({ agentId: null, provider: "openai", model: "gpt-6-luna", costCents: 1, occurredAt: new Date().toISOString() }).success).toBe(false);
|
||||
const f = await fixture(); await expect(f.service.decide(f.context, { ...DECISION_TEST_REQUEST, state: "x".repeat(32_001) })).rejects.toThrow(); expect(f.provider).not.toHaveBeenCalled();
|
||||
});
|
||||
it("preserves an explicit sponsorship opt-out when a subsequent edit omits the field", async () => {
|
||||
const f = await fixture();
|
||||
await f.service.configure(f.companyId, "alice", { ...f.config, allowBackground: false });
|
||||
const second = await f.connections.save(f.companyId, "alice", { provider: "openrouter", method: "api_key", name: "Other", ownership: "shared", apiKey: "other", agentIds: [], allAgents: true }, "other");
|
||||
await f.service.configure(f.companyId, "alice", { ...second, enabled: true });
|
||||
expect((await f.service.settings(f.companyId)).allowBackground).toBe(false);
|
||||
});
|
||||
it("rejects revoked secrets without provider work and returns usage on successful calls", async () => {
|
||||
const f = await fixture();
|
||||
expect(await f.service.decide(f.context, DECISION_TEST_REQUEST)).toMatchObject({ status: "succeeded", usage: { costCents: "0.0010000", costStatus: "estimated", inputTokens: 100 } });
|
||||
await db.update(companySecrets).set({ status: "revoked" }).where(eq(companySecrets.companyId, f.companyId));
|
||||
expect(await f.service.decide(f.context, DECISION_TEST_REQUEST)).toMatchObject({ status: "unavailable" });
|
||||
expect(f.provider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it.each(["agent", "project"] as const)("reserves and enforces applicable %s budgets", async scopeType => {
|
||||
const f = await fixture(), projectId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
|
||||
await db.insert(projects).values({ id: projectId, companyId: f.companyId, name: "Decisions project" });
|
||||
await db.insert(issues).values({ id: issueId, companyId: f.companyId, projectId, title: "Budgeted task", assigneeAgentId: f.agentId });
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.agentId, status: "running", invocationSource: "on_demand", responsibleUserId: "alice", contextSnapshot: { issueId } });
|
||||
const context: DecisionContext = { companyId: f.companyId, feature: settingsDecisionTest, actor: { type: "agent", agentId: f.agentId, runId, companyId: f.companyId, source: "agent_jwt" } };
|
||||
const [policy] = await db.insert(budgetPolicies).values({ companyId: f.companyId, scopeType, scopeId: scopeType === "agent" ? f.agentId : projectId, windowKind: "calendar_month_utc", amount: 1, reservationCents: "2", notifyEnabled: false }).returning();
|
||||
expect(await f.service.availability(context)).toEqual({ available: true });
|
||||
expect(await f.service.decide(context, DECISION_TEST_REQUEST)).toMatchObject({ status: "unavailable", reason: "budget_blocked" });
|
||||
expect(f.provider).not.toHaveBeenCalled();
|
||||
await db.update(budgetPolicies).set({ amount: 5 }).where(eq(budgetPolicies.id, policy.id));
|
||||
expect((await f.service.decide(context, DECISION_TEST_REQUEST)).status).toBe("succeeded");
|
||||
expect((await db.select().from(budgetReservations).where(eq(budgetReservations.companyId, f.companyId)))[0]).toMatchObject({ agentId: f.agentId, projectId, amountCents: "2.0000000", state: "settled" });
|
||||
});
|
||||
it("releases reservations when the provider rejects a request before model work", async () => {
|
||||
const f = await fixture();
|
||||
f.provider.mockResolvedValue({ noProviderWork: true, errorCode: "provider_auth_failed", receipt: { ...outcome.receipt, inputTokens: 0, costCents: "0", pricingProvenance: { source: "unknown", evidence: "HTTP 401" } } });
|
||||
expect((await f.service.decide(f.context, DECISION_TEST_REQUEST)).status).toBe("failed");
|
||||
expect((await db.select().from(budgetReservations).where(eq(budgetReservations.companyId, f.companyId)))[0].state).toBe("released");
|
||||
expect((await db.select().from(costEvents).where(eq(costEvents.companyId, f.companyId)))[0].costCents).toBe(0);
|
||||
});
|
||||
it("enforces manager-only configuration and tests, company isolation, and fixed sample execution", async () => {
|
||||
const f = await fixture(), other = await fixture();
|
||||
const app = express(); app.use(express.json());
|
||||
let actor = f.context.actor;
|
||||
app.use((req, _res, next) => { req.actor = actor as typeof req.actor; next(); });
|
||||
app.use("/api", decisionModelRoutes(db)); app.use(errorHandler);
|
||||
const url = `/api/companies/${f.companyId}/decision-model`;
|
||||
expect((await request(app).get(url)).body.canManage).toBe(true);
|
||||
expect((await request(app).get(`/api/companies/${other.companyId}/decision-model`)).status).toBe(403);
|
||||
actor = { type: "board", userId: "alice", source: "session", companyIds: [f.companyId], memberships: [{ companyId: f.companyId, status: "active", membershipRole: "viewer" }] };
|
||||
expect((await request(app).get(url)).body).toEqual({ canManage: false, settings: null, choices: [] });
|
||||
expect((await request(app).put(url).send(f.config)).status).toBe(403);
|
||||
expect((await request(app).post(`${url}/test`).send({ actor: { type: "system" } })).status).toBe(403);
|
||||
actor = f.context.actor;
|
||||
await f.service.configure(f.companyId, "alice", { ...f.config, enabled: false });
|
||||
expect((await request(app).post(`${url}/test`).send({ state: "untrusted", actor: { type: "system" }, model: "untrusted" })).body).toEqual({ status: "unavailable", reason: "disabled" });
|
||||
expect((await request(app).post(`${url}/decide`).send(DECISION_TEST_REQUEST)).status).toBe(404);
|
||||
expect((await request(app).get(`${url}/history?limit=501`)).status).toBe(400);
|
||||
expect((await request(app).get(`${url}/history?from=2026-10-01T00:00:00.000Z&to=2026-10-31T23:59:59.000Z`)).status).toBe(200);
|
||||
});
|
||||
it("keeps private task links out of company cost history for unauthorized readers", async () => {
|
||||
const f = await fixture(), projectId = randomUUID(), issueId = randomUUID();
|
||||
await db.insert(projects).values({ id: projectId, companyId: f.companyId, name: "Private project", visibility: "private", privacyOwnerUserId: "bob" });
|
||||
await db.insert(issues).values({ id: issueId, companyId: f.companyId, projectId, title: "PRIVATE TITLE", identifier: "SECRET-1" });
|
||||
await f.service.decide({ companyId: f.companyId, feature: backgroundFeature, actor: { type: "system" }, issueId }, DECISION_TEST_REQUEST);
|
||||
await db.update(companyMemberships).set({ membershipRole: "member" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, "alice")));
|
||||
const actor = { type: "board" as const, userId: "alice", source: "session" as const, companyIds: [f.companyId], memberships: [{ companyId: f.companyId, status: "active", membershipRole: "member" }] };
|
||||
expect(await f.service.availability({ ...f.context, actor, issueId })).toMatchObject({ available: false, reason: "access_denied" });
|
||||
const history = await f.service.history(f.companyId, actor);
|
||||
expect(history[0]).toMatchObject({ issueId: null, issueIdentifier: null, runId: null });
|
||||
expect(JSON.stringify(history)).not.toContain("SECRET");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
@@ -87,7 +87,6 @@ describePg("decisionService", () => {
|
||||
options: [{ id: "yes", label: "Yes", effects: [{ type: "comment_on_issue", targetIssueId, staleness, bodyMarkdown: "hello" }] }],
|
||||
...extra,
|
||||
});
|
||||
|
||||
// Make an existing decision TTL-expired for the next sweep. Creating a
|
||||
// decision that is already expired is impossible (create rejects a past
|
||||
// expiresAt), and creating one that expires a few milliseconds later races
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
bootstrapDevRunnerWorktreeEnv,
|
||||
applyEmptyWorktreeSigningSecrets,
|
||||
isWorktreeSeedPending,
|
||||
isLinkedGitWorktreeCheckout,
|
||||
resolveWorktreeEnvFilePath,
|
||||
@@ -25,6 +26,18 @@ function createTempRoot(prefix: string): string {
|
||||
}
|
||||
|
||||
describe("dev-runner worktree env bootstrap", () => {
|
||||
it("uses saved empty-instance signing keys instead of inherited source keys", () => {
|
||||
const root = createTempRoot("paperclip-empty-signing-keys-");
|
||||
fs.mkdirSync(path.join(root, ".paperclip"));
|
||||
fs.writeFileSync(path.join(root, ".git"), "gitdir: /tmp/paperclip/.git/worktrees/empty\n");
|
||||
fs.writeFileSync(path.join(root, ".paperclip", "seed-empty"), "explicitly empty\n");
|
||||
fs.writeFileSync(resolveWorktreeEnvFilePath(root), 'PAPERCLIP_AGENT_JWT_SECRET="fresh-jwt"\nPAPERCLIP_TOOL_ACTION_SIGNING_SECRET="fresh-actions"\n');
|
||||
const env = { PAPERCLIP_AGENT_JWT_SECRET: "source-jwt", PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: "source-actions", BETTER_AUTH_SECRET: "source-auth" };
|
||||
bootstrapDevRunnerWorktreeEnv(root, env);
|
||||
expect(env).toMatchObject({ PAPERCLIP_AGENT_JWT_SECRET: "fresh-jwt", PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: "fresh-actions", BETTER_AUTH_SECRET: "fresh-jwt" });
|
||||
fs.writeFileSync(resolveWorktreeEnvFilePath(root), 'PAPERCLIP_AGENT_JWT_SECRET="fresh-jwt"\n');
|
||||
expect(() => applyEmptyWorktreeSigningSecrets(root, env)).toThrow("missing its saved PAPERCLIP_TOOL_ACTION_SIGNING_SECRET");
|
||||
});
|
||||
it("guards seed-pending worktrees until a seed-complete marker exists", () => {
|
||||
const root = createTempRoot("paperclip-dev-runner-seed-pending-");
|
||||
fs.mkdirSync(path.join(root, ".paperclip"), { recursive: true });
|
||||
|
||||
@@ -187,7 +187,7 @@ async function createApp(actor: "board" | "agent" = "board", actorCompanyId = co
|
||||
}
|
||||
|
||||
describe("document annotation routes", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/issues.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
@@ -226,6 +226,11 @@ describe("document annotation routes", () => {
|
||||
mockAnnotationService.addComment.mockResolvedValue(annotationComment);
|
||||
mockAnnotationService.updateThread.mockResolvedValue({ ...annotationThread, status: "resolved" });
|
||||
mockAnnotationService.remapOpenThreadsForDocument.mockResolvedValue([]);
|
||||
// Cold route transforms belong to fixture setup, not the request timeout.
|
||||
await Promise.all([
|
||||
vi.importActual("../routes/issues.js"),
|
||||
vi.importActual("../middleware/index.js"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes compact open annotations without comment bodies by default for agent document reads", async () => {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDb, authUsers, companies, companyMemberships, agents, dotAgentBindings, mcpOauthGrants, mcpOauthRequests } from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { createPublicMcpOAuth, publicMcpConfig, DEVICE_GRANT } from "../services/public-mcp/oauth.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { dotRunnerBroker } from "../services/dot-runner-broker.js";
|
||||
import { publicMcpManagementRoutes, publicMcpIngressRoutes } from "../routes/public-mcp.js";
|
||||
|
||||
const config = { origin: "https://paperclip.example", resource: "https://paperclip.example/mcp/runner" };
|
||||
const callback = "https://chatgpt.com/connector_platform_oauth_redirect";
|
||||
describe("Dot onboarding with an operator-issued pairing capability", () => {
|
||||
let temp: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => {
|
||||
temp = await startEmbeddedPostgresTestDatabase("paperclip-dot-onboarding-");
|
||||
db = createDb(temp.connectionString);
|
||||
}, 120000);
|
||||
beforeEach(async () => { await instanceSettingsService(db).updateExperimental({ enablePublicMcp: true, enableOpenAiDot: true, enableNativeRunner: false }); });
|
||||
afterAll(async () => { await temp?.cleanup(); });
|
||||
async function fixture(authorizationOrigin?: string) {
|
||||
const userId = randomUUID();
|
||||
await db.insert(authUsers).values({ id: userId, name: "Operator", email: userId + "@example.test", createdAt: new Date(), updatedAt: new Date() });
|
||||
const [company] = await db.insert(companies).values({ name: "Dot onboarding", issuePrefix: "DO" + randomBytes(3).toString("hex") }).returning();
|
||||
await db.insert(companyMemberships).values({ companyId: company!.id, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" });
|
||||
const [agent] = await db.insert(agents).values({ companyId: company!.id, name: "Dot", adapterType: "paperclip_runner", status: "active" }).returning();
|
||||
const oauth = createPublicMcpOAuth(db, { ...config, ...(authorizationOrigin ? { authorizationOrigin } : {}) });
|
||||
const client = await oauth.register({ client_name: "Dot", redirect_uris: [callback], grant_types: ["authorization_code", "refresh_token", DEVICE_GRANT] }, randomUUID());
|
||||
const verifier = randomBytes(32).toString("base64url");
|
||||
const input = { client_id: client.client_id, redirect_uri: callback, response_type: "code", resource: config.resource,
|
||||
scope: "paperclip:agent offline_access", code_challenge: createHash("sha256").update(verifier).digest("base64url"), code_challenge_method: "S256" };
|
||||
const begin = async (extra = {}) => (await oauth.authorize({ ...input, ...extra })).split("/").at(-1)!;
|
||||
const pairing = await dotRunnerBroker(db).createPairing({ companyId: company!.id, agentId: agent!.id, operatorId: userId });
|
||||
const id = await begin();
|
||||
return { userId, company: company!, agent: agent!, oauth, client, verifier, input, pairing, id, begin };
|
||||
}
|
||||
it("pins browser ingress without changing the issuer, resource or token endpoint", async () => {
|
||||
const browserOrigin = "https://paperclip-browser.example:10000";
|
||||
const f = await fixture(browserOrigin);
|
||||
const personal = createPublicMcpOAuth(db, { ...config, authorizationOrigin: browserOrigin, resource: config.origin + "/mcp/paperclip" });
|
||||
const app = express(); app.use(express.json());
|
||||
app.use((req, _res, next) => { req.actor = { type: "none" }; next(); });
|
||||
app.use(publicMcpIngressRoutes(f.oauth, vi.fn()));
|
||||
app.use("/api", publicMcpManagementRoutes(personal, f.oauth));
|
||||
const metadata = await request(app).get("/.well-known/oauth-authorization-server/mcp/runner/oauth");
|
||||
expect(metadata.body).toMatchObject({ issuer: config.origin + "/mcp/runner/oauth",
|
||||
authorization_endpoint: browserOrigin + "/mcp/runner/oauth/authorize", token_endpoint: config.origin + "/mcp/runner/oauth/token" });
|
||||
const authorization = await request(app).get("/mcp/runner/oauth/authorize").query(f.input);
|
||||
expect(authorization.status).toBe(303);
|
||||
expect(new URL(authorization.headers.location).origin).toBe(browserOrigin);
|
||||
const path = `/api/mcp/requests/${f.id}/dot-pairing`;
|
||||
expect((await request(app).post(path).set("Origin", "https://other.example").send({ pairingCode: f.pairing.pairingCode })).status).toBe(403);
|
||||
const approval = await request(app).post(path).set("Origin", browserOrigin).send({ pairingCode: f.pairing.pairingCode });
|
||||
expect(approval.status).toBe(200);
|
||||
expect(new URL(approval.body.redirectUrl).searchParams.get("iss")).toBe(config.origin + "/mcp/runner/oauth");
|
||||
const device = await f.oauth.deviceAuthorize({ client_id: f.client.client_id, resource: config.resource, scope: "paperclip:agent" }, randomUUID());
|
||||
expect(device.verification_uri).toBe(browserOrigin + "/mcp-device");
|
||||
});
|
||||
it("validates a separately configured browser origin", () => {
|
||||
expect(publicMcpConfig({ PAPERCLIP_PUBLIC_URL: config.origin, PAPERCLIP_MCP_AUTHORIZATION_ORIGIN: "https://browser.example:10000" }))
|
||||
.toMatchObject({ origin: config.origin, resource: config.origin + "/mcp/paperclip", authorizationOrigin: "https://browser.example:10000" });
|
||||
for (const invalid of ["http://browser.example", "https://secret@browser.example", "https://browser.example/path", "https://browser.example?next=evil", "https://browser.example#fragment"])
|
||||
expect(() => publicMcpConfig({ PAPERCLIP_PUBLIC_URL: config.origin, PAPERCLIP_MCP_AUTHORIZATION_ORIGIN: invalid })).toThrow();
|
||||
});
|
||||
it("connects without a board session, binds the exact agent, and consumes the capability once", async () => {
|
||||
const f = await fixture();
|
||||
const personal = createPublicMcpOAuth(db, { ...config, resource: config.origin + "/mcp/paperclip" });
|
||||
const app = express(); app.use(express.json());
|
||||
app.use((req, _res, next) => { req.actor = { type: "none" }; next(); });
|
||||
app.use("/api", publicMcpManagementRoutes(personal, f.oauth));
|
||||
const path = `/api/mcp/requests/${f.id}/dot-pairing`;
|
||||
expect((await request(app).post(path).send({ pairingCode: f.pairing.pairingCode })).status).toBe(403);
|
||||
const preview = await request(app).post(path + "/preview").set("Origin", config.origin).send({ pairingCode: f.pairing.pairingCode });
|
||||
expect(preview.status).toBe(200);
|
||||
expect(preview.body).toMatchObject({ company: { id: f.company.id, name: f.company.name }, agent: { id: f.agent.id, name: f.agent.name } });
|
||||
expect(JSON.stringify(preview.body)).not.toContain(f.pairing.pairingCode);
|
||||
expect(await db.select().from(mcpOauthGrants).where(eq(mcpOauthGrants.userId, f.userId))).toHaveLength(0);
|
||||
const approval = await request(app).post(path).set("Origin", config.origin).send({ pairingCode: f.pairing.pairingCode });
|
||||
expect(approval.status).toBe(200);
|
||||
const redirect = new URL(approval.body.redirectUrl);
|
||||
expect(redirect.origin + redirect.pathname).toBe(callback);
|
||||
const exchange = { grant_type: "authorization_code", client_id: f.client.client_id, redirect_uri: callback, resource: config.resource,
|
||||
code: redirect.searchParams.get("code"), code_verifier: f.verifier };
|
||||
await expect(f.oauth.token({ ...exchange, code_verifier: "x".repeat(43) })).rejects.toThrow();
|
||||
const tokens = await f.oauth.token(exchange);
|
||||
const principal = await f.oauth.authenticate(tokens.access_token);
|
||||
expect(principal.actor).toMatchObject({ type: "agent", agentId: f.agent.id, companyId: f.company.id });
|
||||
expect(principal.grant.scopes).toEqual(["paperclip:agent", "offline_access"]);
|
||||
await expect(personal.authenticate(tokens.access_token)).rejects.toThrow();
|
||||
expect(await dotRunnerBroker(db).mailbox(principal)).toMatchObject({ bindingId: f.pairing.bindingId });
|
||||
const [binding] = await db.select().from(dotAgentBindings).where(eq(dotAgentBindings.id, f.pairing.bindingId));
|
||||
expect(binding).toMatchObject({ status: "connected", pairingCodeHash: null, pairingExpiresAt: null });
|
||||
expect((await db.select().from(agents).where(eq(agents.id, f.agent.id)))[0]!.adapterConfig.dotBindingId).toBe(f.pairing.bindingId);
|
||||
await expect(f.oauth.consentDotPairing(f.id, f.pairing.pairingCode)).rejects.toThrow();
|
||||
await expect(f.oauth.consentDotPairing(await f.begin(), f.pairing.pairingCode)).rejects.toThrow();
|
||||
});
|
||||
it.each(["expired-code", "expired-request", "revoked", "paused", "viewer", "wrong-company", "dot-disabled", "mcp-disabled"])("rejects %s without creating a grant", async failure => {
|
||||
const f = await fixture();
|
||||
let id = f.id;
|
||||
if (failure === "expired-code") await db.update(dotAgentBindings).set({ pairingExpiresAt: new Date(0) }).where(eq(dotAgentBindings.id, f.pairing.bindingId));
|
||||
if (failure === "expired-request") await db.update(mcpOauthRequests).set({ expiresAt: new Date(0) }).where(eq(mcpOauthRequests.id, id));
|
||||
if (failure === "revoked") await dotRunnerBroker(db).revoke(f.company.id, f.agent.id, f.userId);
|
||||
if (failure === "paused") await db.update(agents).set({ status: "paused" }).where(eq(agents.id, f.agent.id));
|
||||
if (failure === "viewer") await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(and(eq(companyMemberships.companyId, f.company.id), eq(companyMemberships.principalId, f.userId)));
|
||||
if (failure === "wrong-company") id = await f.begin({ company_id: randomUUID() });
|
||||
if (failure === "dot-disabled") await instanceSettingsService(db).updateExperimental({ enableOpenAiDot: false });
|
||||
if (failure === "mcp-disabled") await instanceSettingsService(db).updateExperimental({ enablePublicMcp: false });
|
||||
await expect(f.oauth.describeDotPairing(id, f.pairing.pairingCode)).rejects.toThrow();
|
||||
await expect(f.oauth.consentDotPairing(id, f.pairing.pairingCode)).rejects.toThrow();
|
||||
expect(await db.select().from(mcpOauthGrants).where(eq(mcpOauthGrants.userId, f.userId))).toHaveLength(0);
|
||||
});
|
||||
it("prevents concurrent requests and the personal endpoint from spending the same capability", async () => {
|
||||
const f = await fixture();
|
||||
const personal = createPublicMcpOAuth(db, { ...config, resource: config.origin + "/mcp/paperclip" });
|
||||
await expect(personal.consentDotPairing(f.id, f.pairing.pairingCode)).rejects.toMatchObject({ status: 403 });
|
||||
const other = await f.begin();
|
||||
const results = await Promise.allSettled([f.oauth.consentDotPairing(f.id, f.pairing.pairingCode), f.oauth.consentDotPairing(other, f.pairing.pairingCode)]);
|
||||
expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1);
|
||||
expect(await db.select().from(mcpOauthGrants).where(eq(mcpOauthGrants.userId, f.userId))).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 2195 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user