mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents can propose secrets and bindings for approval. > - Pending proposals already contain their expiry and encrypted value. > - The server scans for expired proposals on every maintenance tick, even when none exist. > - Committed proposal changes and saved expiry times provide the required triggers. > - This PR uses those triggers and preserves scrubbing, approval checks, and recovery. > - Empty and completed proposal history no longer causes recurring expiry queries. ## Linked Issues or Issue Description Refs #17443. This is the second PR in the sleep migration stack and targets its branch. **What problem does this solve?** The periodic proposal scan contacts PostgreSQL when no proposal needs maintenance. That obstructs idle database behavior across tenant instances. **What is the proposed solution?** Use transaction-aware notifications for creation and resolution. Reconstruct the earliest pending expiry at startup. Run cleanup at that deadline, or retry while reconciliation remains unfinished. **Why is this useful?** It removes an idle scan while retaining security checks and physical ciphertext cleanup. No queue table or migration is required. ## What Changed - Added intent notifications before secret/binding creation, approval, terminal transitions, and linked interaction resolution. The existing outer transaction owns settlement. - Kept expiry selection, ciphertext scrubbing, dependent rejection, and authorization in the secret-proposal service. - Registered a startup and deadline worker in place of the aggregate maintenance sweep. Empty/terminal states disarm; overdue batches and failures retain retries. - Reused shared scoped deadline dispatch for secret expiry. The service owns company-filtered SQL and expiry policy. Custom-image expiry continues to use `registerDeadlineWork`. - Added real PostgreSQL producer, outer-transaction, concurrent-write, expiry, restart, interaction resolution, and drain/resume tests. Updated startup wiring fixtures and documented the supported writer/lifecycle contract. ## Verification - Landing reconciliation at `c10eb1bcf752b058713e05d3345e5cf38dd3fa72`: 160 tests pass across the complete secret-proposal, scoped deadline, company batch, coordinator, startup, custom-image service and custom-image worker suites; no cases skipped in the accepted runs. Server typecheck passes. This integrates master after #17443 landed; fresh remote CI and Ponytail review are required before merge. Current head: `5bbe17ad9ebc4573b08ed1a9323644ef9608ca09`. This remains a single-duty PR stacked on #17443. - Integrated pinned master `8548173fc9efdc0fa07644fb92dcc0b6851a95e5` with normal merges. Every branch passed generated-schema checks and its target-branch migration-order gate. Existing SQL is unchanged; the 25 stack migrations follow master’s new migration. - Duty-specific producer coverage, lifecycle tests, and earlier failure records: [2026-10-08-instance-sleep-and-scheduler-inventory.md](https://github.com/paperclipai/paperclip/blob/5bbe17ad9ebc4573b08ed1a9323644ef9608ca09/doc/plans/2026-10-08-instance-sleep-and-scheduler-inventory.md), [2026-10-24-recurring-server-sleep-stack.md](https://github.com/paperclipai/paperclip/blob/5bbe17ad9ebc4573b08ed1a9323644ef9608ca09/doc/plans/2026-10-24-recurring-server-sleep-stack.md). - Combined tip `51e6cbe67979b55b340ea9f0f434c5b5c682ccd6`: **355 startup/cancellation/recovery/dispatch/preservation tests**, **75 connection-management tests**, and production server typechecking pass. These combined checks do not mean every intermediate PR independently ran the full repository suite. - Real master-to-stack database upgrade passes: **379 unchanged prior migration receipts plus 25 new migrations**, with the gateway responsibility fixture preserved. - Owning-branch checks: 84 runner installation/settlement cases plus runner typechecking and generated TypeScript/Rust profile agreement; 253 GitHub review workflow cases. Stop-attribution and held-worker readiness regressions failed before their fixes. - Failure accounting: the initial combined run passed 349 cases and failed six idle assertions in a reused preservation database containing old cancelled runs. Repeating the combined command with a fresh preservation database passed. Earlier invalid project selection, merge import/type errors, and the interrupted full local suite are not counted as passing checks. - **Latest-head CI and review remain required.** Prior CI findings include GitHub idle observation, route teardown, attention cleanup, touch-picker E2E, workspace health, and voice-cleanup timing; local passes do not erase those failures. No full local repository-suite, deployed sleep, fleet cost, or live Copilot qualification claim is made. ## Risks - Notifications cover supported writers sharing the database signal owner. Independent SQL writers and other process roots require a separate ingress contract; startup recovery alone is not live replica support. - The shared coordinator and scoped worker also serve later stack duties. Their defaults remain intact; existing voice, chat, native, and startup consumers are included in integrated verification. - Startup now checks overdue proposals immediately. During drain, physical cleanup waits; approval still rejects expired proposals synchronously. - Each due company pass remains bounded to 100 rows. Failed scopes back off from 30 seconds to five minutes; healthy overdue work keeps draining. Self-generated write hints coalesce and do not erase a failed sweep's backoff. - Heartbeat-disabled behavior and Cloud sleep-safety gates remain unchanged. This does not establish deployed suspension or external wake coverage. Tool review and gateway token cleanup remain separate slices. ## Model Used OpenAI Codex, GPT-6 family, with reasoning and code execution. The exact serving model identifier and context window were not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1897 lines
78 KiB
TypeScript
1897 lines
78 KiB
TypeScript
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
|
import { registerSecretProposalExpiryWork } from "../services/secret-proposal-expiry-work.js";
|
|
import { deleteCompany } from "../services/company-deletion.js";
|
|
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
|
import { idleWorkSnapshot } from "../services/task-admission.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import { mkdirSync, rmSync } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
activityLog,
|
|
agents,
|
|
companies,
|
|
companySecretBindings,
|
|
companySecretProposals,
|
|
companySecretProviderConfigs,
|
|
companySecretVersions,
|
|
companySecrets,
|
|
createDb,
|
|
heartbeatRuns,
|
|
issueComments,
|
|
issueThreadInteractions,
|
|
issues,
|
|
userSecretDeclarations,
|
|
userSecretDefinitions,
|
|
} from "@paperclipai/db";
|
|
import { conflict } from "../errors.js";
|
|
import { errorHandler } from "../middleware/error-handler.js";
|
|
import { secretRoutes } from "../routes/secrets.js";
|
|
import { awsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js";
|
|
import type { IssueAssignmentWakeupDeps } from "../services/issue-assignment-wakeup.js";
|
|
import { issueService } from "../services/issues.js";
|
|
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
|
|
import { agentService } from "../services/agents.js";
|
|
import { createSecretProposalsService } from "../services/secret-proposals.js";
|
|
import { secretService } from "../services/secrets.js";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
|
|
const externalTestDatabase = resolveExternalTestDatabaseUrl("PAPERCLIP_SECRET_PROPOSAL_TEST_DATABASE_URL");
|
|
const embeddedPostgresSupport = externalTestDatabase ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
describeEmbeddedPostgres("secret proposal routes", () => {
|
|
let stopDb: (() => Promise<void>) | null = null;
|
|
let db!: ReturnType<typeof createDb>;
|
|
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
|
const secretsTmpDir = path.join(os.tmpdir(), `paperclip-secret-proposals-${randomUUID()}`);
|
|
|
|
beforeAll(async () => {
|
|
mkdirSync(secretsTmpDir, { recursive: true });
|
|
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key");
|
|
if (externalTestDatabase) { db = createDb(externalTestDatabase, { maxConnections: 6, idleTimeoutSeconds: 0, maxLifetimeSeconds: 0 }); return; }
|
|
const started = await startEmbeddedPostgresTestDatabase("secret-proposal-routes");
|
|
stopDb = started.cleanup;
|
|
db = createDb(started.connectionString, { maxConnections: 6, idleTimeoutSeconds: 0, maxLifetimeSeconds: 0 });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
await db.delete(activityLog);
|
|
await db.delete(issueComments);
|
|
await db.delete(companySecretProposals);
|
|
await db.delete(issueThreadInteractions);
|
|
await db.delete(companySecretBindings);
|
|
await db.delete(companySecretVersions);
|
|
await db.delete(companySecrets);
|
|
await db.delete(userSecretDeclarations);
|
|
await db.delete(userSecretDefinitions);
|
|
await db.delete(companySecretProviderConfigs);
|
|
await db.delete(issues);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(agents);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db?.$client.end({ timeout: 1 });
|
|
await stopDb?.();
|
|
if (previousKeyFile === undefined) delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
|
else process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
|
|
rmSync(secretsTmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
async function seedRun(identifier = "SEC-1", companyId = randomUUID()) {
|
|
const agentId = randomUUID();
|
|
const heartbeatRunId = randomUUID();
|
|
const issueId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Secret proposals",
|
|
issuePrefix: `P${companyId.slice(0, 7)}`.toUpperCase(),
|
|
status: "active",
|
|
});
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Proposer",
|
|
role: "engineer",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
permissions: {},
|
|
status: "idle",
|
|
});
|
|
await db.insert(heartbeatRuns).values({
|
|
id: heartbeatRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
responsibleUserId: "user-1",
|
|
contextSnapshot: { issueId },
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Needs credential",
|
|
identifier,
|
|
status: "in_progress",
|
|
responsibleUserId: "user-1",
|
|
executionRunId: heartbeatRunId,
|
|
});
|
|
return { companyId, agentId, heartbeatRunId, issueId };
|
|
}
|
|
|
|
async function holdIssueRowLock(issueId: string) {
|
|
let signalLocked!: () => void;
|
|
let releaseLock!: () => void;
|
|
const locked = new Promise<void>((resolve) => {
|
|
signalLocked = resolve;
|
|
});
|
|
const release = new Promise<void>((resolve) => {
|
|
releaseLock = resolve;
|
|
});
|
|
const transaction = db.transaction(async (tx) => {
|
|
await tx.select({ id: issues.id }).from(issues).where(eq(issues.id, issueId)).for("update");
|
|
signalLocked();
|
|
await release;
|
|
});
|
|
await locked;
|
|
return async () => {
|
|
releaseLock();
|
|
await transaction;
|
|
};
|
|
}
|
|
|
|
async function waitForBlockedForUpdate(tableName: string) {
|
|
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
const [waiting] = await db.execute<{ waiting: boolean }>(sql`
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM pg_stat_activity
|
|
WHERE state = 'active'
|
|
AND wait_event_type = 'Lock'
|
|
AND query ILIKE ${`%${tableName}%`}
|
|
AND query ILIKE '%for update%'
|
|
) AS waiting
|
|
`);
|
|
if (waiting?.waiting) return true;
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function lockProposalNowait(proposalId: string) {
|
|
await db.transaction(async (tx) => {
|
|
await tx.execute(sql`
|
|
SELECT id
|
|
FROM company_secret_proposals
|
|
WHERE id = ${proposalId}
|
|
FOR UPDATE NOWAIT
|
|
`);
|
|
});
|
|
}
|
|
|
|
function createAgentApp(
|
|
fixture: Awaited<ReturnType<typeof seedRun>>,
|
|
source: "agent_jwt" | "agent_key" = "agent_jwt",
|
|
keyScope: { kind: "standard" } | { kind: "task_bridge"; parentIssueId: string } = { kind: "standard" },
|
|
) {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
req.actor = {
|
|
type: "agent",
|
|
agentId: fixture.agentId,
|
|
companyId: fixture.companyId,
|
|
runId: fixture.heartbeatRunId,
|
|
source,
|
|
keyScope,
|
|
};
|
|
next();
|
|
});
|
|
app.use("/api", secretRoutes(db));
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
|
|
function createBoardApp(
|
|
fixture: Awaited<ReturnType<typeof seedRun>>,
|
|
options?: {
|
|
admin?: boolean;
|
|
heartbeat?: IssueAssignmentWakeupDeps;
|
|
issues?: Pick<ReturnType<typeof issueService>, "getById" | "addComment">;
|
|
},
|
|
) {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
req.actor = {
|
|
type: "board",
|
|
userId: "board-user",
|
|
companyIds: [fixture.companyId],
|
|
source: options?.admin === false ? "session" : "local_implicit",
|
|
memberships: options?.admin === false
|
|
? [{ companyId: fixture.companyId, status: "active", membershipRole: "member" }]
|
|
: undefined,
|
|
};
|
|
next();
|
|
});
|
|
app.use("/api", secretRoutes(db, { heartbeat: options?.heartbeat, issues: options?.issues }));
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
|
|
describe("expiry scheduling", () => {
|
|
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
|
|
let proposals: ReturnType<typeof createSecretProposalsService>;
|
|
let fixture: Awaited<ReturnType<typeof seedRun>>;
|
|
let draining: boolean;
|
|
const errors: unknown[] = [];
|
|
const expiryMs = 14 * 24 * 60 * 60_000;
|
|
|
|
beforeEach(async () => {
|
|
await Promise.all(Array.from({ length: 6 }, () => db.execute(sql`select pg_sleep(0.01)`)));
|
|
fixture = await seedRun();
|
|
proposals = createSecretProposalsService(db);
|
|
draining = false;
|
|
errors.length = 0;
|
|
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], shouldAdvanceTime: true });
|
|
});
|
|
afterEach(async () => {
|
|
await coordinator?.stop();
|
|
coordinator = undefined;
|
|
vi.useRealTimers();
|
|
expect(errors).toEqual([]);
|
|
});
|
|
async function start() {
|
|
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => !draining,
|
|
canReconcile: () => true, onError: error => errors.push(error) });
|
|
await registerSecretProposalExpiryWork(coordinator, proposals, () => !draining).ready;
|
|
}
|
|
async function settle(ms = 60_000) {
|
|
await vi.advanceTimersByTimeAsync(ms);
|
|
if (!draining) await vi.waitFor(() => expect(idleWorkSnapshot().active === 0).toBe(true), { timeout: 5000 });
|
|
}
|
|
async function quiet() {
|
|
await settle();
|
|
expect(coordinator!.nextWakeAt()).toBeNull();
|
|
const reads = vi.spyOn(db, "select");
|
|
await settle(60 * 60_000);
|
|
expect(reads).not.toHaveBeenCalled();
|
|
reads.mockRestore();
|
|
}
|
|
const create = (service = proposals) => service.createSecret({ ...fixture, registerForRedaction: () => {} }, {
|
|
name: "dev/scheduled/secret", value: "expiry-test-only", justification: "Test durable expiry",
|
|
});
|
|
const bind = (secretProposalId: string) => proposals.createBinding(fixture, {
|
|
secretProposalId, configPath: "access.SCHEDULED", justification: "Test dependent expiry",
|
|
bindingTargetPolicy: "self_and_reports",
|
|
});
|
|
|
|
async function createWithExpiry(expiresAt: Date) {
|
|
return db.transaction(async tx => {
|
|
const proposal = await create(createSecretProposalsService(tx as unknown as typeof db));
|
|
const [updated] = await tx.update(companySecretProposals).set({ expiresAt })
|
|
.where(eq(companySecretProposals.id, proposal.id)).returning();
|
|
return updated;
|
|
});
|
|
}
|
|
|
|
it("honors a saved five-second expiry without clamping it to recovery spacing", async () => {
|
|
const delay = 5000;
|
|
await start(); await quiet();
|
|
const expiresAt = new Date(Date.now() + delay);
|
|
const proposal = await createWithExpiry(expiresAt); await settle(1);
|
|
expect(coordinator!.nextWakeAt()).toBe(expiresAt.getTime());
|
|
await settle(delay / 2);
|
|
expect(await proposals.getById(fixture.companyId, proposal.id)).toMatchObject({ status: "pending" });
|
|
await settle(delay);
|
|
expect(await proposals.getById(fixture.companyId, proposal.id)).toMatchObject({ status: "expired", valueCiphertext: null });
|
|
await quiet();
|
|
});
|
|
|
|
it("moves to an earlier committed proposal deadline without losing the later one", async () => {
|
|
await start(); await quiet();
|
|
const later = await createWithExpiry(new Date(Date.now() + 120_000)); await settle(1);
|
|
expect(coordinator!.nextWakeAt()).toBe(later.expiresAt.getTime());
|
|
const earlier = await createWithExpiry(new Date(Date.now() + 5000)); await settle(1);
|
|
expect(coordinator!.nextWakeAt()).toBe(earlier.expiresAt.getTime());
|
|
await settle(6000);
|
|
expect(await proposals.getById(fixture.companyId, earlier.id)).toMatchObject({ status: "expired", valueCiphertext: null });
|
|
expect(await proposals.getById(fixture.companyId, later.id)).toMatchObject({ status: "pending" });
|
|
expect(coordinator!.nextWakeAt()).toBe(later.expiresAt.getTime());
|
|
await settle(120_000); await quiet();
|
|
});
|
|
|
|
it("keeps sequential tenant writes from scanning another company's proposals", async () => {
|
|
const other = await seedRun("OTHER-1");
|
|
const foreign = await proposals.createSecret({ ...other, registerForRedaction: () => {} }, {
|
|
name: "dev/other/secret", value: "other-expiry-only", justification: "Another company's deadline",
|
|
});
|
|
await start(); await settle(1);
|
|
const reads: Array<{ query: string; params: unknown[] }> = [];
|
|
const debug = db.$client.options.debug;
|
|
db.$client.options.debug = (_id, query, params) => {
|
|
if (/^select/i.test(query) && query.includes('"company_secret_proposals"') &&
|
|
(query.includes('min(') || query.includes('"expires_at" <='))) reads.push({ query, params: [...params] });
|
|
};
|
|
try {
|
|
for (let i = 0; i < 3; i++) { await create(); await settle(1000); }
|
|
} finally { db.$client.options.debug = debug; }
|
|
expect(reads.length).toBeGreaterThan(0);
|
|
for (const read of reads) {
|
|
expect(read.query).toContain('"company_secret_proposals"."company_id" =');
|
|
expect(read.params).toContain(fixture.companyId);
|
|
expect(read.params).not.toContain(other.companyId);
|
|
}
|
|
expect(await proposals.getById(other.companyId, foreign.id)).toMatchObject({ status: "pending", valueCiphertext: foreign.valueCiphertext });
|
|
});
|
|
|
|
it("expires only the company whose saved deadline is due", async () => {
|
|
const other = await seedRun("OTHER-1");
|
|
const foreign = await proposals.createSecret({ ...other, registerForRedaction: () => {} }, {
|
|
name: "dev/other/secret", value: "other-expiry-only", justification: "Another company's deadline",
|
|
});
|
|
const due = await createWithExpiry(new Date(Date.now() + 5000));
|
|
await start(); await settle(1);
|
|
const reads: Array<{ query: string; params: unknown[] }> = [];
|
|
const debug = db.$client.options.debug;
|
|
db.$client.options.debug = (_id, query, params) => {
|
|
if (/^select/i.test(query) && query.includes('"company_secret_proposals"') &&
|
|
(query.includes('min(') || query.includes('"expires_at" <='))) reads.push({ query, params: [...params] });
|
|
};
|
|
try { await settle(6000); } finally { db.$client.options.debug = debug; }
|
|
expect(reads.some(read => read.query.includes('"expires_at" <='))).toBe(true);
|
|
for (const read of reads) {
|
|
expect(read.query).toContain('"company_secret_proposals"."company_id" =');
|
|
expect(read.params).toContain(fixture.companyId);
|
|
expect(read.params).not.toContain(other.companyId);
|
|
}
|
|
expect(await proposals.getById(fixture.companyId, due.id)).toMatchObject({ status: "expired", valueCiphertext: null });
|
|
expect(await proposals.getById(other.companyId, foreign.id)).toMatchObject({ status: "pending", valueCiphertext: foreign.valueCiphertext });
|
|
expect(coordinator!.nextWakeAt()).toBe(foreign.expiresAt.getTime());
|
|
});
|
|
|
|
it.each([0, 3000])("keeps a failed company on backoff while another company expires on time (offset %s)", async offset => {
|
|
// Make the failing company first in the fair dispatch order.
|
|
const other = await seedRun("OTHER-1", "ffffffff-ffff-ffff-ffff-ffffffffffff");
|
|
const failing = await createWithExpiry(new Date(Date.now() + 5000));
|
|
const healthy = await db.transaction(async tx => {
|
|
const proposal = await createSecretProposalsService(tx as unknown as typeof db).createSecret(
|
|
{ ...other, registerForRedaction: () => {} },
|
|
{ name: "dev/other/secret", value: "other-expiry-only", justification: "Independent expiry" });
|
|
const [saved] = await tx.update(companySecretProposals).set({ expiresAt: new Date(failing.expiresAt.getTime() + offset) })
|
|
.where(eq(companySecretProposals.id, proposal.id)).returning();
|
|
return saved;
|
|
});
|
|
const sweep = proposals.sweepExpiredForCompany;
|
|
let unavailable = true;
|
|
const attempts = vi.spyOn(proposals, "sweepExpiredForCompany").mockImplementation(async companyId => {
|
|
if (unavailable && companyId === fixture.companyId) throw new Error("company expiry unavailable");
|
|
return sweep(companyId);
|
|
});
|
|
await start(); await settle(10_000);
|
|
expect(errors).toHaveLength(1); errors.length = 0;
|
|
expect(await proposals.getById(other.companyId, healthy.id)).toMatchObject({ status: "expired", valueCiphertext: null });
|
|
expect(await proposals.getById(fixture.companyId, failing.id)).toMatchObject({ status: "pending" });
|
|
// A new write must refresh this company without erasing its failed sweep's backoff.
|
|
await create(); await settle(1000);
|
|
expect(attempts.mock.calls.filter(([id]) => id === fixture.companyId)).toHaveLength(1);
|
|
unavailable = false; await settle(30_000);
|
|
expect(await proposals.getById(fixture.companyId, failing.id)).toMatchObject({ status: "expired", valueCiphertext: null });
|
|
await settle(expiryMs); await quiet();
|
|
});
|
|
|
|
it("removes the cached expiry when the company deletion owner commits", async () => {
|
|
await create(); await start(); await settle(1);
|
|
expect(coordinator!.nextWakeAt()).not.toBeNull();
|
|
await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() })
|
|
.where(eq(heartbeatRuns.id, fixture.heartbeatRunId));
|
|
await db.update(issues).set({ executionRunId: null }).where(eq(issues.id, fixture.issueId));
|
|
// The deletion fixture has no live resources; its agent has already
|
|
// finished termination before entering the company deletion transaction.
|
|
await db.update(agents).set({ status: "terminated", lifecycleState: "terminated" })
|
|
.where(eq(agents.id, fixture.agentId));
|
|
expect(await deleteCompany(db, fixture.companyId)).toMatchObject({ id: fixture.companyId });
|
|
await quiet();
|
|
expect(await proposals.expiryDeadlines()).toEqual([]);
|
|
});
|
|
|
|
it("stays quiet until the real proposal deadline, scrubs ciphertext, and leaves terminal history quiet", async () => {
|
|
await start(); await quiet();
|
|
const secret = await create();
|
|
const binding = await bind(secret.id);
|
|
expect(secret.valueCiphertext).not.toBeNull();
|
|
await settle();
|
|
expect(coordinator!.nextWakeAt()).toBe(secret.expiresAt.getTime());
|
|
const reads = vi.spyOn(db, "select");
|
|
await settle(24 * 60 * 60_000);
|
|
expect(reads).not.toHaveBeenCalled(); reads.mockRestore();
|
|
await settle(expiryMs);
|
|
expect(await proposals.getById(fixture.companyId, secret.id)).toMatchObject({
|
|
status: "expired", valueCiphertext: null, ciphertextScrubbedAt: expect.any(Date),
|
|
});
|
|
expect(await proposals.getById(fixture.companyId, binding.id)).toMatchObject({ status: "rejected" });
|
|
expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, binding.interactionId!)))
|
|
.toEqual([expect.objectContaining({ status: "rejected" })]);
|
|
await quiet();
|
|
});
|
|
|
|
it.each([false, true])("only observes the producer after outer settlement (rollback %s)", async rollback => {
|
|
await start(); await quiet();
|
|
let release!: () => void, written!: () => void;
|
|
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
const staged = new Promise<void>(resolve => { written = resolve; });
|
|
const rollbackError = new Error("rollback fixture");
|
|
const writing = db.transaction(async tx => {
|
|
await create(createSecretProposalsService(tx as unknown as typeof db));
|
|
written(); await gate;
|
|
if (rollback) throw rollbackError;
|
|
}).catch(error => { if (error !== rollbackError) throw error; });
|
|
await staged;
|
|
try {
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
expect(await proposals.nextExpiryAt()).toBeNull();
|
|
} finally { release(); }
|
|
await writing; await settle();
|
|
expect(await proposals.nextExpiryAt()).toEqual(rollback ? null : expect.any(Number));
|
|
if (rollback) await quiet();
|
|
else expect(coordinator!.nextWakeAt()).toBe(await proposals.nextExpiryAt());
|
|
});
|
|
|
|
it("retains a proposal committed while an empty deadline read is finishing", async () => {
|
|
let release!: () => void, reading!: () => void;
|
|
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
const entered = new Promise<void>(resolve => { reading = resolve; });
|
|
const readDeadline = proposals.expiryDeadlines;
|
|
vi.spyOn(proposals, "expiryDeadlines").mockImplementationOnce(async () => {
|
|
const stale = await readDeadline();
|
|
reading(); await gate;
|
|
return stale;
|
|
});
|
|
const starting = start();
|
|
await entered;
|
|
let secret!: Awaited<ReturnType<typeof create>>;
|
|
try { secret = await create(); } finally { release(); }
|
|
await starting; await settle();
|
|
expect(coordinator!.nextWakeAt()).toBe(secret.expiresAt.getTime());
|
|
await proposals.transition(fixture.companyId, secret.id, "withdrawn", { proposerAgentId: fixture.agentId });
|
|
await quiet();
|
|
});
|
|
|
|
it("reconstructs a deadline after restart and disarms when the proposer withdraws", async () => {
|
|
const secret = await create();
|
|
await start(); await settle();
|
|
expect(coordinator!.nextWakeAt()).toBe(secret.expiresAt.getTime());
|
|
await proposals.transition(fixture.companyId, secret.id, "withdrawn", { proposerAgentId: fixture.agentId });
|
|
await quiet();
|
|
expect(await proposals.getById(fixture.companyId, secret.id)).toMatchObject({ valueCiphertext: null });
|
|
});
|
|
|
|
it.each(["startup", "first_write", "failed_pass"])("retains %s work through drain and still rejects expired approval on use", async mode => {
|
|
if (mode === "startup") draining = true;
|
|
await start();
|
|
if (mode === "first_write") { await quiet(); draining = true; }
|
|
const secret = await create();
|
|
if (mode === "failed_pass") {
|
|
vi.spyOn(proposals, "expiryDeadlines").mockRejectedValueOnce(new Error("database unavailable"));
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
await vi.waitFor(() => expect(errors).toHaveLength(1)); errors.length = 0;
|
|
draining = true;
|
|
}
|
|
await settle();
|
|
vi.setSystemTime(Date.now() + expiryMs);
|
|
const reads = vi.spyOn(db, "select");
|
|
await settle(2 * 60 * 60_000);
|
|
expect(reads).not.toHaveBeenCalled(); reads.mockRestore();
|
|
expect(await proposals.getById(fixture.companyId, secret.id)).toMatchObject({ status: "pending" });
|
|
await expect(proposals.approve(fixture.companyId, secret.id, { resolvedByUserId: "board-user" }))
|
|
.rejects.toThrow("Expired proposals cannot be approved");
|
|
draining = false; await settle();
|
|
expect(await proposals.getById(fixture.companyId, secret.id)).toMatchObject({ status: "expired", valueCiphertext: null });
|
|
await quiet();
|
|
});
|
|
|
|
it.each(["rejected", "execution_failed"])("reschedules when the interaction owner resolves a binding: %s", async mode => {
|
|
const secret = await create();
|
|
await start();
|
|
const approved = await proposals.approve(fixture.companyId, secret.id, { resolvedByUserId: "board-user" });
|
|
await quiet();
|
|
const remaining = await proposals.createBinding(fixture, { secretId: approved.createdSecretId,
|
|
configPath: "access.SECOND", justification: "Independent binding", bindingTargetPolicy: "self_and_reports" });
|
|
await settle();
|
|
expect(coordinator!.nextWakeAt()).toBe(remaining.expiresAt.getTime());
|
|
const interactions = issueThreadInteractionService(db);
|
|
if (mode === "rejected") {
|
|
await interactions.rejectInteraction({ id: fixture.issueId, companyId: fixture.companyId, status: "in_progress" },
|
|
remaining.interactionId!, { reason: "No longer required" }, { userId: "board-user" });
|
|
} else {
|
|
await db.update(issueThreadInteractions).set({ status: "accepted", result: { version: 1, outcome: "accepted" },
|
|
resolvedByUserId: "board-user", resolvedAt: new Date() }).where(eq(issueThreadInteractions.id, remaining.interactionId!));
|
|
await interactions.recordSecretProposalExecutionResult({ id: fixture.issueId, companyId: fixture.companyId },
|
|
remaining.interactionId!, remaining.id, { status: "failed", errorCode: "permission_denied" });
|
|
}
|
|
expect(await proposals.getById(fixture.companyId, remaining.id)).toMatchObject({ status: "rejected" });
|
|
await quiet();
|
|
});
|
|
});
|
|
|
|
it("requires company admin access to reject secret proposals", async () => {
|
|
const fixture = await seedRun();
|
|
const proposed = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({ kind: "secret", name: "dev/reject/token", value: "reject-secret", justification: "Needed" });
|
|
|
|
const rejected = await request(createBoardApp(fixture, { admin: false }))
|
|
.post(`/api/companies/${fixture.companyId}/secret-proposals/${proposed.body.id}/reject`)
|
|
.send({ reason: "Not approved" });
|
|
|
|
expect(rejected.status).toBe(403);
|
|
expect(await db.select().from(companySecretProposals).where(eq(companySecretProposals.id, proposed.body.id)))
|
|
.toEqual([expect.objectContaining({ status: "pending", valueCiphertext: expect.any(Object) })]);
|
|
});
|
|
|
|
it("keeps committed approve and reject responses successful when resolution notifications fail", async () => {
|
|
const fixture = await seedRun();
|
|
await db.update(issues)
|
|
.set({ assigneeAgentId: fixture.agentId })
|
|
.where(eq(issues.id, fixture.issueId));
|
|
const wakeup = vi.fn().mockRejectedValue(new Error("wakeup queue unavailable"));
|
|
const persistedIssues = issueService(db);
|
|
const addComment = vi.fn().mockRejectedValue(new Error("comment store unavailable"));
|
|
const boardApp = createBoardApp(fixture, {
|
|
heartbeat: { wakeup },
|
|
issues: { getById: persistedIssues.getById, addComment },
|
|
});
|
|
|
|
const approvedProposal = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "secret",
|
|
name: "dev/notification/approve",
|
|
value: "approval-value",
|
|
justification: "Needed by task",
|
|
});
|
|
const approved = await request(boardApp)
|
|
.post(`/api/companies/${fixture.companyId}/secret-proposals/${approvedProposal.body.id}/approve`)
|
|
.send({});
|
|
|
|
expect(approved.status).toBe(200);
|
|
expect(approved.body.status).toBe("approved");
|
|
|
|
const rejectedProposal = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "secret",
|
|
name: "dev/notification/reject",
|
|
value: "rejection-value",
|
|
justification: "Needed by task",
|
|
});
|
|
const rejected = await request(boardApp)
|
|
.post(`/api/companies/${fixture.companyId}/secret-proposals/${rejectedProposal.body.id}/reject`)
|
|
.send({ reason: "No longer needed" });
|
|
|
|
expect(rejected.status).toBe(200);
|
|
expect(rejected.body.status).toBe("rejected");
|
|
expect(addComment).toHaveBeenCalledTimes(2);
|
|
expect(wakeup).toHaveBeenCalledTimes(2);
|
|
expect(await db.select().from(companySecretProposals)).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ id: approvedProposal.body.id, status: "approved", valueCiphertext: null }),
|
|
expect.objectContaining({ id: rejectedProposal.body.id, status: "rejected", valueCiphertext: null }),
|
|
]));
|
|
expect(await db.select().from(issueComments)).toHaveLength(0);
|
|
});
|
|
|
|
it("uses the selected provider vault when approving a secret proposal", async () => {
|
|
const fixture = await seedRun();
|
|
const vault = await secretService(db).createProviderConfig(fixture.companyId, {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS production",
|
|
config: { region: "us-east-1", namespace: "prod-use1" },
|
|
});
|
|
const externalRef =
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/proposed-token";
|
|
const createSecret = vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({
|
|
material: {
|
|
scheme: "aws_secrets_manager_v1",
|
|
secretId: externalRef,
|
|
versionId: "aws-version-1",
|
|
source: "managed",
|
|
},
|
|
valueSha256: "value-sha-1",
|
|
fingerprintSha256: "fingerprint-sha-1",
|
|
externalRef,
|
|
providerVersionRef: "aws-version-1",
|
|
});
|
|
|
|
const proposal = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "secret",
|
|
name: "dev/provider/token",
|
|
value: "provider-secret",
|
|
justification: "Store this credential in the company vault",
|
|
});
|
|
const approved = await request(createBoardApp(fixture))
|
|
.post(`/api/companies/${fixture.companyId}/secret-proposals/${proposal.body.id}/approve`)
|
|
.send({ overrides: { providerConfigId: vault.id } });
|
|
|
|
expect(approved.status).toBe(200);
|
|
expect(createSecret).toHaveBeenCalledWith(expect.objectContaining({
|
|
value: "provider-secret",
|
|
providerConfig: expect.objectContaining({ id: vault.id, provider: "aws_secrets_manager" }),
|
|
}));
|
|
expect(await db.select().from(companySecrets)).toEqual([
|
|
expect.objectContaining({
|
|
provider: "aws_secrets_manager",
|
|
providerConfigId: vault.id,
|
|
status: "active",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("requires agent JWT and approves a secret before its binding with dual audits", async () => {
|
|
const fixture = await seedRun();
|
|
const denied = await request(createAgentApp(fixture, "agent_key"))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({ kind: "secret", name: "dev/vendor/token", value: "top-secret", justification: "Needed by task" });
|
|
expect(denied.status).toBe(403);
|
|
const scopedDenied = await request(createAgentApp(
|
|
fixture,
|
|
"agent_jwt",
|
|
{ kind: "task_bridge", parentIssueId: fixture.issueId },
|
|
)).post("/api/agents/me/secret-proposals").send({
|
|
kind: "secret",
|
|
name: "dev/vendor/token",
|
|
value: "top-secret",
|
|
justification: "Needed by task",
|
|
});
|
|
expect(scopedDenied.status).toBe(403);
|
|
|
|
const secretResponse = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "secret",
|
|
name: "dev/vendor/token",
|
|
key: "VENDOR_TOKEN",
|
|
value: "top-secret",
|
|
justification: "Needed by task",
|
|
});
|
|
expect(secretResponse.status).toBe(201);
|
|
expect(JSON.stringify(secretResponse.body)).not.toContain("top-secret");
|
|
expect(secretResponse.body).not.toHaveProperty("valueFingerprintSha256");
|
|
const [registeredRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.heartbeatRunId));
|
|
expect(JSON.stringify(registeredRun.contextSnapshot)).not.toContain("top-secret");
|
|
expect(registeredRun.contextSnapshot).toMatchObject({
|
|
paperclipSecretRedactions: [expect.objectContaining({ fingerprintSha256: expect.any(String), material: expect.any(Object) })],
|
|
});
|
|
|
|
const bindingResponse = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "binding",
|
|
secretProposalId: secretResponse.body.id,
|
|
configPath: "env.VENDOR_TOKEN",
|
|
justification: "Inject for the task",
|
|
});
|
|
expect(bindingResponse.status).toBe(201);
|
|
const [confirmation] = await db.select().from(issueThreadInteractions);
|
|
expect(confirmation).toMatchObject({
|
|
issueId: fixture.issueId,
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
sourceRunId: fixture.heartbeatRunId,
|
|
createdByAgentId: fixture.agentId,
|
|
addresseeAgentId: null,
|
|
requestedResolverPolicy: "human_only",
|
|
effectiveResolverPolicy: "human_only",
|
|
resolverPolicyProvenance: "explicit",
|
|
effectiveResolverPolicySource: "governed_action",
|
|
payload: expect.objectContaining({
|
|
secretProposal: expect.objectContaining({
|
|
proposalId: bindingResponse.body.id,
|
|
sourceSecretLabel: "dev/vendor/token",
|
|
configPath: "env.VENDOR_TOKEN",
|
|
targetAgentId: fixture.agentId,
|
|
targetAgentName: "Proposer",
|
|
justification: "Inject for the task",
|
|
}),
|
|
}),
|
|
});
|
|
expect(JSON.stringify(confirmation.payload)).not.toContain("top-secret");
|
|
expect(JSON.stringify(confirmation.payload)).not.toContain("fingerprint");
|
|
expect(confirmation.payload).not.toHaveProperty("secretId");
|
|
|
|
const agentApprovalDenied = await request(createAgentApp(fixture))
|
|
.post(`/api/companies/${fixture.companyId}/secret-proposals/${secretResponse.body.id}/approve`)
|
|
.send({});
|
|
expect(agentApprovalDenied.status).toBe(403);
|
|
expect(await db.select().from(companySecrets)).toHaveLength(0);
|
|
|
|
const prerequisite = await request(createBoardApp(fixture))
|
|
.post(`/api/companies/${fixture.companyId}/secret-proposals/${bindingResponse.body.id}/approve`)
|
|
.send({});
|
|
expect(prerequisite.status).toBe(409);
|
|
expect(await db.select().from(companySecrets)).toHaveLength(0);
|
|
|
|
const approved = await request(createBoardApp(fixture))
|
|
.post(`/api/companies/${fixture.companyId}/secret-proposals/${bindingResponse.body.id}/approve`)
|
|
.send({ cascade: true });
|
|
expect(approved.status).toBe(200);
|
|
expect(approved.body).toMatchObject({
|
|
status: "approved",
|
|
appliedBindingConfigPath: "env.VENDOR_TOKEN",
|
|
viewerCanApprove: false,
|
|
});
|
|
|
|
const proposalRows = await db.select().from(companySecretProposals);
|
|
expect(proposalRows).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ id: secretResponse.body.id, status: "approved", valueCiphertext: null }),
|
|
expect.objectContaining({ id: bindingResponse.body.id, status: "approved" }),
|
|
]));
|
|
const [secret] = await db.select().from(companySecrets);
|
|
expect(secret).toMatchObject({
|
|
name: "dev/vendor/token",
|
|
key: "vendor_token",
|
|
createdByAgentId: fixture.agentId,
|
|
createdByUserId: "board-user",
|
|
});
|
|
expect(await db.select().from(companySecretBindings)).toEqual([
|
|
expect.objectContaining({ secretId: secret.id, targetId: fixture.agentId, configPath: "env.VENDOR_TOKEN" }),
|
|
]);
|
|
const [agent] = await db.select().from(agents);
|
|
expect(agent.adapterConfig).toMatchObject({
|
|
env: { VENDOR_TOKEN: { type: "secret_ref", secretId: secret.id, version: "latest" } },
|
|
});
|
|
const actions = (await db.select().from(activityLog)).map((row) => row.action);
|
|
expect(actions.filter((action) => action === "secret.proposal.approved")).toHaveLength(2);
|
|
expect(actions).toEqual(expect.arrayContaining([
|
|
"secret.proposal.created",
|
|
"secret.created",
|
|
"agent.updated",
|
|
"secret.binding.created",
|
|
]));
|
|
expect(await db.select().from(issueThreadInteractions)).toEqual([
|
|
expect.objectContaining({
|
|
id: confirmation.id,
|
|
status: "accepted",
|
|
result: expect.objectContaining({
|
|
outcome: "accepted",
|
|
secretProposal: expect.objectContaining({ status: "executed" }),
|
|
}),
|
|
}),
|
|
]);
|
|
expect(await db.select().from(issueComments)).toEqual([
|
|
expect.objectContaining({
|
|
issueId: fixture.issueId,
|
|
authorUserId: "board-user",
|
|
body: expect.stringContaining("GET /api/agents/me/secrets"),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("keeps an approved secret after binding fails and reuses it on retry", async () => {
|
|
const fixture = await seedRun();
|
|
const agentApp = createAgentApp(fixture);
|
|
const boardApp = createBoardApp(fixture);
|
|
const secret = await request(agentApp).post("/api/agents/me/secret-proposals").send({
|
|
kind: "secret", name: "dev/retry/token", value: "retry-fixture", justification: "Needed by task",
|
|
});
|
|
const binding = await request(agentApp).post("/api/agents/me/secret-proposals").send({
|
|
kind: "binding", secretProposalId: secret.body.id, configPath: "env.RETRY_TOKEN", justification: "Use saved secret",
|
|
});
|
|
expect(secret.status).toBe(201);
|
|
expect(binding.status).toBe(201);
|
|
await db.update(agents).set({ adapterConfig: { env: { RETRY_TOKEN: "existing-value" } } })
|
|
.where(eq(agents.id, fixture.agentId));
|
|
const approve = () => request(boardApp)
|
|
.post(`/api/companies/${fixture.companyId}/secret-proposals/${binding.body.id}/approve`).send({ cascade: true });
|
|
|
|
expect((await approve()).status).toBe(409);
|
|
const [saved] = await db.select().from(companySecrets);
|
|
expect(saved).toMatchObject({ name: "dev/retry/token", status: "active" });
|
|
expect(await db.select().from(companySecretProposals)).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ id: secret.body.id, status: "approved", createdSecretId: saved.id, valueCiphertext: null }),
|
|
expect.objectContaining({ id: binding.body.id, status: "pending" }),
|
|
]));
|
|
expect(await db.select().from(companySecretBindings)).toHaveLength(0);
|
|
expect((await db.select().from(agents))[0].adapterConfig).toEqual({ env: { RETRY_TOKEN: "existing-value" } });
|
|
expect((await db.select().from(issueThreadInteractions))[0].status).toBe("pending");
|
|
|
|
await db.update(agents).set({ adapterConfig: {} }).where(eq(agents.id, fixture.agentId));
|
|
expect((await approve()).status).toBe(200);
|
|
expect(await db.select().from(companySecrets)).toHaveLength(1);
|
|
expect(await db.select().from(companySecretVersions)).toHaveLength(1);
|
|
expect(await db.select().from(companySecretBindings)).toEqual([
|
|
expect.objectContaining({ secretId: saved.id, targetId: fixture.agentId, configPath: "env.RETRY_TOKEN" }),
|
|
]);
|
|
const history = await db.select().from(activityLog);
|
|
expect(history.filter(row => row.action === "secret.created")).toHaveLength(1);
|
|
expect(history.filter(row => row.action === "secret.proposal.approved")).toHaveLength(2);
|
|
});
|
|
|
|
it("approves a binding without cascade after its secret proposal was approved separately", async () => {
|
|
const fixture = await seedRun();
|
|
const agentApp = createAgentApp(fixture);
|
|
const boardApp = createBoardApp(fixture);
|
|
const secretProposal = await request(agentApp)
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "secret",
|
|
name: "dev/sequential/token",
|
|
value: "sequential-secret",
|
|
justification: "Approve this first",
|
|
});
|
|
expect(secretProposal.status).toBe(201);
|
|
|
|
const bindingProposal = await request(agentApp)
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "binding",
|
|
secretProposalId: secretProposal.body.id,
|
|
configPath: "env.SEQUENTIAL_TOKEN",
|
|
justification: "Bind after separate approval",
|
|
});
|
|
expect(bindingProposal.status).toBe(201);
|
|
|
|
const approvedSecret = await request(boardApp)
|
|
.post(`/api/companies/${fixture.companyId}/secret-proposals/${secretProposal.body.id}/approve`)
|
|
.send({});
|
|
expect(approvedSecret.status).toBe(200);
|
|
expect(approvedSecret.body).toMatchObject({ status: "approved", createdSecretId: expect.any(String) });
|
|
|
|
const approvedBinding = await request(boardApp)
|
|
.post(`/api/companies/${fixture.companyId}/secret-proposals/${bindingProposal.body.id}/approve`)
|
|
.send({});
|
|
expect(approvedBinding.status).toBe(200);
|
|
expect(approvedBinding.body).toMatchObject({
|
|
status: "approved",
|
|
appliedBindingConfigPath: "env.SEQUENTIAL_TOKEN",
|
|
});
|
|
expect(await db.select().from(companySecrets)).toHaveLength(1);
|
|
expect(await db.select().from(companySecretBindings)).toEqual([
|
|
expect.objectContaining({
|
|
secretId: approvedSecret.body.createdSecretId,
|
|
targetId: fixture.agentId,
|
|
configPath: "env.SEQUENTIAL_TOKEN",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("proposes and approves a binding by resolving the proposer's own sourceConfigPath", async () => {
|
|
const fixture = await seedRun();
|
|
const sourceSecret = await secretService(db).create(fixture.companyId, {
|
|
name: "dev/source-path/token",
|
|
key: "SOURCE_PATH_TOKEN",
|
|
provider: "local_encrypted",
|
|
value: "source-path-secret",
|
|
});
|
|
await secretService(db).createBinding({
|
|
companyId: fixture.companyId,
|
|
secretId: sourceSecret.id,
|
|
targetType: "agent",
|
|
targetId: fixture.agentId,
|
|
configPath: "access.existing_source",
|
|
});
|
|
|
|
const proposal = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "binding",
|
|
sourceConfigPath: "access.existing_source",
|
|
configPath: "access.new_alias",
|
|
justification: "Reuse the credential under the task-specific alias",
|
|
});
|
|
|
|
expect(proposal.status).toBe(201);
|
|
expect(proposal.body).toMatchObject({
|
|
kind: "binding",
|
|
secretId: sourceSecret.id,
|
|
configPath: "access.new_alias",
|
|
target: { id: fixture.agentId },
|
|
});
|
|
expect(await db.select().from(companySecretProposals).where(eq(companySecretProposals.id, proposal.body.id)))
|
|
.toEqual([expect.objectContaining({ secretId: sourceSecret.id, targetId: fixture.agentId })]);
|
|
|
|
const approved = await request(createBoardApp(fixture))
|
|
.post(`/api/companies/${fixture.companyId}/secret-proposals/${proposal.body.id}/approve`)
|
|
.send({});
|
|
|
|
expect(approved.status).toBe(200);
|
|
expect(await db.select().from(companySecretBindings)).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
secretId: sourceSecret.id,
|
|
targetId: fixture.agentId,
|
|
configPath: "access.new_alias",
|
|
}),
|
|
]));
|
|
});
|
|
|
|
it("rejects binding proposals that provide both secretId and sourceConfigPath", async () => {
|
|
const fixture = await seedRun();
|
|
const sourceSecret = await secretService(db).create(fixture.companyId, {
|
|
name: "dev/mutual-exclusion/token",
|
|
key: "MUTUAL_EXCLUSION_TOKEN",
|
|
provider: "local_encrypted",
|
|
value: "mutual-exclusion-secret",
|
|
});
|
|
await secretService(db).createBinding({
|
|
companyId: fixture.companyId,
|
|
secretId: sourceSecret.id,
|
|
targetType: "agent",
|
|
targetId: fixture.agentId,
|
|
configPath: "access.mutual_exclusion_source",
|
|
});
|
|
|
|
const response = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "binding",
|
|
secretId: sourceSecret.id,
|
|
sourceConfigPath: "access.mutual_exclusion_source",
|
|
configPath: "access.new_alias",
|
|
justification: "This request is intentionally ambiguous",
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toMatch(/exactly one of secretId, sourceConfigPath, or secretProposalId/);
|
|
expect(await db.select().from(companySecretProposals)).toHaveLength(0);
|
|
});
|
|
|
|
it("accepts a user-scoped source secret only through the proposer's existing binding", async () => {
|
|
const fixture = await seedRun();
|
|
const secrets = secretService(db);
|
|
const definition = await secrets.createUserSecretDefinition(fixture.companyId, {
|
|
key: "personal_source_token",
|
|
name: "Personal source token",
|
|
provider: "local_encrypted",
|
|
});
|
|
const userSecret = await secrets.createCurrentUserSecretValue(fixture.companyId, "user-1", {
|
|
definitionId: definition.id,
|
|
value: "personal-source-secret",
|
|
});
|
|
await agentService(db).update(fixture.agentId, {
|
|
adapterConfig: {
|
|
"access.personal_source": {
|
|
type: "user_secret_ref",
|
|
key: definition.key,
|
|
version: "latest",
|
|
required: true,
|
|
allowMissingOverride: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
const proposal = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "binding",
|
|
sourceConfigPath: "access.personal_source",
|
|
configPath: "access.personal_alias",
|
|
justification: "Reuse the already-bound user credential",
|
|
});
|
|
|
|
expect(proposal.status).toBe(201);
|
|
expect(proposal.body).toMatchObject({ secretId: userSecret.id, target: { id: fixture.agentId } });
|
|
|
|
const approved = await request(createBoardApp(fixture))
|
|
.post(`/api/companies/${fixture.companyId}/secret-proposals/${proposal.body.id}/approve`)
|
|
.send({});
|
|
|
|
expect(approved.status).toBe(200);
|
|
const updatedAgent = await agentService(db).getById(fixture.agentId);
|
|
expect(updatedAgent?.adapterConfig).toMatchObject({
|
|
"access.personal_alias": {
|
|
type: "user_secret_ref",
|
|
key: definition.key,
|
|
version: "latest",
|
|
required: true,
|
|
allowMissingOverride: false,
|
|
},
|
|
});
|
|
expect(await db.select().from(userSecretDeclarations).where(eq(userSecretDeclarations.targetId, fixture.agentId)))
|
|
.toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
userSecretDefinitionId: definition.id,
|
|
configPath: "access.personal_source",
|
|
}),
|
|
expect.objectContaining({
|
|
userSecretDefinitionId: definition.id,
|
|
configPath: "access.personal_alias",
|
|
}),
|
|
]));
|
|
});
|
|
|
|
it("preserves user-secret declaration semantics when rebinding a source secret to a report", async () => {
|
|
const fixture = await seedRun();
|
|
const reportAgentId = randomUUID();
|
|
await db.insert(agents).values({
|
|
id: reportAgentId,
|
|
companyId: fixture.companyId,
|
|
name: "Report",
|
|
role: "engineer",
|
|
reportsTo: fixture.agentId,
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
permissions: {},
|
|
status: "idle",
|
|
});
|
|
const secrets = secretService(db);
|
|
const definition = await secrets.createUserSecretDefinition(fixture.companyId, {
|
|
key: "personal_report_source_token",
|
|
name: "Personal report source token",
|
|
provider: "local_encrypted",
|
|
});
|
|
const userSecret = await secrets.createCurrentUserSecretValue(fixture.companyId, "user-1", {
|
|
definitionId: definition.id,
|
|
value: "personal-report-source-secret",
|
|
});
|
|
await secrets.createCurrentUserSecretValue(fixture.companyId, "user-2", {
|
|
definitionId: definition.id,
|
|
value: "second-user-report-secret",
|
|
});
|
|
await agentService(db).update(fixture.agentId, {
|
|
adapterConfig: {
|
|
"access.personal_report_source": {
|
|
type: "user_secret_ref",
|
|
key: definition.key,
|
|
version: "latest",
|
|
required: true,
|
|
allowMissingOverride: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
const proposal = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "binding",
|
|
sourceConfigPath: "access.personal_report_source",
|
|
targetAgentId: reportAgentId,
|
|
configPath: "access.personal_alias",
|
|
justification: "Attempt to pass a personal credential to a report",
|
|
});
|
|
|
|
expect(proposal.status).toBe(201);
|
|
expect(proposal.body).toMatchObject({ secretId: userSecret.id, target: { id: reportAgentId } });
|
|
|
|
const approved = await request(createBoardApp(fixture))
|
|
.post(`/api/companies/${fixture.companyId}/secret-proposals/${proposal.body.id}/approve`)
|
|
.send({});
|
|
|
|
expect(approved.status).toBe(200);
|
|
const report = await agentService(db).getById(reportAgentId);
|
|
expect(report?.adapterConfig).toMatchObject({
|
|
"access.personal_alias": {
|
|
type: "user_secret_ref",
|
|
key: definition.key,
|
|
version: "latest",
|
|
required: true,
|
|
allowMissingOverride: false,
|
|
},
|
|
});
|
|
expect(await db.select().from(userSecretDeclarations).where(and(
|
|
eq(userSecretDeclarations.targetId, reportAgentId),
|
|
eq(userSecretDeclarations.configPath, "access.personal_alias"),
|
|
))).toEqual([expect.objectContaining({ userSecretDefinitionId: definition.id })]);
|
|
await expect(secrets.resolveUserSecretValue(
|
|
fixture.companyId,
|
|
{
|
|
definitionKey: definition.key,
|
|
responsibleUserId: "user-1",
|
|
},
|
|
{
|
|
consumerType: "agent",
|
|
consumerId: reportAgentId,
|
|
configPath: "access.personal_alias",
|
|
},
|
|
)).resolves.toMatchObject({ value: "personal-report-source-secret" });
|
|
await expect(secrets.resolveUserSecretValue(
|
|
fixture.companyId,
|
|
{
|
|
definitionKey: definition.key,
|
|
responsibleUserId: "user-2",
|
|
},
|
|
{
|
|
consumerType: "agent",
|
|
consumerId: reportAgentId,
|
|
configPath: "access.personal_alias",
|
|
},
|
|
)).resolves.toMatchObject({ value: "second-user-report-secret" });
|
|
});
|
|
|
|
it("returns 404 when sourceConfigPath is missing or belongs to another agent", async () => {
|
|
const fixture = await seedRun();
|
|
const otherAgentId = randomUUID();
|
|
await db.insert(agents).values({
|
|
id: otherAgentId,
|
|
companyId: fixture.companyId,
|
|
name: "Other agent",
|
|
role: "engineer",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
permissions: {},
|
|
status: "idle",
|
|
});
|
|
const otherSecret = await secretService(db).create(fixture.companyId, {
|
|
name: "dev/other-agent/token",
|
|
key: "OTHER_AGENT_TOKEN",
|
|
provider: "local_encrypted",
|
|
value: "other-agent-secret",
|
|
});
|
|
await secretService(db).createBinding({
|
|
companyId: fixture.companyId,
|
|
secretId: otherSecret.id,
|
|
targetType: "agent",
|
|
targetId: otherAgentId,
|
|
configPath: "access.other_agent_only",
|
|
});
|
|
const agentApp = createAgentApp(fixture);
|
|
|
|
const missing = await request(agentApp)
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "binding",
|
|
sourceConfigPath: "access.does_not_exist",
|
|
configPath: "access.new_alias",
|
|
justification: "Try an unresolved source",
|
|
});
|
|
const crossAgent = await request(agentApp)
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "binding",
|
|
sourceConfigPath: "access.other_agent_only",
|
|
configPath: "access.new_alias",
|
|
justification: "Try another agent's source",
|
|
});
|
|
|
|
expect(missing.status).toBe(404);
|
|
expect(crossAgent.status).toBe(404);
|
|
expect(missing.body.error).toBe("Source secret binding not found");
|
|
expect(crossAgent.body.error).toBe("Source secret binding not found");
|
|
expect(await db.select().from(companySecretProposals)).toHaveLength(0);
|
|
});
|
|
|
|
it("serializes proposal quotas and exposes bounded list pagination", async () => {
|
|
const fixture = await seedRun();
|
|
const liveSecret = await secretService(db).create(fixture.companyId, {
|
|
name: "dev/quota/source",
|
|
key: "QUOTA_SOURCE",
|
|
provider: "local_encrypted",
|
|
value: "quota-source-secret",
|
|
});
|
|
await secretService(db).createBinding({
|
|
companyId: fixture.companyId,
|
|
secretId: liveSecret.id,
|
|
targetType: "agent",
|
|
targetId: fixture.agentId,
|
|
configPath: "access.quota_source",
|
|
});
|
|
const agentApp = createAgentApp(fixture);
|
|
|
|
const responses = await Promise.all(Array.from({ length: 21 }, (_, index) =>
|
|
request(agentApp)
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "binding",
|
|
sourceConfigPath: "access.quota_source",
|
|
configPath: `env.QUOTA_${index}`,
|
|
justification: "Exercise the concurrent proposal cap",
|
|
})));
|
|
expect(responses.filter((response) => response.status === 201)).toHaveLength(20);
|
|
expect(responses.filter((response) => response.status === 422)).toHaveLength(1);
|
|
expect(await db.select().from(companySecretProposals)).toHaveLength(20);
|
|
expect(await db.select().from(activityLog)).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ action: "secret.proposal.denied" }),
|
|
]));
|
|
|
|
const firstPage = await request(agentApp).get("/api/agents/me/secret-proposals?limit=7&offset=0");
|
|
expect(firstPage.status).toBe(200);
|
|
expect(firstPage.body.proposals).toHaveLength(7);
|
|
expect(firstPage.body.nextOffset).toBe(7);
|
|
expect(firstPage.headers["x-next-offset"]).toBe("7");
|
|
|
|
const lastBoardPage = await request(createBoardApp(fixture))
|
|
.get(`/api/companies/${fixture.companyId}/secret-proposals?status=pending&limit=7&offset=14`);
|
|
expect(lastBoardPage.status).toBe(200);
|
|
expect(lastBoardPage.body).toHaveLength(6);
|
|
expect(lastBoardPage.headers["x-next-offset"]).toBeUndefined();
|
|
});
|
|
|
|
it("bounds each expiry sweep batch", async () => {
|
|
const fixture = await seedRun();
|
|
const liveSecret = await secretService(db).create(fixture.companyId, {
|
|
name: "dev/sweep/source",
|
|
key: "SWEEP_SOURCE",
|
|
provider: "local_encrypted",
|
|
value: "sweep-source-secret",
|
|
});
|
|
const agentApp = createAgentApp(fixture);
|
|
for (let index = 0; index < 3; index += 1) {
|
|
const response = await request(agentApp)
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "binding",
|
|
secretId: liveSecret.id,
|
|
configPath: `env.SWEEP_${index}`,
|
|
justification: "Exercise bounded expiry cleanup",
|
|
});
|
|
expect(response.status).toBe(201);
|
|
}
|
|
await db.update(companySecretProposals).set({ expiresAt: new Date(Date.now() - 1_000) });
|
|
|
|
const proposals = createSecretProposalsService(db);
|
|
let injectedCollision = false;
|
|
await expect(proposals.sweepExpired(new Date(), 2, async (companyId, proposalId) => {
|
|
if (!injectedCollision) {
|
|
injectedCollision = true;
|
|
throw conflict("Proposal is no longer pending");
|
|
}
|
|
return proposals.transition(companyId, proposalId, "expired", { reason: "Pending proposal expired" });
|
|
})).resolves.toBe(1);
|
|
expect((await db.select().from(companySecretProposals)).filter((proposal) => proposal.status === "pending"))
|
|
.toHaveLength(2);
|
|
await expect(proposals.sweepExpired(new Date(), 2)).resolves.toBe(2);
|
|
});
|
|
|
|
it("mirrors binding proposal rejection, withdrawal, and expiry onto linked cards", async () => {
|
|
const fixture = await seedRun();
|
|
const liveSecret = await secretService(db).create(fixture.companyId, {
|
|
name: "dev/lifecycle/source",
|
|
key: "LIFECYCLE_SOURCE",
|
|
provider: "local_encrypted",
|
|
value: "lifecycle-secret",
|
|
});
|
|
const agentApp = createAgentApp(fixture);
|
|
const boardApp = createBoardApp(fixture);
|
|
const createBinding = async (configPath: string) => request(agentApp)
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "binding",
|
|
secretId: liveSecret.id,
|
|
configPath,
|
|
justification: "Exercise mirrored lifecycle",
|
|
});
|
|
|
|
const rejected = await createBinding("access.REJECTED_ALIAS");
|
|
expect(rejected.status).toBe(201);
|
|
expect((await request(boardApp)
|
|
.post(`/api/companies/${fixture.companyId}/secret-proposals/${rejected.body.id}/reject`)
|
|
.send({ reason: "Use a narrower alias" })).status).toBe(200);
|
|
|
|
const withdrawn = await createBinding("access.WITHDRAWN_ALIAS");
|
|
expect(withdrawn.status).toBe(201);
|
|
expect((await request(agentApp)
|
|
.delete(`/api/agents/me/secret-proposals/${withdrawn.body.id}`)).status).toBe(200);
|
|
|
|
const expired = await createBinding("access.EXPIRED_ALIAS");
|
|
expect(expired.status).toBe(201);
|
|
await db.update(companySecretProposals)
|
|
.set({ expiresAt: new Date(Date.now() - 1_000) })
|
|
.where(eq(companySecretProposals.id, expired.body.id));
|
|
await expect(createSecretProposalsService(db).sweepExpired()).resolves.toBe(1);
|
|
|
|
const proposalRows = await db.select().from(companySecretProposals);
|
|
const cards = await db.select().from(issueThreadInteractions);
|
|
const cardByProposalId = new Map(cards.map((card) => {
|
|
const payload = card.payload as { secretProposal?: { proposalId?: string } };
|
|
return [payload.secretProposal?.proposalId, card];
|
|
}));
|
|
expect(proposalRows).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ id: rejected.body.id, status: "rejected" }),
|
|
expect.objectContaining({ id: withdrawn.body.id, status: "withdrawn" }),
|
|
expect.objectContaining({ id: expired.body.id, status: "expired" }),
|
|
]));
|
|
expect(cardByProposalId.get(rejected.body.id)).toMatchObject({ status: "rejected" });
|
|
expect(cardByProposalId.get(withdrawn.body.id)).toMatchObject({ status: "cancelled" });
|
|
expect(cardByProposalId.get(expired.body.id)).toMatchObject({ status: "expired" });
|
|
});
|
|
|
|
it("mirrors card rejection onto the linked binding proposal", async () => {
|
|
const fixture = await seedRun();
|
|
const liveSecret = await secretService(db).create(fixture.companyId, {
|
|
name: "dev/card-lifecycle/source",
|
|
key: "CARD_LIFECYCLE_SOURCE",
|
|
provider: "local_encrypted",
|
|
value: "card-lifecycle-secret",
|
|
});
|
|
const proposed = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "binding",
|
|
secretId: liveSecret.id,
|
|
configPath: "access.CARD_REJECTED_ALIAS",
|
|
justification: "Exercise reverse lifecycle mirroring",
|
|
});
|
|
expect(proposed.status).toBe(201);
|
|
|
|
const [card] = await db.select().from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, proposed.body.interactionId));
|
|
expect(card).toMatchObject({ status: "pending" });
|
|
await issueThreadInteractionService(db).rejectInteraction(
|
|
{ id: fixture.issueId, companyId: fixture.companyId, status: "in_progress" },
|
|
card!.id,
|
|
{ reason: "Use the canonical binding name" },
|
|
{ userId: "board-user" },
|
|
);
|
|
|
|
expect(await db.select().from(companySecretProposals)
|
|
.where(eq(companySecretProposals.id, proposed.body.id)))
|
|
.toEqual([expect.objectContaining({
|
|
status: "rejected",
|
|
resolutionReason: "Use the canonical binding name",
|
|
})]);
|
|
});
|
|
|
|
it("settles an accepted card execution failure on both the card and proposal", async () => {
|
|
const fixture = await seedRun();
|
|
const liveSecret = await secretService(db).create(fixture.companyId, {
|
|
name: "dev/card-failure/source",
|
|
key: "CARD_FAILURE_SOURCE",
|
|
provider: "local_encrypted",
|
|
value: "card-failure-secret",
|
|
});
|
|
const proposed = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "binding",
|
|
secretId: liveSecret.id,
|
|
configPath: "access.CARD_FAILED_ALIAS",
|
|
justification: "Exercise atomic execution failure settlement",
|
|
});
|
|
expect(proposed.status).toBe(201);
|
|
await db.update(issueThreadInteractions)
|
|
.set({
|
|
status: "accepted",
|
|
result: { version: 1, outcome: "accepted" },
|
|
resolvedByUserId: "board-user",
|
|
resolvedAt: new Date(),
|
|
})
|
|
.where(eq(issueThreadInteractions.id, proposed.body.interactionId));
|
|
|
|
const card = await issueThreadInteractionService(db).recordSecretProposalExecutionResult(
|
|
{ id: fixture.issueId, companyId: fixture.companyId },
|
|
proposed.body.interactionId,
|
|
proposed.body.id,
|
|
{ status: "failed", errorCode: "permission_denied" },
|
|
);
|
|
|
|
expect(card).toMatchObject({
|
|
status: "accepted",
|
|
result: {
|
|
outcome: "accepted",
|
|
secretProposal: { status: "failed", errorCode: "permission_denied" },
|
|
},
|
|
});
|
|
expect(await db.select().from(companySecretProposals)
|
|
.where(eq(companySecretProposals.id, proposed.body.id)))
|
|
.toEqual([expect.objectContaining({
|
|
status: "rejected",
|
|
resolvedByUserId: "board-user",
|
|
resolutionReason: "Interaction acceptance failed: permission_denied",
|
|
})]);
|
|
});
|
|
|
|
it("waits on the issue before locking a linked proposal during card rejection", async () => {
|
|
const fixture = await seedRun();
|
|
const liveSecret = await secretService(db).create(fixture.companyId, {
|
|
name: "dev/card-lock-order/source",
|
|
key: "CARD_LOCK_ORDER_SOURCE",
|
|
provider: "local_encrypted",
|
|
value: "card-lock-order-secret",
|
|
});
|
|
const proposed = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "binding",
|
|
secretId: liveSecret.id,
|
|
configPath: "access.CARD_LOCK_ORDER_ALIAS",
|
|
justification: "Verify the proposal-to-issue lock order",
|
|
});
|
|
expect(proposed.status).toBe(201);
|
|
|
|
const releaseIssueLock = await holdIssueRowLock(fixture.issueId);
|
|
|
|
const rejection = issueThreadInteractionService(db).rejectInteraction(
|
|
{ id: fixture.issueId, companyId: fixture.companyId, status: "in_progress" },
|
|
proposed.body.interactionId,
|
|
{ reason: "Reject after the lock-order check" },
|
|
{ userId: "board-user" },
|
|
);
|
|
|
|
try {
|
|
expect(await waitForBlockedForUpdate("issues")).toBe(true);
|
|
|
|
// Terminal issue transitions already hold the issue before expiring a
|
|
// proposal. Rejection must wait there without taking the proposal first.
|
|
await expect(lockProposalNowait(proposed.body.id)).resolves.toBeUndefined();
|
|
} finally {
|
|
await releaseIssueLock();
|
|
}
|
|
|
|
await expect(rejection).resolves.toMatchObject({ status: "rejected" });
|
|
});
|
|
|
|
it("waits on the issue before locking a proposal execution result", async () => {
|
|
const fixture = await seedRun();
|
|
const liveSecret = await secretService(db).create(fixture.companyId, {
|
|
name: "dev/execution-lock-order/source",
|
|
key: "EXECUTION_LOCK_ORDER_SOURCE",
|
|
provider: "local_encrypted",
|
|
value: "execution-lock-order-secret",
|
|
});
|
|
const proposed = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "binding",
|
|
secretId: liveSecret.id,
|
|
configPath: "access.EXECUTION_LOCK_ORDER_ALIAS",
|
|
justification: "Verify execution result lock ordering",
|
|
});
|
|
expect(proposed.status).toBe(201);
|
|
await db.update(issueThreadInteractions)
|
|
.set({
|
|
status: "accepted",
|
|
result: { version: 1, outcome: "accepted" },
|
|
resolvedByUserId: "board-user",
|
|
resolvedAt: new Date(),
|
|
})
|
|
.where(eq(issueThreadInteractions.id, proposed.body.interactionId));
|
|
|
|
const releaseIssueLock = await holdIssueRowLock(fixture.issueId);
|
|
|
|
const recording = issueThreadInteractionService(db).recordSecretProposalExecutionResult(
|
|
{ id: fixture.issueId, companyId: fixture.companyId },
|
|
proposed.body.interactionId,
|
|
proposed.body.id,
|
|
{ status: "failed", errorCode: "permission_denied" },
|
|
);
|
|
|
|
try {
|
|
expect(await waitForBlockedForUpdate("issues")).toBe(true);
|
|
await expect(lockProposalNowait(proposed.body.id)).resolves.toBeUndefined();
|
|
} finally {
|
|
await releaseIssueLock();
|
|
}
|
|
|
|
await expect(recording).resolves.toMatchObject({
|
|
status: "accepted",
|
|
result: { secretProposal: { status: "failed", errorCode: "permission_denied" } },
|
|
});
|
|
});
|
|
|
|
it("keeps proposal and card consistent when approval races card rejection", async () => {
|
|
const fixture = await seedRun();
|
|
const liveSecret = await secretService(db).create(fixture.companyId, {
|
|
name: "dev/card-race/source",
|
|
key: "CARD_RACE_SOURCE",
|
|
provider: "local_encrypted",
|
|
value: "card-race-secret",
|
|
});
|
|
const proposed = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "binding",
|
|
secretId: liveSecret.id,
|
|
configPath: "access.CARD_RACE_ALIAS",
|
|
justification: "Exercise proposal and card lock ordering",
|
|
});
|
|
expect(proposed.status).toBe(201);
|
|
|
|
const [approval, rejection] = await Promise.allSettled([
|
|
request(createBoardApp(fixture))
|
|
.post(`/api/companies/${fixture.companyId}/secret-proposals/${proposed.body.id}/approve`)
|
|
.send({}),
|
|
issueThreadInteractionService(db).rejectInteraction(
|
|
{ id: fixture.issueId, companyId: fixture.companyId, status: "in_progress" },
|
|
proposed.body.interactionId,
|
|
{ reason: "Reject the racing request" },
|
|
{ userId: "board-user" },
|
|
),
|
|
]);
|
|
expect(approval.status).toBe("fulfilled");
|
|
if (approval.status !== "fulfilled") throw approval.reason;
|
|
expect([200, 409]).toContain(approval.value.status);
|
|
expect(rejection.status).toBe(approval.value.status === 200 ? "rejected" : "fulfilled");
|
|
|
|
const [proposal] = await db.select().from(companySecretProposals)
|
|
.where(eq(companySecretProposals.id, proposed.body.id));
|
|
const [card] = await db.select().from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, proposed.body.interactionId));
|
|
if (proposal!.status === "approved") {
|
|
expect(card).toMatchObject({
|
|
status: "accepted",
|
|
result: { secretProposal: { status: "executed" } },
|
|
});
|
|
} else {
|
|
expect(proposal).toMatchObject({ status: "rejected" });
|
|
expect(card).toMatchObject({
|
|
status: "rejected",
|
|
result: { secretProposal: { status: "rejected" } },
|
|
});
|
|
}
|
|
});
|
|
|
|
it("denies direct and cascade approval after a proposal expires", async () => {
|
|
const fixture = await seedRun();
|
|
const secretProposal = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "secret",
|
|
name: "dev/expired/token",
|
|
value: "expired-secret",
|
|
justification: "Needed before the retention window elapsed",
|
|
});
|
|
const bindingProposal = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "binding",
|
|
secretProposalId: secretProposal.body.id,
|
|
configPath: "env.EXPIRED_TOKEN",
|
|
justification: "Inject the proposed secret",
|
|
});
|
|
await db.update(companySecretProposals)
|
|
.set({ expiresAt: new Date(Date.now() - 1_000) })
|
|
.where(eq(companySecretProposals.id, secretProposal.body.id));
|
|
|
|
const directApproval = await request(createBoardApp(fixture))
|
|
.post(`/api/companies/${fixture.companyId}/secret-proposals/${secretProposal.body.id}/approve`)
|
|
.send({});
|
|
expect(directApproval.status).toBe(409);
|
|
|
|
const cascadeApproval = await request(createBoardApp(fixture))
|
|
.post(`/api/companies/${fixture.companyId}/secret-proposals/${bindingProposal.body.id}/approve`)
|
|
.send({ cascade: true });
|
|
expect(cascadeApproval.status).toBe(409);
|
|
expect(await db.select().from(companySecrets)).toHaveLength(0);
|
|
});
|
|
|
|
it("rechecks expiry after acquiring the proposal approval lock", async () => {
|
|
const fixture = await seedRun();
|
|
const proposal = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "secret",
|
|
name: "dev/expiry-race/token",
|
|
value: "expiry-race-secret",
|
|
justification: "Exercise the approval boundary",
|
|
});
|
|
let approvalPromise: Promise<{ status: number; body: Record<string, unknown> }> | null = null;
|
|
|
|
await db.transaction(async (tx) => {
|
|
await tx.select().from(companySecretProposals)
|
|
.where(eq(companySecretProposals.id, proposal.body.id))
|
|
.for("update");
|
|
approvalPromise = request(createBoardApp(fixture))
|
|
.post(`/api/companies/${fixture.companyId}/secret-proposals/${proposal.body.id}/approve`)
|
|
.send({})
|
|
.then((response) => response);
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
await tx.update(companySecretProposals)
|
|
.set({ expiresAt: new Date(Date.now() - 1_000) })
|
|
.where(eq(companySecretProposals.id, proposal.body.id));
|
|
});
|
|
|
|
const approval = await approvalPromise;
|
|
expect(approval?.status).toBe(409);
|
|
expect(await db.select().from(companySecrets)).toHaveLength(0);
|
|
expect(await db.select().from(companySecretProposals)).toEqual([
|
|
expect.objectContaining({ id: proposal.body.id, status: "pending" }),
|
|
]);
|
|
});
|
|
|
|
it("returns 409 without mutation when the current org graph no longer permits the stored binding target", async () => {
|
|
const fixture = await seedRun();
|
|
const targetAgentId = randomUUID();
|
|
await db.insert(agents).values({
|
|
id: targetAgentId,
|
|
companyId: fixture.companyId,
|
|
name: "Report",
|
|
role: "engineer",
|
|
reportsTo: fixture.agentId,
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
permissions: {},
|
|
status: "idle",
|
|
});
|
|
const secretProposal = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({ kind: "secret", name: "dev/reorg/token", value: "reorg-secret", justification: "Needed by report" });
|
|
const bindingProposal = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "binding",
|
|
secretProposalId: secretProposal.body.id,
|
|
targetAgentId,
|
|
configPath: "access.REORG_TOKEN",
|
|
justification: "Give the report API access",
|
|
});
|
|
expect(bindingProposal.status).toBe(201);
|
|
|
|
await db.update(agents).set({ reportsTo: null }).where(eq(agents.id, targetAgentId));
|
|
const response = await request(createBoardApp(fixture))
|
|
.post(`/api/companies/${fixture.companyId}/secret-proposals/${bindingProposal.body.id}/approve`)
|
|
.send({ cascade: true });
|
|
expect(response.status).toBe(409);
|
|
expect(await db.select().from(companySecrets)).toHaveLength(0);
|
|
expect((await db.select().from(companySecretProposals)).every((proposal) => proposal.status === "pending")).toBe(true);
|
|
});
|
|
|
|
|
|
it("rejects bindings that reference terminal secret proposals", async () => {
|
|
const fixture = await seedRun();
|
|
const secretProposal = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "secret",
|
|
name: "dev/withdrawn/token",
|
|
value: "withdrawn-secret",
|
|
justification: "No longer needed",
|
|
});
|
|
expect(secretProposal.status).toBe(201);
|
|
|
|
const withdrawn = await request(createAgentApp(fixture))
|
|
.delete(`/api/agents/me/secret-proposals/${secretProposal.body.id}`);
|
|
expect(withdrawn.status).toBe(200);
|
|
expect(withdrawn.body.status).toBe("withdrawn");
|
|
|
|
const bindingProposal = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "binding",
|
|
secretProposalId: secretProposal.body.id,
|
|
configPath: "env.WITHDRAWN_TOKEN",
|
|
justification: "Reference a terminal dependency",
|
|
});
|
|
expect(bindingProposal.status).toBe(422);
|
|
expect(bindingProposal.body.error).toContain("no longer pending");
|
|
expect(await db.select().from(companySecretProposals)).toEqual([
|
|
expect.objectContaining({ id: secretProposal.body.id, status: "withdrawn" }),
|
|
]);
|
|
});
|
|
|
|
it("serializes binding creation with concurrent dependency rejection", async () => {
|
|
const fixture = await seedRun();
|
|
const secretProposal = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "secret",
|
|
name: "dev/concurrent-reject/token",
|
|
value: "concurrent-reject-secret",
|
|
justification: "Create a binding while review is pending",
|
|
});
|
|
expect(secretProposal.status).toBe(201);
|
|
|
|
const advisoryLockKey = 147460186;
|
|
await db.execute(sql.raw(`
|
|
CREATE OR REPLACE FUNCTION paperclip_test_pause_binding_proposal()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $function$
|
|
BEGIN
|
|
IF NEW.kind = 'binding' THEN
|
|
PERFORM pg_advisory_xact_lock(${advisoryLockKey});
|
|
PERFORM pg_sleep(1);
|
|
END IF;
|
|
RETURN NEW;
|
|
END
|
|
$function$;
|
|
CREATE TRIGGER paperclip_test_pause_binding_proposal
|
|
BEFORE INSERT ON company_secret_proposals
|
|
FOR EACH ROW EXECUTE FUNCTION paperclip_test_pause_binding_proposal();
|
|
`));
|
|
|
|
try {
|
|
const bindingPromise = request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "binding",
|
|
secretProposalId: secretProposal.body.id,
|
|
configPath: "env.CONCURRENT_REJECT_TOKEN",
|
|
justification: "Exercise dependency serialization",
|
|
})
|
|
.then((response) => response);
|
|
|
|
let bindingPaused = false;
|
|
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
const lockAvailable = await db.transaction(async (tx) => {
|
|
const [result] = await tx.execute<{ acquired: boolean }>(
|
|
sql`SELECT pg_try_advisory_lock(${advisoryLockKey}) AS acquired`,
|
|
);
|
|
if (result?.acquired) {
|
|
await tx.execute(sql`SELECT pg_advisory_unlock(${advisoryLockKey})`);
|
|
}
|
|
return result?.acquired ?? false;
|
|
});
|
|
if (!lockAvailable) {
|
|
bindingPaused = true;
|
|
break;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
}
|
|
expect(bindingPaused).toBe(true);
|
|
|
|
let rejectionFinished = false;
|
|
const rejectionPromise = request(createBoardApp(fixture))
|
|
.post(`/api/companies/${fixture.companyId}/secret-proposals/${secretProposal.body.id}/reject`)
|
|
.send({ reason: "Reject during binding creation" })
|
|
.then((response) => {
|
|
rejectionFinished = true;
|
|
return response;
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
expect(rejectionFinished).toBe(false);
|
|
|
|
const binding = await bindingPromise;
|
|
const rejection = await rejectionPromise;
|
|
expect(binding.status).toBe(201);
|
|
expect(rejection.status).toBe(200);
|
|
} finally {
|
|
await db.execute(sql.raw(`
|
|
DROP TRIGGER IF EXISTS paperclip_test_pause_binding_proposal ON company_secret_proposals;
|
|
DROP FUNCTION IF EXISTS paperclip_test_pause_binding_proposal();
|
|
`));
|
|
}
|
|
|
|
expect(await db.select().from(companySecretProposals)).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ id: secretProposal.body.id, status: "rejected" }),
|
|
expect.objectContaining({
|
|
kind: "binding",
|
|
secretProposalId: secretProposal.body.id,
|
|
status: "rejected",
|
|
resolutionReason: `Dependent secret proposal ${secretProposal.body.id} was rejected`,
|
|
}),
|
|
]));
|
|
expect(await db.select().from(issueThreadInteractions)).toEqual([
|
|
expect.objectContaining({ status: "rejected" }),
|
|
]);
|
|
});
|
|
|
|
it("cascade-rejects pending binding proposals when their secret proposal is withdrawn", async () => {
|
|
const fixture = await seedRun();
|
|
const secretProposal = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "secret",
|
|
name: "dev/cascade/token",
|
|
value: "cascade-secret",
|
|
justification: "Temporary access",
|
|
});
|
|
const bindingProposal = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "binding",
|
|
secretProposalId: secretProposal.body.id,
|
|
configPath: "env.CASCADE_TOKEN",
|
|
justification: "Use the temporary secret",
|
|
});
|
|
expect(bindingProposal.status).toBe(201);
|
|
|
|
const withdrawn = await request(createAgentApp(fixture))
|
|
.delete(`/api/agents/me/secret-proposals/${secretProposal.body.id}`);
|
|
expect(withdrawn.status).toBe(200);
|
|
|
|
expect(await db.select().from(companySecretProposals)).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ id: secretProposal.body.id, status: "withdrawn" }),
|
|
expect.objectContaining({
|
|
id: bindingProposal.body.id,
|
|
status: "rejected",
|
|
resolutionReason: `Dependent secret proposal ${secretProposal.body.id} was withdrawn`,
|
|
}),
|
|
]));
|
|
});
|
|
|
|
it("rechecks the org graph after secret approval commits", async () => {
|
|
const fixture = await seedRun();
|
|
const targetAgentId = randomUUID();
|
|
await db.insert(agents).values({
|
|
id: targetAgentId,
|
|
companyId: fixture.companyId,
|
|
name: "Concurrent report",
|
|
role: "engineer",
|
|
reportsTo: fixture.agentId,
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
permissions: {},
|
|
status: "idle",
|
|
});
|
|
const secretProposal = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "secret",
|
|
name: "dev/concurrent-reorg/token",
|
|
value: "concurrent-reorg-secret",
|
|
justification: "Needed by report",
|
|
});
|
|
const bindingProposal = await request(createAgentApp(fixture))
|
|
.post("/api/agents/me/secret-proposals")
|
|
.send({
|
|
kind: "binding",
|
|
secretProposalId: secretProposal.body.id,
|
|
targetAgentId,
|
|
configPath: "access.CONCURRENT_REORG_TOKEN",
|
|
justification: "Give the report API access",
|
|
});
|
|
expect(bindingProposal.status).toBe(201);
|
|
|
|
const advisoryLockKey = 147460185;
|
|
await db.execute(sql.raw(`
|
|
CREATE OR REPLACE FUNCTION paperclip_test_pause_secret_approval()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $function$
|
|
BEGIN
|
|
PERFORM pg_advisory_xact_lock(${advisoryLockKey});
|
|
PERFORM pg_sleep(1);
|
|
RETURN NEW;
|
|
END
|
|
$function$;
|
|
CREATE TRIGGER paperclip_test_pause_secret_approval
|
|
BEFORE INSERT ON company_secrets
|
|
FOR EACH ROW EXECUTE FUNCTION paperclip_test_pause_secret_approval();
|
|
`));
|
|
|
|
try {
|
|
const approvalPromise = request(createBoardApp(fixture))
|
|
.post(`/api/companies/${fixture.companyId}/secret-proposals/${bindingProposal.body.id}/approve`)
|
|
.send({ cascade: true })
|
|
.then((response) => response);
|
|
|
|
let approvalPaused = false;
|
|
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
const lockAvailable = await db.transaction(async (tx) => {
|
|
const [result] = await tx.execute<{ acquired: boolean }>(
|
|
sql`SELECT pg_try_advisory_lock(${advisoryLockKey}) AS acquired`,
|
|
);
|
|
if (result?.acquired) {
|
|
await tx.execute(sql`SELECT pg_advisory_unlock(${advisoryLockKey})`);
|
|
}
|
|
return result?.acquired ?? false;
|
|
});
|
|
if (!lockAvailable) {
|
|
approvalPaused = true;
|
|
break;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
}
|
|
expect(approvalPaused).toBe(true);
|
|
|
|
let reorgFinished = false;
|
|
const reorgPromise = db.update(agents)
|
|
.set({ reportsTo: null })
|
|
.where(eq(agents.id, targetAgentId))
|
|
.then(() => {
|
|
reorgFinished = true;
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
expect(reorgFinished).toBe(false);
|
|
|
|
const approval = await approvalPromise;
|
|
await reorgPromise;
|
|
expect(approval.status).toBe(409);
|
|
expect(approval.body.error).toContain("chain-of-command policy");
|
|
} finally {
|
|
await db.execute(sql.raw(`
|
|
DROP TRIGGER IF EXISTS paperclip_test_pause_secret_approval ON company_secrets;
|
|
DROP FUNCTION IF EXISTS paperclip_test_pause_secret_approval();
|
|
`));
|
|
}
|
|
|
|
expect(await db.select().from(companySecretBindings)).toHaveLength(0);
|
|
expect(await db.select().from(companySecrets)).toHaveLength(1);
|
|
expect(await db.select().from(companySecretProposals)).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ id: secretProposal.body.id, status: "approved", valueCiphertext: null }),
|
|
expect.objectContaining({ id: bindingProposal.body.id, status: "pending" }),
|
|
]));
|
|
expect(await db.select({ reportsTo: agents.reportsTo }).from(agents).where(eq(agents.id, targetAgentId)))
|
|
.toEqual([{ reportsTo: null }]);
|
|
});
|
|
});
|