fix(server): enforce agent secret binding sync across lifecycle flows (#8307)
## Thinking Path > - Paperclip is the control plane people use to create, configure, and run AI agents for work. > - This change sits in the server-side agent lifecycle and secret-binding subsystem, where adapter config `env` entries can reference company secrets. > - An incident (while trying to configure a Novita sandbox) showed that an agent can reach a broken runtime state if `adapterConfig.env` contains `secret_ref` entries but the matching `company_secret_bindings` rows are missing. > - The immediate run-path guard and error-surfacing work made the failure diagnosable, but they did not fully prevent new broken agents from being created. > - The risk came from create and approval flows being responsible for remembering to sync bindings at each call site, which is easy to miss as new flows are added. > - This pull request moves the invariant into `agentService` create/update/activate paths, keeps the existing hire-flow fix, and adds regression coverage for create, update, and legacy pending-approval recovery. > - The benefit is that agent secret binding integrity is enforced closer to the data mutation point, so future callers inherit the protection automatically. ## Linked Issues or Issue Description Refs #8309 ### What happened? A Paperclip agent could persist `adapterConfig.env` `secret_ref` entries without matching agent-scoped `company_secret_bindings` rows. When that happened, the config UI could still look configured, but the real run path failed pre-dispatch because the secret was not actually bound to that agent. ### Expected behavior Every normal agent create, config-update, and pending-approval activation flow should leave the agent with secret bindings that match its persisted secret-ref env config. ### Steps to reproduce 1. Create or activate an agent through a flow that persists `adapterConfig.env` secret refs without synchronizing `company_secret_bindings`. 2. Observe that the config state can still appear populated. 3. Start a run for that agent. 4. Observe that pre-dispatch binding validation fails because the secret reference exists but the agent binding does not. ### Deployment mode Local dev (`pnpm dev`) ### Installation method Built from source (`pnpm dev` / `pnpm build`) ### Agent adapter(s) involved - Claude Code - Not adapter-specific (core bug) ### Database mode Embedded PGlite / embedded local dev database flow ### Access context Board (human operator) created or approved the agent; agent runtime later consumed the config. ### Additional context This PR focuses on preventing new broken states from normal service flows and on backfilling the covered legacy pending-approval activation path. ## What Changed - Kept the existing branch-local hire-flow fix that synchronized bindings for route and approval paths. - Moved the binding integrity invariant into `agentService.create()`, `agentService.update()` when `adapterConfig` changes, and `agentService.activatePendingApproval()`. - Added `server/src/__tests__/agents-service-secret-bindings.test.ts` covering create-time sync, update-time resync, and backfill for legacy pending-approval agents. - Removed now-redundant route-layer and approval-layer binding sync calls once the service layer became authoritative. - Simplified the affected unit tests so route/approval tests no longer assert service-owned binding writes directly. ## Verification - `pnpm --filter @paperclipai/server typecheck` - `pnpm exec vitest run server/src/__tests__/agents-service-secret-bindings.test.ts server/src/__tests__/approvals-service.test.ts server/src/__tests__/agent-skills-routes.test.ts` ## Risks - Low to medium risk. - This changes where secret-binding synchronization is enforced, so any unexpected caller that relied on upper-layer manual sync behavior could behave differently. - Agent create/update/activation flows now perform binding synchronization consistently, which adds binding-table writes at those mutation points. - This PR does not retroactively scan and heal every already-broken historical agent row; it prevents and backfills through the covered service flows. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex / GPT-5 Codex class model via `codex_local` - Session model family: GPT-5 Codex - Tool-assisted coding with shell, git, HTTP, and local test execution - Reasoning mode: medium interactive tool-use workflow ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] 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>
This commit is contained in:
@@ -51,6 +51,7 @@ const mockCompanySkillService = vi.hoisted(() => ({
|
||||
const mockSecretService = vi.hoisted(() => ({
|
||||
resolveAdapterConfigForRuntime: vi.fn(),
|
||||
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
|
||||
syncEnvBindingsForTarget: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
@@ -96,6 +97,10 @@ vi.mock("../services/index.js", () => ({
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
|
||||
vi.mock("../services/secrets.js", () => ({
|
||||
secretService: () => mockSecretService,
|
||||
}));
|
||||
|
||||
vi.mock("../adapters/index.js", () => ({
|
||||
findServerAdapter: vi.fn(() => mockAdapter),
|
||||
findActiveServerAdapter: vi.fn(() => mockAdapter),
|
||||
@@ -129,6 +134,10 @@ function registerModuleMocks() {
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/secrets.js", () => ({
|
||||
secretService: () => mockSecretService,
|
||||
}));
|
||||
|
||||
vi.doMock("../adapters/index.js", () => ({
|
||||
findServerAdapter: vi.fn(() => mockAdapter),
|
||||
findActiveServerAdapter: vi.fn(() => mockAdapter),
|
||||
@@ -249,6 +258,7 @@ describe.sequential("agent skill routes", () => {
|
||||
agent: makeAgent("claude_local"),
|
||||
});
|
||||
mockSecretService.resolveAdapterConfigForRuntime.mockResolvedValue({ config: { env: {} } });
|
||||
mockSecretService.syncEnvBindingsForTarget.mockResolvedValue(undefined);
|
||||
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([
|
||||
{
|
||||
key: "paperclipai/paperclip/paperclip",
|
||||
|
||||
@@ -66,6 +66,7 @@ describeEmbeddedPostgres("agent service clearError", () => {
|
||||
status: "error",
|
||||
pauseReason: "system",
|
||||
pausedAt: new Date("2026-06-07T00:00:00.000Z"),
|
||||
errorReason: "Secret is not bound to agent at env.ANTHROPIC_API_KEY",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
@@ -116,6 +117,7 @@ describeEmbeddedPostgres("agent service clearError", () => {
|
||||
status: "idle",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
errorReason: null,
|
||||
});
|
||||
|
||||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
companySecretBindings,
|
||||
companySecretProviderConfigs,
|
||||
companySecretVersions,
|
||||
companySecrets,
|
||||
createDb,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { agentService } from "../services/agents.ts";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres agent secret binding tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("agent service secret binding sync", () => {
|
||||
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-agent-secret-bindings-${randomUUID()}`);
|
||||
|
||||
beforeAll(async () => {
|
||||
mkdirSync(secretsTmpDir, { recursive: true });
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key");
|
||||
const started = await startEmbeddedPostgresTestDatabase("agent-secret-bindings");
|
||||
stopDb = started.cleanup;
|
||||
db = createDb(started.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(companySecretBindings);
|
||||
await db.delete(companySecretVersions);
|
||||
await db.delete(companySecrets);
|
||||
await db.delete(companySecretProviderConfigs);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
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 seedCompany() {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
return companyId;
|
||||
}
|
||||
|
||||
it("creates agent secret bindings when a new agent persists secret_ref env", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const secrets = secretService(db);
|
||||
const secret = await secrets.create(companyId, {
|
||||
name: `anthropic-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: "sk-ant-123",
|
||||
});
|
||||
|
||||
const created = await agentService(db).create(companyId, {
|
||||
name: "Claude Novita",
|
||||
role: "engineer",
|
||||
status: "pending_approval",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: { type: "secret_ref", secretId: secret.id, version: "latest" },
|
||||
},
|
||||
},
|
||||
runtimeConfig: {},
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
});
|
||||
|
||||
const bindings = await db
|
||||
.select()
|
||||
.from(companySecretBindings)
|
||||
.where(and(
|
||||
eq(companySecretBindings.companyId, companyId),
|
||||
eq(companySecretBindings.targetType, "agent"),
|
||||
eq(companySecretBindings.targetId, created.id),
|
||||
));
|
||||
|
||||
expect(bindings).toHaveLength(1);
|
||||
expect(bindings[0]).toMatchObject({
|
||||
secretId: secret.id,
|
||||
configPath: "env.ANTHROPIC_API_KEY",
|
||||
versionSelector: "latest",
|
||||
required: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces agent secret bindings when adapterConfig env changes", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const secrets = secretService(db);
|
||||
const oldSecret = await secrets.create(companyId, {
|
||||
name: `old-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: "old-value",
|
||||
});
|
||||
const nextSecret = await secrets.create(companyId, {
|
||||
name: `next-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: "next-value",
|
||||
});
|
||||
|
||||
const created = await agentService(db).create(companyId, {
|
||||
name: "Binding Swapper",
|
||||
role: "engineer",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {
|
||||
env: {
|
||||
OLD_KEY: { type: "secret_ref", secretId: oldSecret.id, version: "latest" },
|
||||
},
|
||||
},
|
||||
runtimeConfig: {},
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
});
|
||||
|
||||
await agentService(db).update(created.id, {
|
||||
adapterConfig: {
|
||||
env: {
|
||||
NEW_KEY: { type: "secret_ref", secretId: nextSecret.id, version: "latest" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const bindings = await db
|
||||
.select()
|
||||
.from(companySecretBindings)
|
||||
.where(and(
|
||||
eq(companySecretBindings.companyId, companyId),
|
||||
eq(companySecretBindings.targetType, "agent"),
|
||||
eq(companySecretBindings.targetId, created.id),
|
||||
));
|
||||
|
||||
expect(bindings).toHaveLength(1);
|
||||
expect(bindings[0]).toMatchObject({
|
||||
secretId: nextSecret.id,
|
||||
configPath: "env.NEW_KEY",
|
||||
});
|
||||
});
|
||||
|
||||
it("backfills missing secret bindings when a legacy pending agent is approved", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const secrets = secretService(db);
|
||||
const secret = await secrets.create(companyId, {
|
||||
name: `legacy-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: "legacy-value",
|
||||
});
|
||||
const agentId = randomUUID();
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Legacy Pending Agent",
|
||||
role: "engineer",
|
||||
status: "pending_approval",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: { type: "secret_ref", secretId: secret.id, version: "latest" },
|
||||
},
|
||||
},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
const beforeBindings = await db
|
||||
.select()
|
||||
.from(companySecretBindings)
|
||||
.where(eq(companySecretBindings.targetId, agentId));
|
||||
expect(beforeBindings).toHaveLength(0);
|
||||
|
||||
const approved = await agentService(db).activatePendingApproval(agentId);
|
||||
|
||||
expect(approved).toMatchObject({
|
||||
activated: true,
|
||||
agent: {
|
||||
id: agentId,
|
||||
status: "idle",
|
||||
},
|
||||
});
|
||||
|
||||
const afterBindings = await db
|
||||
.select()
|
||||
.from(companySecretBindings)
|
||||
.where(and(
|
||||
eq(companySecretBindings.companyId, companyId),
|
||||
eq(companySecretBindings.targetType, "agent"),
|
||||
eq(companySecretBindings.targetId, agentId),
|
||||
));
|
||||
|
||||
expect(afterBindings).toHaveLength(1);
|
||||
expect(afterBindings[0]).toMatchObject({
|
||||
secretId: secret.id,
|
||||
configPath: "env.ANTHROPIC_API_KEY",
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls back create when binding sync fails", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const missingSecretId = randomUUID();
|
||||
|
||||
await expect(
|
||||
agentService(db).create(companyId, {
|
||||
name: "Broken Create",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: { type: "secret_ref", secretId: missingSecretId, version: "latest" },
|
||||
},
|
||||
},
|
||||
runtimeConfig: {},
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
}),
|
||||
).rejects.toBeTruthy();
|
||||
|
||||
const persistedAgents = await db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.companyId, companyId));
|
||||
expect(persistedAgents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rolls back adapterConfig updates when binding sync fails", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const secrets = secretService(db);
|
||||
const validSecret = await secrets.create(companyId, {
|
||||
name: `valid-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: "valid-value",
|
||||
});
|
||||
const created = await agentService(db).create(companyId, {
|
||||
name: "Transactional Update",
|
||||
role: "engineer",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {
|
||||
env: {
|
||||
API_KEY: { type: "secret_ref", secretId: validSecret.id, version: "latest" },
|
||||
},
|
||||
},
|
||||
runtimeConfig: {},
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
agentService(db).update(created.id, {
|
||||
adapterConfig: {
|
||||
env: {
|
||||
API_KEY: { type: "secret_ref", secretId: randomUUID(), version: "latest" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
).rejects.toBeTruthy();
|
||||
|
||||
const reloaded = await agentService(db).getById(created.id);
|
||||
expect(reloaded?.adapterConfig).toMatchObject({
|
||||
env: {
|
||||
API_KEY: { type: "secret_ref", secretId: validSecret.id, version: "latest" },
|
||||
},
|
||||
});
|
||||
|
||||
const bindings = await db
|
||||
.select()
|
||||
.from(companySecretBindings)
|
||||
.where(and(
|
||||
eq(companySecretBindings.companyId, companyId),
|
||||
eq(companySecretBindings.targetType, "agent"),
|
||||
eq(companySecretBindings.targetId, created.id),
|
||||
));
|
||||
expect(bindings).toHaveLength(1);
|
||||
expect(bindings[0]?.secretId).toBe(validSecret.id);
|
||||
});
|
||||
|
||||
it("keeps pending approval status when activation binding sync fails", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const agentId = randomUUID();
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Broken Pending Agent",
|
||||
role: "engineer",
|
||||
status: "pending_approval",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: { type: "secret_ref", secretId: randomUUID(), version: "latest" },
|
||||
},
|
||||
},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await expect(agentService(db).activatePendingApproval(agentId)).rejects.toBeTruthy();
|
||||
|
||||
const reloaded = await agentService(db).getById(agentId);
|
||||
expect(reloaded?.status).toBe("pending_approval");
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,7 @@ function createDbStub(selectResults: ApprovalRecord[][], updateResults: Approval
|
||||
describe("approvalService resolution idempotency", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAgentService.activatePendingApproval.mockResolvedValue(undefined);
|
||||
mockAgentService.activatePendingApproval.mockResolvedValue({ agent: { id: "agent-1" }, activated: true });
|
||||
mockAgentService.create.mockResolvedValue({ id: "agent-1" });
|
||||
mockAgentService.terminate.mockResolvedValue(undefined);
|
||||
mockNotifyHireApproved.mockResolvedValue(undefined);
|
||||
@@ -104,4 +104,34 @@ describe("approvalService resolution idempotency", () => {
|
||||
expect(mockAgentService.activatePendingApproval).toHaveBeenCalledWith("agent-1");
|
||||
expect(mockNotifyHireApproved).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("creates the agent from payload when approval does not reference a pending agent", async () => {
|
||||
const approved = {
|
||||
...createApproval("approved"),
|
||||
payload: {
|
||||
name: "New Agent",
|
||||
adapterConfig: {
|
||||
env: {
|
||||
API_KEY: {
|
||||
type: "secret_ref",
|
||||
secretId: "secret-1",
|
||||
version: "latest",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const dbStub = createDbStub([[{ ...createApproval("pending"), payload: approved.payload }]], [approved]);
|
||||
|
||||
const svc = approvalService(dbStub.db as any);
|
||||
const result = await svc.approve("approval-1", "board", "ship it");
|
||||
|
||||
expect(result.applied).toBe(true);
|
||||
expect(mockAgentService.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
adapterConfig: approved.payload.adapterConfig,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
agentRuntimeState,
|
||||
agentWakeupRequests,
|
||||
budgetPolicies,
|
||||
companySecretBindings,
|
||||
companySecrets,
|
||||
companySkills,
|
||||
companies,
|
||||
costEvents,
|
||||
@@ -96,6 +98,7 @@ import {
|
||||
heartbeatService,
|
||||
redactDetectedSuccessfulRunProgressSummaryForBoard,
|
||||
} from "../services/heartbeat.ts";
|
||||
import { secretService } from "../services/secrets.ts";
|
||||
import {
|
||||
SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
|
||||
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
|
||||
@@ -411,6 +414,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
await db.delete(issueDocuments);
|
||||
await db.delete(documentRevisions);
|
||||
await db.delete(documents);
|
||||
await db.delete(companySecretBindings);
|
||||
await db.delete(companySecrets);
|
||||
try {
|
||||
await db.delete(companies);
|
||||
break;
|
||||
@@ -1432,6 +1437,90 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
expect(validationComment).toBeTruthy();
|
||||
});
|
||||
|
||||
it("blocks before dispatch when a declared secret ref has no binding instead of emitting an opaque setup failure", async () => {
|
||||
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
|
||||
const svc = secretService(db);
|
||||
const secretName = `unbound-runtime-${randomUUID()}`;
|
||||
const secret = await svc.create(companyId, {
|
||||
name: secretName,
|
||||
provider: "local_encrypted",
|
||||
value: "never-resolved",
|
||||
});
|
||||
// Declare the secret ref on the agent env WITHOUT creating a binding so the
|
||||
// pre-dispatch gate short-circuits to a configuration-incomplete blocker.
|
||||
await db
|
||||
.update(agents)
|
||||
.set({
|
||||
adapterConfig: {
|
||||
env: {
|
||||
UNBOUND_API_KEY: { type: "secret_ref", secretId: secret.id, version: "latest" },
|
||||
},
|
||||
},
|
||||
})
|
||||
.where(eq(agents.id, agentId));
|
||||
|
||||
const heartbeat = heartbeatService(db);
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
await waitForRunToSettle(heartbeat, runId, 5_000);
|
||||
|
||||
expect(mockAdapterExecute).not.toHaveBeenCalled();
|
||||
|
||||
const failedRun = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(failedRun).toMatchObject({
|
||||
status: "failed",
|
||||
errorCode: "configuration_incomplete",
|
||||
});
|
||||
expect(failedRun?.error).toContain("configuration incomplete");
|
||||
expect(failedRun?.error).toContain(secretName);
|
||||
expect(failedRun?.error).toContain("env.UNBOUND_API_KEY");
|
||||
expect(failedRun?.resultJson).toMatchObject({
|
||||
configurationIncomplete: {
|
||||
reason: "secret_binding_missing",
|
||||
missingBindings: [
|
||||
{
|
||||
consumerType: "agent",
|
||||
consumerId: agentId,
|
||||
configPath: "env.UNBOUND_API_KEY",
|
||||
envKey: "UNBOUND_API_KEY",
|
||||
secretId: secret.id,
|
||||
secretName,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
// Value-free gate: no secret access events were recorded.
|
||||
expect(await svc.listAccessEvents(companyId, secret.id)).toHaveLength(0);
|
||||
|
||||
const issue = await waitForValue(async () =>
|
||||
db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => {
|
||||
const row = rows[0] ?? null;
|
||||
return row?.status === "blocked" ? row : null;
|
||||
}),
|
||||
);
|
||||
expect(issue?.executionRunId).toBeNull();
|
||||
|
||||
const recoveryAction = await db
|
||||
.select()
|
||||
.from(issueRecoveryActions)
|
||||
.where(and(eq(issueRecoveryActions.companyId, companyId), eq(issueRecoveryActions.sourceIssueId, issueId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(recoveryAction).toMatchObject({
|
||||
kind: "configuration_validation",
|
||||
cause: "configuration_incomplete",
|
||||
status: "active",
|
||||
ownerAgentId: agentId,
|
||||
recoveryIssueId: null,
|
||||
});
|
||||
expect(recoveryAction?.nextAction).toContain("Bind the missing secret");
|
||||
|
||||
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
|
||||
expect(comments.some((comment) => comment.body.includes("secret/env bindings are missing"))).toBe(true);
|
||||
});
|
||||
|
||||
it("queues one finish-handoff wake when a successful run leaves in-progress work without a next action", async () => {
|
||||
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
|
||||
mockAdapterExecute.mockImplementationOnce(async (ctx: { runId: string }) => {
|
||||
|
||||
@@ -223,6 +223,46 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
expect(JSON.stringify(events)).not.toContain("runtime-secret");
|
||||
});
|
||||
|
||||
it("collects declared secret refs that have no binding without resolving values", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const secretName = `unbound-${randomUUID()}`;
|
||||
const secret = await svc.create(companyId, {
|
||||
name: secretName,
|
||||
provider: "local_encrypted",
|
||||
value: "runtime-secret",
|
||||
});
|
||||
const env = {
|
||||
API_KEY: { type: "secret_ref" as const, secretId: secret.id, version: "latest" as const },
|
||||
PLAIN_VALUE: "not-a-secret",
|
||||
};
|
||||
|
||||
const missing = await svc.collectMissingRuntimeBindings(companyId, env, {
|
||||
consumerType: "agent",
|
||||
consumerId: "agent-1",
|
||||
});
|
||||
|
||||
expect(missing).toHaveLength(1);
|
||||
expect(missing[0]).toMatchObject({
|
||||
consumerType: "agent",
|
||||
consumerId: "agent-1",
|
||||
configPath: "env.API_KEY",
|
||||
envKey: "API_KEY",
|
||||
secretId: secret.id,
|
||||
secretName,
|
||||
});
|
||||
// Value-free validation: no access events recorded.
|
||||
expect(await svc.listAccessEvents(companyId, secret.id)).toHaveLength(0);
|
||||
|
||||
await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: "agent-1" }, env);
|
||||
|
||||
const afterBinding = await svc.collectMissingRuntimeBindings(companyId, env, {
|
||||
consumerType: "agent",
|
||||
consumerId: "agent-1",
|
||||
});
|
||||
expect(afterBinding).toEqual([]);
|
||||
});
|
||||
|
||||
it("denies runtime secret resolution outside the low-trust binding allowlist", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
|
||||
@@ -2462,14 +2462,6 @@ export function agentRoutes(
|
||||
lastHeartbeatAt: null,
|
||||
});
|
||||
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent, instructionsBundle);
|
||||
const agentEnv = asRecord(agent.adapterConfig)?.env;
|
||||
if (agentEnv) {
|
||||
await secretsSvc.syncEnvBindingsForTarget?.(
|
||||
companyId,
|
||||
{ targetType: "agent", targetId: agent.id },
|
||||
agentEnv,
|
||||
);
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
@@ -2952,14 +2944,6 @@ export function agentRoutes(
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
if (touchesAdapterConfiguration) {
|
||||
const agentEnv = asRecord(agent.adapterConfig)?.env;
|
||||
await secretsSvc.syncEnvBindingsForTarget?.(
|
||||
agent.companyId,
|
||||
{ targetType: "agent", targetId: agent.id },
|
||||
agentEnv,
|
||||
);
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: agent.companyId,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
interface AgentSecretBindingSyncService {
|
||||
syncEnvBindingsForTarget?: (
|
||||
companyId: string,
|
||||
target: { targetType: "agent"; targetId: string; pathPrefix?: string },
|
||||
envValue: unknown,
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function syncAgentAdapterEnvBindings(input: {
|
||||
secretsSvc: AgentSecretBindingSyncService;
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
adapterConfig: unknown;
|
||||
}) {
|
||||
const envValue = asRecord(asRecord(input.adapterConfig)?.env);
|
||||
await input.secretsSvc.syncEnvBindingsForTarget?.(
|
||||
input.companyId,
|
||||
{ targetType: "agent", targetId: input.agentId },
|
||||
envValue,
|
||||
);
|
||||
}
|
||||
@@ -24,8 +24,10 @@ import {
|
||||
type AgentEligibilityAgent,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { syncAgentAdapterEnvBindings } from "./agent-secret-bindings.js";
|
||||
import { normalizeAgentPermissions } from "./agent-permissions.js";
|
||||
import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
|
||||
function hashToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
@@ -218,6 +220,8 @@ export function deduplicateAgentName(
|
||||
}
|
||||
|
||||
export function agentService(db: Db) {
|
||||
const secretsSvc = secretService(db);
|
||||
|
||||
function currentUtcMonthWindow(now = new Date()) {
|
||||
const year = now.getUTCFullYear();
|
||||
const month = now.getUTCMonth();
|
||||
@@ -371,6 +375,19 @@ export function agentService(db: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
async function syncAgentSecretBindings(
|
||||
agent: { id: string; companyId: string; adapterConfig: unknown },
|
||||
dbClient: Db = db,
|
||||
) {
|
||||
const scopedSecretsSvc = dbClient === db ? secretsSvc : secretService(dbClient);
|
||||
await syncAgentAdapterEnvBindings({
|
||||
secretsSvc: scopedSecretsSvc,
|
||||
companyId: agent.companyId,
|
||||
agentId: agent.id,
|
||||
adapterConfig: agent.adapterConfig,
|
||||
});
|
||||
}
|
||||
|
||||
async function updateAgent(
|
||||
id: string,
|
||||
data: Partial<typeof agents.$inferInsert>,
|
||||
@@ -415,33 +432,45 @@ export function agentService(db: Db) {
|
||||
const shouldRecordRevision = Boolean(options?.recordRevision) && hasConfigPatchFields(normalizedPatch);
|
||||
const beforeConfig = shouldRecordRevision ? buildConfigSnapshot(existing) : null;
|
||||
|
||||
const updated = await db
|
||||
.update(agents)
|
||||
.set({ ...normalizedPatch, updatedAt: new Date() })
|
||||
.where(eq(agents.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const normalizedUpdated = updated ? await getById(updated.id) : null;
|
||||
return db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
const updated = await tx
|
||||
.update(agents)
|
||||
.set({ ...normalizedPatch, updatedAt: new Date() })
|
||||
.where(eq(agents.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!updated) return null;
|
||||
|
||||
if (normalizedUpdated && shouldRecordRevision && beforeConfig) {
|
||||
const afterConfig = buildConfigSnapshot(normalizedUpdated);
|
||||
const changedKeys = diffConfigSnapshot(beforeConfig, afterConfig);
|
||||
if (changedKeys.length > 0) {
|
||||
await db.insert(agentConfigRevisions).values({
|
||||
companyId: normalizedUpdated.companyId,
|
||||
agentId: normalizedUpdated.id,
|
||||
createdByAgentId: options?.recordRevision?.createdByAgentId ?? null,
|
||||
createdByUserId: options?.recordRevision?.createdByUserId ?? null,
|
||||
source: options?.recordRevision?.source ?? "patch",
|
||||
rolledBackFromRevisionId: options?.recordRevision?.rolledBackFromRevisionId ?? null,
|
||||
changedKeys,
|
||||
beforeConfig: beforeConfig as unknown as Record<string, unknown>,
|
||||
afterConfig: afterConfig as unknown as Record<string, unknown>,
|
||||
});
|
||||
if (Object.prototype.hasOwnProperty.call(normalizedPatch, "adapterConfig")) {
|
||||
await syncAgentSecretBindings(updated, txDb);
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedUpdated;
|
||||
const normalizedUpdated = await agentService(txDb).getById(updated.id);
|
||||
if (!normalizedUpdated) {
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
|
||||
if (shouldRecordRevision && beforeConfig) {
|
||||
const afterConfig = buildConfigSnapshot(normalizedUpdated);
|
||||
const changedKeys = diffConfigSnapshot(beforeConfig, afterConfig);
|
||||
if (changedKeys.length > 0) {
|
||||
await tx.insert(agentConfigRevisions).values({
|
||||
companyId: normalizedUpdated.companyId,
|
||||
agentId: normalizedUpdated.id,
|
||||
createdByAgentId: options?.recordRevision?.createdByAgentId ?? null,
|
||||
createdByUserId: options?.recordRevision?.createdByUserId ?? null,
|
||||
source: options?.recordRevision?.source ?? "patch",
|
||||
rolledBackFromRevisionId: options?.recordRevision?.rolledBackFromRevisionId ?? null,
|
||||
changedKeys,
|
||||
beforeConfig: beforeConfig as unknown as Record<string, unknown>,
|
||||
afterConfig: afterConfig as unknown as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedUpdated;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -474,13 +503,20 @@ export function agentService(db: Db) {
|
||||
const role = data.role ?? "general";
|
||||
const normalizedPermissions = normalizeAgentPermissions(data.permissions, role);
|
||||
const runtimeConfig = normalizeRuntimeConfigForNewAgent(data.runtimeConfig);
|
||||
const created = await db
|
||||
.insert(agents)
|
||||
.values({ ...data, name: uniqueName, companyId, role, permissions: normalizedPermissions, runtimeConfig })
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
return requireGetById(created.id);
|
||||
return db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
const created = await tx
|
||||
.insert(agents)
|
||||
.values({ ...data, name: uniqueName, companyId, role, permissions: normalizedPermissions, runtimeConfig })
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
await syncAgentSecretBindings(created, txDb);
|
||||
const normalizedCreated = await agentService(txDb).getById(created.id);
|
||||
if (!normalizedCreated) {
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
return normalizedCreated;
|
||||
});
|
||||
},
|
||||
|
||||
update: updateAgent,
|
||||
@@ -496,6 +532,7 @@ export function agentService(db: Db) {
|
||||
status: "paused",
|
||||
pauseReason: reason,
|
||||
pausedAt: new Date(),
|
||||
errorReason: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, id))
|
||||
@@ -518,6 +555,7 @@ export function agentService(db: Db) {
|
||||
status: "idle",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
errorReason: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, id))
|
||||
@@ -543,6 +581,7 @@ export function agentService(db: Db) {
|
||||
status: "idle",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
errorReason: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(agents.id, id), eq(agents.status, "error")))
|
||||
@@ -565,6 +604,7 @@ export function agentService(db: Db) {
|
||||
status: "terminated",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
errorReason: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, id));
|
||||
@@ -611,15 +651,25 @@ export function agentService(db: Db) {
|
||||
},
|
||||
|
||||
activatePendingApproval: async (id: string) => {
|
||||
const updated = await db
|
||||
.update(agents)
|
||||
.set({ status: "idle", updatedAt: new Date() })
|
||||
.where(and(eq(agents.id, id), eq(agents.status, "pending_approval")))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const activatedAgent = await db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
const updated = await tx
|
||||
.update(agents)
|
||||
.set({ status: "idle", updatedAt: new Date() })
|
||||
.where(and(eq(agents.id, id), eq(agents.status, "pending_approval")))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!updated) return null;
|
||||
await syncAgentSecretBindings(updated, txDb);
|
||||
const agent = await agentService(txDb).getById(updated.id);
|
||||
if (!agent) {
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
return agent;
|
||||
});
|
||||
|
||||
if (updated) {
|
||||
return { agent: await requireGetById(updated.id), activated: true };
|
||||
if (activatedAgent) {
|
||||
return { agent: activatedAgent, activated: true };
|
||||
}
|
||||
|
||||
const existing = await getById(id);
|
||||
|
||||
@@ -71,7 +71,7 @@ import { trackAgentFirstHeartbeat } from "@paperclipai/shared/telemetry";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
import { companySkillService } from "./company-skills.js";
|
||||
import { budgetService, type BudgetEnforcementScope } from "./budgets.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { secretService, type MissingRuntimeBinding } from "./secrets.js";
|
||||
import { resolveDefaultAgentWorkspaceDir, resolveManagedProjectWorkspaceDir } from "../home-paths.js";
|
||||
import {
|
||||
buildHeartbeatRunIssueComment,
|
||||
@@ -254,6 +254,8 @@ const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_WAKE_REASON = "transient_failure_retry";
|
||||
const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS = BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS.length;
|
||||
const WORKSPACE_VALIDATION_FAILURE_CODE = "workspace_validation_failed";
|
||||
const WORKSPACE_VALIDATION_RECOVERY_CAUSE = "workspace_validation_failed";
|
||||
const CONFIGURATION_INCOMPLETE_FAILURE_CODE = "configuration_incomplete";
|
||||
const CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE = "configuration_incomplete";
|
||||
// Keep this in sync with local adapters that require a git workspace before launch.
|
||||
const GIT_SENSITIVE_LOCAL_ADAPTER_TYPES = new Set([
|
||||
"acpx_local",
|
||||
@@ -296,6 +298,20 @@ export class WorkspaceValidationFailure extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-dispatch gate outcome: required secret/env bindings are missing, so the
|
||||
// run must not be dispatched. Surfaced as a configuration-incomplete blocker
|
||||
// routed to a human owner instead of N opaque dispatched-then-failed runs.
|
||||
export class ConfigurationIncompleteFailure extends Error {
|
||||
code = CONFIGURATION_INCOMPLETE_FAILURE_CODE;
|
||||
resultJson: Record<string, unknown>;
|
||||
|
||||
constructor(message: string, resultJson: Record<string, unknown>) {
|
||||
super(message);
|
||||
this.name = "ConfigurationIncompleteFailure";
|
||||
this.resultJson = resultJson;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCodexTransientFallbackMode(attempt: number): CodexTransientFallbackMode {
|
||||
if (attempt <= 1) return "same_session";
|
||||
if (attempt === 2) return "safer_invocation";
|
||||
@@ -381,9 +397,16 @@ const INLINE_BASE64_IMAGE_DATA_RE = /("type":"image","source":\{"type":"base64",
|
||||
|
||||
type RuntimeConfigSecretResolver = Pick<
|
||||
ReturnType<typeof secretService>,
|
||||
"resolveAdapterConfigForRuntime" | "resolveEnvBindings"
|
||||
"resolveAdapterConfigForRuntime" | "resolveEnvBindings" | "collectMissingRuntimeBindings"
|
||||
>;
|
||||
|
||||
function formatMissingBindingForOperator(missing: MissingRuntimeBinding): string {
|
||||
const secretLabel = missing.secretName
|
||||
? `"${missing.secretName}"`
|
||||
: missing.secretId;
|
||||
return `secret ${secretLabel} not bound at ${missing.consumerType} ${missing.configPath}`;
|
||||
}
|
||||
|
||||
const LOW_TRUST_SENSITIVE_ENV_KEY_RE =
|
||||
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
|
||||
|
||||
@@ -449,6 +472,54 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
assertLowTrustEnvConfigAllowed(projectEnv, "project.env");
|
||||
assertLowTrustEnvConfigAllowed(routineEnv, "routine.env");
|
||||
}
|
||||
// Pre-dispatch binding-validation gate: detect declared secret refs that have
|
||||
// no binding before resolving any secret value. Missing bindings short-circuit
|
||||
// to a configuration-incomplete blocker routed to a human owner instead of a
|
||||
// dispatched-then-failed run (which previously surfaced as opaque setup_failed).
|
||||
if (typeof input.secretsSvc.collectMissingRuntimeBindings === "function") {
|
||||
const missingBindings: MissingRuntimeBinding[] = [];
|
||||
if (input.agentId) {
|
||||
missingBindings.push(
|
||||
...(await input.secretsSvc.collectMissingRuntimeBindings(
|
||||
input.companyId,
|
||||
parseObject(executionRunConfig.env),
|
||||
{ consumerType: "agent", consumerId: input.agentId },
|
||||
)),
|
||||
);
|
||||
}
|
||||
if (projectEnv && input.projectId) {
|
||||
missingBindings.push(
|
||||
...(await input.secretsSvc.collectMissingRuntimeBindings(
|
||||
input.companyId,
|
||||
projectEnv,
|
||||
{ consumerType: "project", consumerId: input.projectId },
|
||||
)),
|
||||
);
|
||||
}
|
||||
if (routineEnv && input.routineId) {
|
||||
missingBindings.push(
|
||||
...(await input.secretsSvc.collectMissingRuntimeBindings(
|
||||
input.companyId,
|
||||
routineEnv,
|
||||
{ consumerType: "routine", consumerId: input.routineId },
|
||||
)),
|
||||
);
|
||||
}
|
||||
if (missingBindings.length > 0) {
|
||||
const detail = missingBindings.map(formatMissingBindingForOperator).join("; ");
|
||||
throw new ConfigurationIncompleteFailure(`configuration incomplete: ${detail}`, {
|
||||
configurationIncomplete: {
|
||||
reason: "secret_binding_missing",
|
||||
companyId: input.companyId,
|
||||
agentId: input.agentId ?? null,
|
||||
issueId: input.issueId ?? null,
|
||||
projectId: input.projectId ?? null,
|
||||
routineId: input.routineId ?? null,
|
||||
missingBindings,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
const { config: resolvedConfig, secretKeys, manifest } = await input.secretsSvc.resolveAdapterConfigForRuntime(
|
||||
input.companyId,
|
||||
executionRunConfig,
|
||||
@@ -939,6 +1010,16 @@ function isWorkspaceValidationFailedRun(
|
||||
return run?.errorCode === WORKSPACE_VALIDATION_FAILURE_CODE;
|
||||
}
|
||||
|
||||
function isConfigurationIncompleteFailure(error: unknown): error is ConfigurationIncompleteFailure {
|
||||
return error instanceof ConfigurationIncompleteFailure;
|
||||
}
|
||||
|
||||
function isConfigurationIncompleteFailedRun(
|
||||
run: Pick<typeof heartbeatRuns.$inferSelect, "errorCode"> | null | undefined,
|
||||
) {
|
||||
return run?.errorCode === CONFIGURATION_INCOMPLETE_FAILURE_CODE;
|
||||
}
|
||||
|
||||
async function hasGitMetadata(cwd: string | null | undefined) {
|
||||
const normalized = readNonEmptyString(cwd);
|
||||
if (!normalized) return false;
|
||||
@@ -7199,9 +7280,17 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
function truncateAgentErrorReason(reason: string | null | undefined): string | null {
|
||||
if (!reason) return null;
|
||||
const trimmed = reason.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.length > 500 ? `${trimmed.slice(0, 499)}…` : trimmed;
|
||||
}
|
||||
|
||||
async function finalizeAgentStatus(
|
||||
agentId: string,
|
||||
outcome: "succeeded" | "failed" | "cancelled" | "timed_out",
|
||||
failureReason?: string | null,
|
||||
) {
|
||||
const existing = await getAgent(agentId);
|
||||
if (!existing) return;
|
||||
@@ -7224,6 +7313,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.update(agents)
|
||||
.set({
|
||||
status: nextStatus,
|
||||
// Persist a human-readable reason on the agent record when it enters
|
||||
// error so operators see it on the agent page without digging into run
|
||||
// events; clear it whenever the agent leaves error.
|
||||
errorReason: nextStatus === "error" ? truncateAgentErrorReason(failureReason) : null,
|
||||
lastHeartbeatAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
@@ -7586,7 +7679,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
},
|
||||
});
|
||||
|
||||
await finalizeAgentStatus(run.agentId, "failed");
|
||||
await finalizeAgentStatus(run.agentId, "failed", baseMessage);
|
||||
await startNextQueuedRunForAgent(run.agentId);
|
||||
runningProcesses.delete(run.id);
|
||||
reaped.push(run.id);
|
||||
@@ -9439,14 +9532,20 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
await finalizeAgentStatus(agent.id, outcome);
|
||||
await finalizeAgentStatus(
|
||||
agent.id,
|
||||
outcome,
|
||||
outcome === "succeeded" ? null : (adapterResult.errorMessage ?? null),
|
||||
);
|
||||
} catch (err) {
|
||||
const message = redactCurrentUserText(
|
||||
err instanceof Error ? err.message : "Unknown adapter failure",
|
||||
await getCurrentUserRedactionOptions(),
|
||||
);
|
||||
const workspaceValidationFailure = isWorkspaceValidationFailure(err) ? err : null;
|
||||
const failureErrorCode = workspaceValidationFailure?.code ?? "adapter_failed";
|
||||
const configurationIncompleteFailure = isConfigurationIncompleteFailure(err) ? err : null;
|
||||
const failureErrorCode =
|
||||
workspaceValidationFailure?.code ?? configurationIncompleteFailure?.code ?? "adapter_failed";
|
||||
logger.error({ err, runId }, "heartbeat execution failed");
|
||||
|
||||
let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null;
|
||||
@@ -9472,7 +9571,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
resultJson: mergeRunStopMetadataForAgent(agent, "failed", {
|
||||
errorCode: failureErrorCode,
|
||||
errorMessage: message,
|
||||
resultJson: workspaceValidationFailure?.resultJson ?? null,
|
||||
resultJson: workspaceValidationFailure?.resultJson ?? configurationIncompleteFailure?.resultJson ?? null,
|
||||
}),
|
||||
stdoutExcerpt,
|
||||
stderrExcerpt,
|
||||
@@ -9507,7 +9606,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
});
|
||||
const livenessRun = await classifyAndPersistRunLiveness(failedRun) ?? failedRun;
|
||||
await refreshContinuationSummaryForRun(livenessRun, agent);
|
||||
if (!isWorkspaceValidationFailedRun(livenessRun)) {
|
||||
if (!isWorkspaceValidationFailedRun(livenessRun) && !isConfigurationIncompleteFailedRun(livenessRun)) {
|
||||
await finalizeIssueCommentPolicy(livenessRun, agent);
|
||||
}
|
||||
await releaseIssueExecutionAndPromote(livenessRun);
|
||||
@@ -9535,22 +9634,31 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
}
|
||||
}
|
||||
|
||||
await finalizeAgentStatus(agent.id, "failed");
|
||||
await finalizeAgentStatus(agent.id, "failed", message);
|
||||
}
|
||||
} catch (outerErr) {
|
||||
// Setup code before adapter.execute threw (e.g. ensureRuntimeState, resolveWorkspaceForRun).
|
||||
// The inner catch did not fire, so we must record the failure here.
|
||||
const message = outerErr instanceof Error ? outerErr.message : "Unknown setup failure";
|
||||
const message = redactCurrentUserText(
|
||||
outerErr instanceof Error ? outerErr.message : "Unknown setup failure",
|
||||
await getCurrentUserRedactionOptions(),
|
||||
);
|
||||
// A missing secret/env binding is a known pre-dispatch configuration gap,
|
||||
// not an opaque setup crash. Surface it with its own errorCode so the
|
||||
// recovery path routes it to a human owner instead of looping retries.
|
||||
const configurationIncompleteSetupFailure = isConfigurationIncompleteFailure(outerErr) ? outerErr : null;
|
||||
const setupFailureErrorCode = configurationIncompleteSetupFailure?.code ?? "setup_failed";
|
||||
logger.error({ err: outerErr, runId }, "heartbeat execution setup failed");
|
||||
const setupFailureAgent = await getAgent(run.agentId).catch(() => null);
|
||||
const setupFailureWrite = await setRunStatusIfRunning(runId, "failed", {
|
||||
error: message,
|
||||
errorCode: "setup_failed",
|
||||
errorCode: setupFailureErrorCode,
|
||||
finishedAt: new Date(),
|
||||
...(setupFailureAgent ? {
|
||||
resultJson: mergeRunStopMetadataForAgent(setupFailureAgent, "failed", {
|
||||
errorCode: "setup_failed",
|
||||
errorCode: setupFailureErrorCode,
|
||||
errorMessage: message,
|
||||
resultJson: configurationIncompleteSetupFailure?.resultJson ?? null,
|
||||
}),
|
||||
} : {}),
|
||||
}).catch(() => ({ run: null, updated: false as const }));
|
||||
@@ -9583,7 +9691,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
const failedAgent = setupFailureAgent ?? await getAgent(run.agentId).catch(() => null);
|
||||
if (failedAgent) {
|
||||
await refreshContinuationSummaryForRun(livenessRun, failedAgent).catch(() => undefined);
|
||||
if (!isWorkspaceValidationFailedRun(livenessRun)) {
|
||||
if (!isWorkspaceValidationFailedRun(livenessRun) && !isConfigurationIncompleteFailedRun(livenessRun)) {
|
||||
await finalizeIssueCommentPolicy(livenessRun, failedAgent).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -9593,7 +9701,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
// path owned the terminal transition. If another path already finalized
|
||||
// the run, keep that terminal outcome authoritative.
|
||||
if (setupFailureWrite.updated) {
|
||||
await finalizeAgentStatus(run.agentId, "failed").catch(() => undefined);
|
||||
await finalizeAgentStatus(run.agentId, "failed", message).catch(() => undefined);
|
||||
}
|
||||
} finally {
|
||||
const latestRun = await getRun(run.id).catch(() => null);
|
||||
@@ -9641,6 +9749,17 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
);
|
||||
}
|
||||
|
||||
function buildConfigurationIncompleteRecoveryComment(input: {
|
||||
latestRun: Pick<typeof heartbeatRuns.$inferSelect, "error" | "errorCode"> | null | undefined;
|
||||
}) {
|
||||
const failureSummary = summarizeRunFailureForIssueComment(input.latestRun);
|
||||
return (
|
||||
"Paperclip stopped before dispatching the adapter because required secret/env bindings are missing. " +
|
||||
`Resolving them as a runtime failure would only produce repeated opaque setup failures.${failureSummary ?? ""} ` +
|
||||
"Moving it to `blocked` with a source-scoped recovery action so an operator can bind the missing secret(s) before resuming."
|
||||
);
|
||||
}
|
||||
|
||||
async function releaseIssueExecutionAndPromote(run: typeof heartbeatRuns.$inferSelect) {
|
||||
const runContext = parseObject(run.contextSnapshot);
|
||||
const contextIssueId = readNonEmptyString(runContext.issueId);
|
||||
@@ -9761,17 +9880,22 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
// Sibling lock cleanup is already done above; only the primary issue carries
|
||||
// the recovery surface because the comment is attached to a single issue.
|
||||
if (
|
||||
isWorkspaceValidationFailedRun(run) &&
|
||||
(isWorkspaceValidationFailedRun(run) || isConfigurationIncompleteFailedRun(run)) &&
|
||||
(issue.status === "todo" || issue.status === "in_progress") &&
|
||||
!issue.assigneeUserId &&
|
||||
issue.assigneeAgentId === run.agentId
|
||||
) {
|
||||
const configurationIncomplete = isConfigurationIncompleteFailedRun(run);
|
||||
return {
|
||||
kind: "blocked" as const,
|
||||
issue,
|
||||
previousStatus: issue.status,
|
||||
comment: buildWorkspaceValidationRecoveryComment({ latestRun: run }),
|
||||
recoveryCause: WORKSPACE_VALIDATION_RECOVERY_CAUSE,
|
||||
comment: configurationIncomplete
|
||||
? buildConfigurationIncompleteRecoveryComment({ latestRun: run })
|
||||
: buildWorkspaceValidationRecoveryComment({ latestRun: run }),
|
||||
recoveryCause: configurationIncomplete
|
||||
? CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE
|
||||
: WORKSPACE_VALIDATION_RECOVERY_CAUSE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10057,21 +10181,29 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
!recoveryAgentInvokable ||
|
||||
!recoveryAgent ||
|
||||
isWorkspaceValidationFailedRun(run) ||
|
||||
isConfigurationIncompleteFailedRun(run) ||
|
||||
didAutomaticRecoveryFail(run, issue.status === "todo" ? "assignment_recovery" : "issue_continuation_needed");
|
||||
if (shouldBlockImmediately) {
|
||||
const workspaceValidationFailure = isWorkspaceValidationFailedRun(run);
|
||||
const configurationIncompleteFailure = isConfigurationIncompleteFailedRun(run);
|
||||
const comment = workspaceValidationFailure
|
||||
? buildWorkspaceValidationRecoveryComment({ latestRun: run })
|
||||
: buildImmediateExecutionPathRecoveryComment({
|
||||
status: issue.status as "todo" | "in_progress",
|
||||
latestRun: run,
|
||||
});
|
||||
: configurationIncompleteFailure
|
||||
? buildConfigurationIncompleteRecoveryComment({ latestRun: run })
|
||||
: buildImmediateExecutionPathRecoveryComment({
|
||||
status: issue.status as "todo" | "in_progress",
|
||||
latestRun: run,
|
||||
});
|
||||
return {
|
||||
kind: "blocked" as const,
|
||||
issue,
|
||||
previousStatus: issue.status,
|
||||
comment,
|
||||
recoveryCause: workspaceValidationFailure ? WORKSPACE_VALIDATION_RECOVERY_CAUSE : undefined,
|
||||
recoveryCause: workspaceValidationFailure
|
||||
? WORKSPACE_VALIDATION_RECOVERY_CAUSE
|
||||
: configurationIncompleteFailure
|
||||
? CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10157,7 +10289,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
recoveryCause:
|
||||
promotionResult.recoveryCause === WORKSPACE_VALIDATION_RECOVERY_CAUSE
|
||||
? WORKSPACE_VALIDATION_RECOVERY_CAUSE
|
||||
: undefined,
|
||||
: promotionResult.recoveryCause === CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE
|
||||
? CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE
|
||||
: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -119,6 +119,7 @@ type SuccessfulLatestIssueRun = NonNullable<LatestIssueRun> & { status: "succeed
|
||||
type StrandedRecoveryCause =
|
||||
| "stranded_assigned_issue"
|
||||
| "workspace_validation_failed"
|
||||
| "configuration_incomplete"
|
||||
| typeof SUCCESSFUL_RUN_MISSING_STATE_REASON;
|
||||
|
||||
type SuccessfulRunHandoffRecoveryEvidence = {
|
||||
@@ -2231,6 +2232,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
? "missing_disposition" as const
|
||||
: cause === "workspace_validation_failed"
|
||||
? "workspace_validation" as const
|
||||
: cause === "configuration_incomplete"
|
||||
? "configuration_validation" as const
|
||||
: "stranded_assigned_issue" as const;
|
||||
}
|
||||
|
||||
@@ -2306,11 +2309,13 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
? "Choose and record a valid issue disposition without copying transcript content."
|
||||
: recoveryCause === "workspace_validation_failed"
|
||||
? "Repair the source issue workspace link, project workspace cwd, or git checkout before resuming adapter execution."
|
||||
: recoveryCause === "configuration_incomplete"
|
||||
? "Bind the missing secret(s) named in the run failure to the agent/project/routine env before resuming adapter execution."
|
||||
: "Restore a live execution path, fix the runtime/adapter failure, or record an intentional manual resolution.",
|
||||
wakePolicy: recoveryCause === "workspace_validation_failed"
|
||||
wakePolicy: recoveryCause === "workspace_validation_failed" || recoveryCause === "configuration_incomplete"
|
||||
? {
|
||||
type: "manual_repair_required",
|
||||
reason: "workspace_validation_failed",
|
||||
reason: recoveryCause,
|
||||
ownerAgentId,
|
||||
}
|
||||
: ownerAgentId
|
||||
@@ -2338,6 +2343,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
recoveryCause: StrandedRecoveryCause;
|
||||
}) {
|
||||
if (input.recoveryCause === "workspace_validation_failed") return;
|
||||
if (input.recoveryCause === "configuration_incomplete") return;
|
||||
if (!input.action.ownerAgentId) return;
|
||||
await deps.enqueueWakeup(input.action.ownerAgentId, {
|
||||
source: "assignment",
|
||||
@@ -2543,7 +2549,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
|
||||
const shouldPostEscalationComment =
|
||||
recoveryAction.attemptCount === 1 ||
|
||||
input.recoveryCause === "workspace_validation_failed";
|
||||
input.recoveryCause === "workspace_validation_failed" ||
|
||||
input.recoveryCause === "configuration_incomplete";
|
||||
if (shouldPostEscalationComment) {
|
||||
const escalationCommentMarker = `Recovery action: \`${recoveryAction.id}\``;
|
||||
|
||||
@@ -2597,6 +2604,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
? "recovery.reconcile_successful_run_handoff_missing_state"
|
||||
: input.recoveryCause === "workspace_validation_failed"
|
||||
? "recovery.reconcile_workspace_validation_failed"
|
||||
: input.recoveryCause === "configuration_incomplete"
|
||||
? "recovery.reconcile_configuration_incomplete"
|
||||
: "recovery.reconcile_stranded_assigned_issue",
|
||||
recoveryCause: input.recoveryCause ?? "stranded_assigned_issue",
|
||||
latestRunId: input.latestRun?.id ?? null,
|
||||
|
||||
@@ -203,6 +203,15 @@ export type RuntimeSecretManifestEntry = {
|
||||
errorCode?: string | null;
|
||||
};
|
||||
|
||||
export type MissingRuntimeBinding = {
|
||||
consumerType: SecretBindingTargetType;
|
||||
consumerId: string;
|
||||
configPath: string;
|
||||
envKey: string;
|
||||
secretId: string;
|
||||
secretName: string | null;
|
||||
};
|
||||
|
||||
type RuntimeSecretResolution = {
|
||||
value: string;
|
||||
manifestEntry: RuntimeSecretManifestEntry;
|
||||
@@ -2352,6 +2361,60 @@ export function secretService(db: Db) {
|
||||
return { env: resolved, secretKeys, manifest };
|
||||
},
|
||||
|
||||
// Pre-dispatch validation: list declared secret refs in an env-like config
|
||||
// that have no binding for the given consumer, WITHOUT resolving any secret
|
||||
// values. Callers use this to surface a configuration-incomplete blocker
|
||||
// before a run is dispatched instead of letting resolution throw mid-setup.
|
||||
collectMissingRuntimeBindings: async (
|
||||
companyId: string,
|
||||
envValue: unknown,
|
||||
context: Omit<SecretConsumerContext, "configPath">,
|
||||
): Promise<MissingRuntimeBinding[]> => {
|
||||
const record = asRecord(envValue);
|
||||
if (!record) return [];
|
||||
const secretRefs = Object.entries(record).flatMap(([key, rawBinding]) => {
|
||||
if (!ENV_KEY_RE.test(key)) return [];
|
||||
const parsed = envBindingSchema.safeParse(rawBinding);
|
||||
if (!parsed.success) return [];
|
||||
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
||||
if (binding.type !== "secret_ref") return [];
|
||||
return [{ key, configPath: `env.${key}`, secretId: binding.secretId }];
|
||||
});
|
||||
if (secretRefs.length === 0) return [];
|
||||
|
||||
const bindingChecks = await Promise.all(secretRefs.map(async (entry) => ({
|
||||
entry,
|
||||
found: await getBinding({
|
||||
companyId,
|
||||
secretId: entry.secretId,
|
||||
consumerType: context.consumerType,
|
||||
consumerId: context.consumerId,
|
||||
configPath: entry.configPath,
|
||||
}),
|
||||
})));
|
||||
const missingEntries = bindingChecks
|
||||
.filter((check) => !check.found)
|
||||
.map((check) => check.entry);
|
||||
if (missingEntries.length === 0) return [];
|
||||
|
||||
const secretRows = await Promise.all(
|
||||
[...new Set(missingEntries.map((entry) => entry.secretId))].map(async (secretId) => [
|
||||
secretId,
|
||||
await getById(secretId).catch(() => null),
|
||||
] as const),
|
||||
);
|
||||
const secretsById = new Map(secretRows);
|
||||
|
||||
return missingEntries.map((entry) => ({
|
||||
consumerType: context.consumerType,
|
||||
consumerId: context.consumerId,
|
||||
configPath: entry.configPath,
|
||||
envKey: entry.key,
|
||||
secretId: entry.secretId,
|
||||
secretName: secretsById.get(entry.secretId)?.name ?? null,
|
||||
}));
|
||||
},
|
||||
|
||||
resolveAdapterConfigForRuntime: async (
|
||||
companyId: string,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
|
||||
Reference in New Issue
Block a user