Add governed secret alias confirmation cards (#11486)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents need scoped secret bindings to use external services safely.
> - Agents could not request an existing secret under a new config name
without an internal secret identifier.
> - Existing binding proposals were only visible in Settings and did not
create an issue-thread approval path.
> - A confirmation card could record acceptance without proving that the
binding was created.
> - This pull request extends the existing secret proposal system with
safe source references and governed issue-thread confirmation cards.
> - The benefit is a one-click flow that creates the binding or shows a
clear failure without exposing secret material.

## Linked Issues or Issue Description

Related prerequisite: #11482.

**Subsystem affected**

Cross-cutting: server REST APIs, shared interaction contracts, database
proposal schema, and issue-thread UI.

**Problem or motivation**

An agent can need an existing bound secret under a second config name.
The agent cannot safely discover the internal secret identifier. The
existing proposal is also easy for the operator to miss because it only
appears in Settings. A generic confirmation can record acceptance
without executing the binding.

**Proposed solution**

Let an agent create a binding proposal from one of its existing config
paths. Mint a server-owned, human-only confirmation card on the
checked-out issue. Recheck the operator's target-agent permission under
the proposal row lock. Execute the existing proposal transaction after
card acceptance. Store an `executed` or `failed` result on the card.
Render the complete lifecycle in the issue thread and attention
resolver.

**Alternatives considered**

A new alias subsystem would duplicate proposal quotas, expiry,
authorization, and binding synchronization. A text-only issue comment
would not provide a governed action or an execution result. An
agent-supplied card payload would permit metadata smuggling. This change
uses the existing proposal transaction and a server-owned payload
instead.

**Roadmap alignment**

This change extends the completed "Secrets Manager with per-agent
access" roadmap item. It preserves scoped bindings and audited
resolution. The required GitHub search found no other open duplicate
issue or pull request.

## What Changed

- Added safe source-config-path binding proposals and preserved
user-secret ownership checks.
- Added a proposal-to-interaction link and an idempotent database
migration.
- Minted human-only `request_confirmation` cards with server-owned
`secretProposal` metadata.
- Rejected agent-supplied governed metadata and agent addressees.
- Rechecked `agent_config:update` authority under the proposal lock
before execution.
- Recorded `executed` or `failed` results and posted a failure comment
when no binding was created.
- Settled failed accepted proposals atomically and mirrored rejection,
withdrawal, and expiry in both directions.
- Emitted `secret.binding.created` for new agent binding writes.
- Added a dedicated issue-thread card for pending, executed, failed,
rejected, withdrawn, and expired states.
- Showed only the source label, target agent, config path, skeptical
justification, expiry, and safe failure code.
- Replaced resolved attention-query entries immediately with the
stitched server result.
- Added focused server, database, UI, and state-transition tests.
- Added Storybook fixtures for every review state and documented the API
and agent behavior.

## Verification

- `pnpm exec vitest run
ui/src/components/IssueThreadInteractionCard.test.tsx
ui/src/components/AttentionInteractionResolver.test.ts` — 58 passed.
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm check:token-gates`
- `pnpm build-storybook`
- `pnpm --filter @paperclipai/shared typecheck`
- `pnpm --filter @paperclipai/db typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/db check:migrations`
- `NODE_ENV=test pnpm exec vitest run
server/src/__tests__/issue-thread-interaction-routes.test.ts
server/src/__tests__/secret-proposals-routes.test.ts
server/src/__tests__/secrets-routes.test.ts
server/src/__tests__/agents-service-secret-bindings.test.ts` — 142
passed.
- `NODE_ENV=test pnpm --filter @paperclipai/db exec vitest run
src/company-secret-proposals-migration.test.ts --silent` — 1 passed.
- `pnpm -r typecheck`
- `pnpm test:run` — server 4,175 passed, UI 4,109 passed; the CLI
AWS-doctor case passes 8/8 with runtime-injected static AWS credential
variables unset.
- `pnpm build`
- `git diff --check origin/master...HEAD`

## Risks

- Migration `0221` adds one nullable foreign key and one index. It uses
idempotent guards.
- The accept route performs a governed write after it records card
acceptance. A failed write is visible and settles the proposal as
rejected.
- Concurrent proposal and card resolution must use
proposal-before-interaction lock order. A race test covers direct
approval against card rejection.
- The new audit event increases activity rows for newly added agent
bindings. It does not include secret values or fingerprints.
- The card includes only safe proposal metadata. It does not include
secret value, fingerprint, version, or internal secret identifiers.
- The UI uses the stitched resolution result. Focused tests cover
immediate cache replacement and every terminal state.

> This work extends an existing completed roadmap capability. The GitHub
duplicate search returned no other open related work.

## Model Used

- OpenAI Codex with model ID `gpt-5`. The runtime did not expose its
context-window size. Reasoning, repository tools, code execution,
database integration tests, UI rendering, and GitHub tools were enabled.

## 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
- [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:
Dotta 2026-08-18 10:44:24 -04:00 committed by GitHub
parent b446ff59bf
commit 6b8e42168e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 42547 additions and 125 deletions

View File

@ -22,6 +22,7 @@ GET /api/agents/me/secrets
"secrets": [
{
"key": "github_token",
"secretRef": "11111111-1111-4111-8111-111111111111",
"name": "GitHub token",
"description": null,
"delivery": "env",
@ -34,9 +35,11 @@ GET /api/agents/me/secrets
}
```
`delivery` is `env`, `api`, or `both`. The list never returns values, secret
IDs, binding IDs, or config paths. An `env.*` binding implies read access through
this API; an `access.*` binding grants API access without environment injection.
`delivery` is `env`, `api`, or `both`. `secretRef` is a stable opaque handle,
not secret material or a capability; every consuming route re-authorizes it.
The list never returns values, the internal `secretId` field, binding IDs, or
config paths. An `env.*` binding implies read access through this API; an
`access.*` binding grants API access without environment injection.
Fetch a value only when it is needed. The request has no body and the response
uses `Cache-Control: no-store`:
@ -60,6 +63,61 @@ successful or failed value fetch is audited in both `secret_access_events` and
`activity_log`; agents must not log or paste fetched values into issues,
comments, or documents.
## Agent Secret Proposals
These routes use the same current run-bound agent JWT as the list and fetch
routes:
```
POST /api/agents/me/secret-proposals
GET /api/agents/me/secret-proposals
DELETE /api/agents/me/secret-proposals/{proposalId}
```
An agent can ask Paperclip to bind an existing secret under a new path without
knowing a secret ID. Set `kind` to `binding` and identify the source by the
agent's own existing `env.*` or `access.*` config path:
```json
POST /api/agents/me/secret-proposals
{
"kind": "binding",
"sourceConfigPath": "access.openai_api_key",
"configPath": "access.evals_openai_api_key",
"justification": "Use the existing OpenAI credential under the eval-specific alias"
}
```
`sourceConfigPath` must resolve from the proposing agent's own binding. An
unknown path or another agent's path returns `404`. A binding request must
provide exactly one of `sourceConfigPath`, `secretId`, or `secretProposalId`.
Omit `targetAgentId` to target the proposing agent; under the default policy a
manager may instead target one of its reports. `configPath` accepts
`env.<KEY>` for environment injection or `access.<ALIAS>` for API-only access.
For a run with a checked-out origin issue, a successful proposal automatically
creates a human-only **Confirm secret binding** card in that issue. API clients
must not create a second interaction. The card contains only non-secret
metadata: the source label, target agent, new config path, justification, and
expiry.
Selecting **Create binding** accepts the card and then triggers a separate,
freshly authorized binding write. Card acceptance is not execution. Read
`result.secretProposal.status` for the actual outcome:
- `executed` means the binding write completed.
- `failed` means the card was accepted but execution failed. The card renders
**FAILED**, exposes a non-secret `errorCode`, and Paperclip posts a **Secret
binding execution failed** comment with `Binding created: no`.
- `rejected`, `withdrawn`, or `expired` means no binding was created.
The card wakes the issue assignee after resolution. The wake payload includes
`secretProposal.configPath`, `decision`, `executionStatus`, and instructions.
After any secret card, call `GET /api/agents/me/secrets` again and confirm the
expected secret metadata and delivery before using the new binding. Acceptance
is not execution; a failed wake or missing metadata means the alias must be
treated as unavailable until a fresh proposal executes successfully.
## List Secrets
```

View File

@ -44,6 +44,6 @@ describeEmbeddedPostgres("company secret proposals migration", () => {
(SELECT count(*)::int FROM pg_constraint WHERE conrelid = 'company_secret_proposals'::regclass AND contype <> 'n') AS constraints,
(SELECT count(*)::int FROM pg_indexes WHERE tablename = 'company_secret_proposals') AS indexes
`;
expect(result).toEqual({ constraints: 13, indexes: 5 });
expect(result).toEqual({ constraints: 14, indexes: 6 });
}, 30_000);
});

View File

@ -0,0 +1,6 @@
ALTER TABLE "company_secret_proposals" ADD COLUMN IF NOT EXISTS "interaction_id" uuid;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "company_secret_proposals" ADD CONSTRAINT "company_secret_proposals_interaction_id_issue_thread_interactions_id_fk" FOREIGN KEY ("interaction_id") REFERENCES "public"."issue_thread_interactions"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "company_secret_proposals_interaction_idx" ON "company_secret_proposals" USING btree ("interaction_id");

File diff suppressed because it is too large Load Diff

View File

@ -1548,6 +1548,13 @@
"when": 1786983999395,
"tag": "0222_orphan_cleanup_env_reference_survives_delete",
"breakpoints": true
},
{
"idx": 223,
"version": "7",
"when": 1787000753489,
"tag": "0223_robust_zaladane",
"breakpoints": true
}
]
}

View File

@ -5,6 +5,7 @@ import { companies } from "./companies.js";
import { companySecrets } from "./company_secrets.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { issues } from "./issues.js";
import { issueThreadInteractions } from "./issue_thread_interactions.js";
export const companySecretProposals = pgTable(
"company_secret_proposals",
@ -32,6 +33,7 @@ export const companySecretProposals = pgTable(
proposedByAgentId: uuid("proposed_by_agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }),
originIssueId: uuid("origin_issue_id").references(() => issues.id, { onDelete: "set null" }),
originRunId: uuid("origin_run_id").notNull().references(() => heartbeatRuns.id, { onDelete: "cascade" }),
interactionId: uuid("interaction_id").references(() => issueThreadInteractions.id, { onDelete: "set null" }),
resolvedByUserId: text("resolved_by_user_id"),
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
resolutionReason: text("resolution_reason"),
@ -47,6 +49,7 @@ export const companySecretProposals = pgTable(
proposerStatusIdx: index("company_secret_proposals_proposer_status_idx").on(table.proposedByAgentId, table.status),
expiryIdx: index("company_secret_proposals_expiry_idx").on(table.status, table.expiresAt),
secretProposalIdx: index("company_secret_proposals_secret_proposal_idx").on(table.secretProposalId),
interactionIdx: index("company_secret_proposals_interaction_idx").on(table.interactionId),
kindCheck: check("company_secret_proposals_kind_check", sql`${table.kind} in ('secret', 'binding')`),
statusCheck: check("company_secret_proposals_status_check", sql`${table.status} in ('pending', 'approved', 'rejected', 'withdrawn', 'expired')`),
projectionCheck: check("company_secret_proposals_projection_check", sql`${table.projectionClass} = 'unclassified'`),

View File

@ -1044,6 +1044,8 @@ export type {
RequestConfirmationResult,
RequestConfirmationToolActionPayload,
RequestConfirmationToolActionResult,
RequestConfirmationSecretProposalPayload,
RequestConfirmationSecretProposalResult,
RequestCheckboxConfirmationOption,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,

View File

@ -687,6 +687,8 @@ export type {
RequestConfirmationResult,
RequestConfirmationToolActionPayload,
RequestConfirmationToolActionResult,
RequestConfirmationSecretProposalPayload,
RequestConfirmationSecretProposalResult,
RequestCheckboxConfirmationOption,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,

View File

@ -1186,6 +1186,17 @@ export interface RequestConfirmationToolActionPayload {
expiresAt: string;
}
export interface RequestConfirmationSecretProposalPayload {
version: 1;
proposalId: string;
sourceSecretLabel: string;
configPath: string;
targetAgentId: string;
targetAgentName: string;
justification: string;
expiresAt: string;
}
/**
* Lifecycle status written back onto the resolved interaction once the operator
* approves. `approve = run`, so the terminal states are executed/failed/expired
@ -1201,6 +1212,13 @@ export interface RequestConfirmationToolActionResult {
updatedAt: string;
}
export interface RequestConfirmationSecretProposalResult {
version: 1;
status: "executed" | "failed" | "rejected" | "withdrawn" | "expired";
errorCode?: string | null;
updatedAt: string;
}
export interface RequestConfirmationPayload {
version: 1;
prompt: string;
@ -1214,6 +1232,7 @@ export interface RequestConfirmationPayload {
supersedeOnUserComment?: boolean;
target?: RequestConfirmationTarget | null;
toolAction?: RequestConfirmationToolActionPayload;
secretProposal?: RequestConfirmationSecretProposalPayload;
}
export interface RequestCheckboxConfirmationOption {
@ -1290,6 +1309,7 @@ export interface RequestConfirmationResult {
updatedAt?: string | null;
} | null;
toolAction?: RequestConfirmationToolActionResult;
secretProposal?: RequestConfirmationSecretProposalResult;
}
export interface RequestCheckboxConfirmationResult extends RequestConfirmationResult {

View File

@ -446,6 +446,8 @@ export {
requestConfirmationPayloadSchema,
requestConfirmationResumeFailureSchema,
requestConfirmationResultSchema,
requestConfirmationSecretProposalPayloadSchema,
requestConfirmationSecretProposalResultSchema,
requestCheckboxConfirmationOptionSchema,
requestCheckboxConfirmationPayloadSchema,
requestCheckboxConfirmationResultSchema,

View File

@ -913,6 +913,17 @@ export const requestConfirmationToolActionPayloadSchema = z.object({
expiresAt: z.string().datetime({ offset: true }),
});
export const requestConfirmationSecretProposalPayloadSchema = z.object({
version: z.literal(1),
proposalId: z.string().uuid(),
sourceSecretLabel: z.string().trim().min(1).max(500),
configPath: z.string().trim().min(1).max(500),
targetAgentId: z.string().uuid(),
targetAgentName: z.string().trim().min(1).max(500),
justification: z.string().trim().min(1).max(20000),
expiresAt: z.string().datetime({ offset: true }),
});
export const requestConfirmationPayloadSchema = z.object({
version: z.literal(1),
prompt: z.string().trim().min(1).max(1000),
@ -926,6 +937,7 @@ export const requestConfirmationPayloadSchema = z.object({
supersedeOnUserComment: z.boolean().optional(),
target: requestConfirmationTargetSchema.nullable().optional(),
toolAction: requestConfirmationToolActionPayloadSchema.optional(),
secretProposal: requestConfirmationSecretProposalPayloadSchema.optional(),
});
export const requestCheckboxConfirmationOptionSchema = z.object({
@ -1052,6 +1064,13 @@ export const requestConfirmationToolActionResultSchema = z.object({
updatedAt: z.string().datetime({ offset: true }),
});
export const requestConfirmationSecretProposalResultSchema = z.object({
version: z.literal(1),
status: z.enum(["executed", "failed", "rejected", "withdrawn", "expired"]),
errorCode: z.string().trim().min(1).max(120).nullable().optional(),
updatedAt: z.string().datetime({ offset: true }),
});
export const requestConfirmationResultSchema = z.object({
version: z.literal(1),
outcome: z.enum([
@ -1070,6 +1089,7 @@ export const requestConfirmationResultSchema = z.object({
staleTarget: requestConfirmationTargetSchema.nullable().optional(),
resumeFailure: requestConfirmationResumeFailureSchema.nullable().optional(),
toolAction: requestConfirmationToolActionResultSchema.optional(),
secretProposal: requestConfirmationSecretProposalResultSchema.optional(),
});
export const requestCheckboxConfirmationResultSchema = requestConfirmationResultSchema.extend({

View File

@ -5,6 +5,7 @@ import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { eq, sql } from "drizzle-orm";
import {
activityLog,
agents,
claudeSetupTokenSessions,
companies,
@ -219,6 +220,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(companySecretBindings);
await db.delete(companySecretVersions);
await db.delete(companySecrets);

View File

@ -5,6 +5,7 @@ import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { and, eq } from "drizzle-orm";
import {
activityLog,
agents,
companies,
companySecretBindings,
@ -44,6 +45,7 @@ describeEmbeddedPostgres("agent service secret binding sync", () => {
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(companySecretBindings);
await db.delete(companySecretVersions);
await db.delete(companySecrets);
@ -113,6 +115,15 @@ describeEmbeddedPostgres("agent service secret binding sync", () => {
versionSelector: "latest",
required: true,
});
expect(await db.select().from(activityLog).where(eq(activityLog.companyId, companyId)))
.toEqual(expect.arrayContaining([
expect.objectContaining({
action: "secret.binding.created",
entityType: "agent",
entityId: created.id,
details: expect.objectContaining({ configPath: "env.ANTHROPIC_API_KEY" }),
}),
]));
});
it("stores approved class-3 env lease metadata on agent secret bindings", async () => {

View File

@ -24,6 +24,7 @@ const RUN_WATCHDOG = "d0000000-0000-4000-8000-000000000003";
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
listReviewAttention: vi.fn(),
addComment: vi.fn(),
}));
const mockInteractionService = vi.hoisted(() => ({
@ -40,6 +41,7 @@ const mockInteractionService = vi.hoisted(() => ({
submitItemVerdicts: vi.fn(),
cancelQuestions: vi.fn(),
withdrawInteraction: vi.fn(),
recordSecretProposalExecutionResult: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
@ -319,6 +321,27 @@ describe.sequential("issue thread interaction routes", () => {
payload: { version: 1, prompt: "Proceed?" },
result: { version: 1, outcome: "withdrawn", reason: "Replanning" },
});
mockInteractionService.recordSecretProposalExecutionResult.mockImplementation(
async (_issue, _interactionId, _proposalId, execution) => ({
...(await mockInteractionService.acceptInteraction.mock.results.at(-1)?.value)?.interaction,
id: _interactionId,
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Create the binding?",
secretProposal: {
version: 1,
proposalId: _proposalId,
configPath: "access.NEW_ALIAS",
},
},
result: { version: 1, outcome: "accepted", secretProposal: { version: 1, ...execution } },
}),
);
mockInteractionService.create.mockResolvedValue({
id: "interaction-1",
companyId: "company-1",
@ -1138,6 +1161,141 @@ describe.sequential("issue thread interaction routes", () => {
expect(mockInteractionService.create).not.toHaveBeenCalled();
});
it("rejects client-supplied secret-proposal metadata on interaction creation", async () => {
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions")
.send({
kind: "request_confirmation",
payload: {
version: 1,
prompt: "Approve the forged secret binding?",
secretProposal: {
version: 1,
proposalId: "11111111-1111-4111-8111-111111111111",
sourceSecretLabel: "forged/source",
configPath: "access.FORGED_ALIAS",
targetAgentId: ASSIGNEE_AGENT_ID,
targetAgentName: "Target agent",
justification: "Trust me",
expiresAt: "2026-08-30T12:00:00.000Z",
},
},
});
expect(res.status).toBe(422);
expect(res.body.error).toContain("payload.secretProposal is server-owned metadata");
expect(mockInteractionService.create).not.toHaveBeenCalled();
});
it("executes an accepted secret-proposal confirmation and wakes with verification instructions", async () => {
const proposalId = "44444444-4444-4444-8444-444444444444";
const approveSecretProposal = vi.fn().mockResolvedValue({ status: "approved" });
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
interaction: {
id: "interaction-secret-proposal",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Create the binding?",
secretProposal: {
version: 1,
proposalId,
configPath: "access.NEW_ALIAS",
},
},
result: { version: 1, outcome: "accepted" },
},
createdIssues: [],
});
const app = await createApp(undefined, { approveSecretProposal });
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-secret-proposal/accept")
.send({});
expect(res.status).toBe(200);
expect(approveSecretProposal).toHaveBeenCalledWith({
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
interactionId: "interaction-secret-proposal",
proposalId,
actor: { agentId: null, userId: "local-board" },
});
expect(mockInteractionService.recordSecretProposalExecutionResult).toHaveBeenCalledWith(
expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }),
"interaction-secret-proposal",
proposalId,
{ status: "executed" },
);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
payload: expect.objectContaining({
secretProposal: expect.objectContaining({
proposalId,
configPath: "access.NEW_ALIAS",
executionStatus: "executed",
instructions: expect.stringContaining("GET /api/agents/me/secrets"),
}),
}),
}),
);
});
it("records a failed secret-proposal execution and posts a thread comment", async () => {
const proposalId = "55555555-5555-4555-8555-555555555555";
const approveSecretProposal = vi.fn().mockRejectedValue(new Error("binding failed"));
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
interaction: {
id: "interaction-secret-proposal-failed",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Create the binding?",
secretProposal: {
version: 1,
proposalId,
configPath: "access.NEW_ALIAS",
},
},
result: { version: 1, outcome: "accepted" },
},
createdIssues: [],
});
const app = await createApp(undefined, { approveSecretProposal });
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-secret-proposal-failed/accept")
.send({});
expect(res.status).toBe(200);
expect(mockInteractionService.recordSecretProposalExecutionResult).toHaveBeenCalledWith(
expect.anything(),
"interaction-secret-proposal-failed",
proposalId,
{ status: "failed", errorCode: "secret_proposal_execution_failed" },
);
expect(mockIssueService.addComment).toHaveBeenCalledWith(
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
expect.stringContaining("Binding created: **no**"),
{ userId: "local-board" },
);
expect(res.body.result.secretProposal).toMatchObject({
status: "failed",
errorCode: "secret_proposal_execution_failed",
});
});
it("forwards plan-document confirmations to the interaction service for revision validation", async () => {
const app = await createApp();

View File

@ -4,7 +4,7 @@ import os from "node:os";
import path from "node:path";
import express from "express";
import request from "supertest";
import { eq, sql } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
@ -18,7 +18,10 @@ import {
createDb,
heartbeatRuns,
issueComments,
issueThreadInteractions,
issues,
userSecretDeclarations,
userSecretDefinitions,
} from "@paperclipai/db";
import { conflict } from "../errors.js";
import { errorHandler } from "../middleware/error-handler.js";
@ -26,6 +29,8 @@ 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 {
@ -55,9 +60,12 @@ describeEmbeddedPostgres("secret proposal routes", () => {
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);
@ -98,6 +106,7 @@ describeEmbeddedPostgres("secret proposal routes", () => {
companyId,
agentId,
status: "running",
responsibleUserId: "user-1",
contextSnapshot: { issueId },
});
await db.insert(issues).values({
@ -106,11 +115,62 @@ describeEmbeddedPostgres("secret proposal routes", () => {
title: "Needs credential",
identifier: "SEC-1",
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",
@ -320,6 +380,32 @@ describeEmbeddedPostgres("secret proposal routes", () => {
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`)
@ -364,9 +450,28 @@ describeEmbeddedPostgres("secret proposal routes", () => {
});
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"]));
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" }),
expect.objectContaining({
issueId: fixture.issueId,
authorUserId: "board-user",
body: expect.stringContaining("GET /api/agents/me/secrets"),
}),
]);
});
@ -418,6 +523,300 @@ describeEmbeddedPostgres("secret proposal routes", () => {
]);
});
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, {
@ -426,6 +825,13 @@ describeEmbeddedPostgres("secret proposal routes", () => {
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) =>
@ -433,7 +839,7 @@ describeEmbeddedPostgres("secret proposal routes", () => {
.post("/api/agents/me/secret-proposals")
.send({
kind: "binding",
secretId: liveSecret.id,
sourceConfigPath: "access.quota_source",
configPath: `env.QUOTA_${index}`,
justification: "Exercise the concurrent proposal cap",
})));
@ -493,6 +899,284 @@ describeEmbeddedPostgres("secret proposal routes", () => {
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))
@ -723,6 +1407,9 @@ describeEmbeddedPostgres("secret proposal routes", () => {
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 () => {

View File

@ -74,6 +74,48 @@ describe("secret routes", () => {
mockLogActivity.mockReset();
});
it("returns an opaque secretRef in agent secret metadata without internal binding details", async () => {
const secretId = "11111111-1111-4111-8111-111111111111";
mockSecretService.listAgentSecretAccess.mockResolvedValue([{
secretId,
bindingId: "22222222-2222-4222-8222-222222222222",
configPath: "env.OPENAI_API_KEY",
key: "openai_api_key",
name: "OpenAI API key",
description: "Used for model access",
delivery: "env",
projectionClass: "unclassified",
latestVersion: 3,
versionSelector: "latest",
resolvedVersion: 3,
}]);
const res = await request(createApp({
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
companyId: "44444444-4444-4444-8444-444444444444",
runId: "55555555-5555-4555-8555-555555555555",
source: "agent_jwt",
keyScope: { kind: "standard" },
})).get("/api/agents/me/secrets");
expect(res.status).toBe(200);
expect(res.body.secrets).toEqual([{
secretRef: secretId,
key: "openai_api_key",
name: "OpenAI API key",
description: "Used for model access",
delivery: "env",
projectionClass: "unclassified",
latestVersion: 3,
versionSelector: "latest",
resolvedVersion: 3,
}]);
expect(res.body.secrets[0]).not.toHaveProperty("secretId");
expect(res.body.secrets[0]).not.toHaveProperty("bindingId");
expect(res.body.secrets[0]).not.toHaveProperty("configPath");
});
it("returns provider health checks for board callers with company access", async () => {
mockSecretService.checkProviders.mockResolvedValue([
{

View File

@ -137,6 +137,7 @@ import {
routineService,
workProductService,
} from "../services/index.js";
import { assertCanResolveProposal } from "../services/secret-proposal-authorization.js";
import { buildDocumentReviewContext, buildPlanReviewContext } from "../services/plan-review-context.js";
import {
decideIssueReviewPathRecovery,
@ -170,6 +171,8 @@ import {
SVG_CONTENT_TYPE,
} from "../attachment-types.js";
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
import { createSecretProposalsService } from "../services/secret-proposals.js";
import { notifySecretProposalResolution } from "../services/secret-proposal-notifications.js";
import {
buildOnboardingGreeting,
ONBOARDING_GREETING_AUTHORIZATION_REASON,
@ -1922,6 +1925,14 @@ function readToolActionExecutionStatus(value: unknown) {
: null;
}
function secretProposalExecutionErrorCode(error: unknown) {
if (error instanceof HttpError) {
const details = readObject(error.details);
return readNonEmptyString(details.code) ?? `http_${error.status}`;
}
return "secret_proposal_execution_failed";
}
function readToolActionContinuationContext(interaction: {
status: string;
payload?: unknown;
@ -1988,6 +1999,54 @@ function readToolActionContinuationContext(interaction: {
};
}
function readSecretProposalContinuationContext(interaction: {
status: string;
payload?: unknown;
result?: unknown;
}) {
const payload = readObject(interaction.payload);
const proposal = readObject(payload.secretProposal);
const proposalId = readNonEmptyString(proposal.proposalId);
const configPath = readNonEmptyString(proposal.configPath);
if (!proposalId || !configPath) return null;
const result = readObject(interaction.result);
const execution = readObject(result.secretProposal);
const executionStatus = readNonEmptyString(execution.status);
const errorCode = readNonEmptyString(execution.errorCode);
const sourceSecretLabel = readNonEmptyString(proposal.sourceSecretLabel);
if (interaction.status === "rejected") {
return {
proposalId,
configPath,
decision: "rejected",
executionStatus: "rejected",
instructions: "the secret binding proposal was rejected; do not assume the alias exists.",
};
}
if (interaction.status !== "accepted" || (executionStatus !== "executed" && executionStatus !== "failed")) {
return null;
}
if (executionStatus === "executed") {
return {
proposalId,
configPath,
decision: "accepted",
executionStatus,
...(sourceSecretLabel ? { sourceSecretLabel } : {}),
instructions: `the binding was created at ${configPath}; verify it with GET /api/agents/me/secrets before using it.`,
};
}
return {
proposalId,
configPath,
decision: "accepted",
executionStatus,
...(errorCode ? { errorCode } : {}),
instructions: "the binding was not created; inspect the failure comment and submit a fresh proposal after fixing the cause.",
};
}
const REQUEST_ITEM_VERDICTS_WAKE_COALESCE_WINDOW_MS = 2_000;
function buildRequestItemVerdictsWakeIdempotencyKey(args: {
@ -2056,6 +2115,7 @@ async function queueResolvedInteractionContinuationWakeup(input: {
const interactionResult = readConfirmationResultForWake(input.interaction.result);
const checkboxSelection = readCheckboxSelectionForWake(input.interaction);
const toolAction = readToolActionContinuationContext(input.interaction);
const secretProposal = readSecretProposalContinuationContext(input.interaction);
const newlyResolvedItemIds = input.newlyResolvedItemIds?.filter((value) => value.length > 0) ?? [];
const itemVerdicts = newlyResolvedItemIds.length > 0
? {
@ -2088,6 +2148,7 @@ async function queueResolvedInteractionContinuationWakeup(input: {
...(planReviewInteraction ? { planReviewInteraction } : {}),
...(checkboxSelection ? { checkboxSelection } : {}),
...(toolAction ? { toolAction } : {}),
...(secretProposal ? { secretProposal } : {}),
...(itemVerdicts ? { itemVerdicts, newlyResolvedItemIds } : {}),
...(reviewPathContext ?? {}),
mutation: "interaction",
@ -2106,6 +2167,7 @@ async function queueResolvedInteractionContinuationWakeup(input: {
...(planReviewInteraction ? { planReviewInteraction } : {}),
...(checkboxSelection ? { checkboxSelection } : {}),
...(toolAction ? { toolAction } : {}),
...(secretProposal ? { secretProposal } : {}),
...(itemVerdicts ? { itemVerdicts, newlyResolvedItemIds } : {}),
...(reviewPathContext ?? {}),
wakeReason: "issue_commented",
@ -2731,12 +2793,20 @@ export function issueRoutes(
actionRequestId: string;
actor: { agentId?: string | null; userId?: string | null };
}) => Promise<unknown>;
approveSecretProposal?: (input: {
companyId: string;
issueId: string;
interactionId: string;
proposalId: string;
actor: { agentId?: string | null; userId?: string | null };
}) => Promise<unknown>;
} = {},
) {
const router = Router();
const svc = issueService(db);
const runRedactions = createRunSecretRedactionRegistry(db);
const access = accessService(db);
const secretProposals = createSecretProposalsService(db);
const heartbeat = heartbeatService(db, {
pluginWorkerManager: opts.pluginWorkerManager,
});
@ -3459,8 +3529,10 @@ export function issueRoutes(
interaction.kind === "request_confirmation"
&& interaction.payload
&& typeof interaction.payload === "object"
&& "toolAction" in interaction.payload
&& interaction.payload.toolAction !== undefined
&& (
("toolAction" in interaction.payload && interaction.payload.toolAction !== undefined)
|| ("secretProposal" in interaction.payload && interaction.payload.secretProposal !== undefined)
)
)
);
if (!designatedReviewConfirmation) {
@ -4263,7 +4335,7 @@ export function issueRoutes(
await assertPendingReviewInteractionVerdictAllowed(req, issue, interaction);
}
const payload = interaction.payload && typeof interaction.payload === "object"
? interaction.payload as { toolAction?: unknown }
? interaction.payload as { toolAction?: unknown; secretProposal?: unknown }
: null;
const actor = getActorInfo(req);
const decision: IssueThreadInteractionResolverAudienceDecision =
@ -4273,7 +4345,9 @@ export function issueRoutes(
: { type: "user", userId: actor.actorId },
interaction,
additionalRestriction: resolverPolicyRestriction,
governedAction: interaction.kind === "request_confirmation" && payload?.toolAction !== undefined,
governedAction:
interaction.kind === "request_confirmation"
&& (payload?.toolAction !== undefined || payload?.secretProposal !== undefined),
});
if (!decision.allowed) {
return denyIssueThreadInteractionResolution(res, {
@ -4286,7 +4360,6 @@ export function issueRoutes(
},
});
}
// Resolving an interaction on another run's issue is a cross-issue mutation
// like a comment or a PATCH, so it consumes the same per-run budget (§9.3,
// §9.8.1). This runs last: company/resource access, run attribution,
@ -10800,6 +10873,9 @@ export function issueRoutes(
if (req.body.kind === "request_confirmation" && req.body.payload?.toolAction !== undefined) {
throw unprocessable("payload.toolAction is server-owned metadata and cannot be supplied when creating an interaction");
}
if (req.body.kind === "request_confirmation" && req.body.payload?.secretProposal !== undefined) {
throw unprocessable("payload.secretProposal is server-owned metadata and cannot be supplied when creating an interaction");
}
// Plan-document confirmation targets are validated authoritatively inside
// issueThreadInteractionService.create, which re-reads the plan document's
@ -10924,6 +11000,9 @@ export function issueRoutes(
const toolAction = interaction.payload && typeof interaction.payload === "object"
? (interaction.payload as { toolAction?: { actionRequestId?: unknown } }).toolAction
: null;
const secretProposal = interaction.payload && typeof interaction.payload === "object"
? (interaction.payload as { secretProposal?: { proposalId?: unknown; configPath?: unknown } }).secretProposal
: null;
let continuationInteraction = interaction;
if (
interaction.kind === "request_confirmation"
@ -10960,6 +11039,81 @@ export function issueRoutes(
};
}
}
if (
interaction.kind === "request_confirmation"
&& interaction.status === "accepted"
&& typeof secretProposal?.proposalId === "string"
) {
const resolvedByUserId = actor.actorType === "user" ? actor.actorId : "board";
try {
if (opts.approveSecretProposal) {
await opts.approveSecretProposal({
companyId: issue.companyId,
issueId: issue.id,
interactionId: interaction.id,
proposalId: secretProposal.proposalId,
actor: { agentId: actor.agentId, userId: actor.actorType === "user" ? actor.actorId : null },
});
} else {
const proposal = await secretProposals.getById(issue.companyId, secretProposal.proposalId);
if (
!proposal
|| proposal.kind !== "binding"
|| proposal.originIssueId !== issue.id
|| proposal.interactionId !== interaction.id
) {
throw notFound("Secret proposal not found");
}
await secretProposals.approve(issue.companyId, proposal.id, {
resolvedByUserId,
assertCanResolve: (lockedProposal, txDb) => assertCanResolveProposal({
db: txDb,
actor: req.actor,
companyId: issue.companyId,
proposal: lockedProposal,
}),
});
await notifySecretProposalResolution({
proposal,
status: "approved",
userId: resolvedByUserId,
issues: svc,
heartbeat,
});
}
continuationInteraction = await interactionSvc.recordSecretProposalExecutionResult(
issue,
interaction.id,
secretProposal.proposalId,
{ status: "executed" },
);
} catch (error) {
const errorCode = secretProposalExecutionErrorCode(error);
continuationInteraction = await interactionSvc.recordSecretProposalExecutionResult(
issue,
interaction.id,
secretProposal.proposalId,
{ status: "failed", errorCode },
);
const recordedResult = readObject(continuationInteraction.result);
const recordedSecretProposal = readObject(recordedResult.secretProposal);
if (recordedSecretProposal.status !== "executed") {
const configPath = typeof secretProposal.configPath === "string" ? secretProposal.configPath : "unknown";
try {
await svc.addComment(
issue.id,
`Secret binding execution failed\n\n- Config path: \`${configPath}\`\n- Error code: \`${errorCode}\`\n- Binding created: **no**`,
{ userId: resolvedByUserId },
);
} catch (commentError) {
logger.warn(
{ err: commentError, issueId: issue.id, interactionId: interaction.id, errorCode },
"failed to post secret proposal execution failure comment",
);
}
}
}
}
const continuationWakeIssue = continuationIssue ?? issue;
await logActivity(db, {

View File

@ -1698,6 +1698,7 @@ registry.registerPath({
const AgentSecretListResponseSchema = z.object({
secrets: z.array(z.object({
secretRef: z.string().uuid(),
key: z.string(),
name: z.string(),
description: z.string().nullable(),
@ -1720,12 +1721,24 @@ const createAgentSecretProposalSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("binding"),
secretId: z.string().uuid().optional(),
sourceConfigPath: z.string().min(1).optional(),
secretProposalId: z.string().uuid().optional(),
targetAgentId: z.string().uuid().optional(),
configPath: z.string().min(1),
justification: z.string().min(1),
}),
]);
]).superRefine((value, ctx) => {
if (
value.kind === "binding"
&& [value.secretId, value.sourceConfigPath, value.secretProposalId]
.filter((reference) => Boolean(reference)).length !== 1
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Provide exactly one of secretId, sourceConfigPath, or secretProposalId",
});
}
});
const approveSecretProposalSchema = z.object({
cascade: z.boolean().optional(),

View File

@ -25,12 +25,10 @@ import { authorizationDeniedDetails } from "../services/authorization.js";
import { accessService } from "../services/access.js";
import { heartbeatService } from "../services/heartbeat.js";
import { issueService } from "../services/issues.js";
import {
queueIssueAssignmentWakeup,
type IssueAssignmentWakeupDeps,
} from "../services/issue-assignment-wakeup.js";
import type { IssueAssignmentWakeupDeps } from "../services/issue-assignment-wakeup.js";
import { createRunSecretRedactionRegistry } from "../services/run-secret-redaction.js";
import { logger } from "../middleware/logger.js";
import { notifySecretProposalResolution } from "../services/secret-proposal-notifications.js";
import { assertCanResolveProposal } from "../services/secret-proposal-authorization.js";
type SecretRoutesDeps = {
heartbeat?: IssueAssignmentWakeupDeps;
@ -168,20 +166,6 @@ export function secretRoutes(db: Db, deps: SecretRoutesDeps = {}) {
});
}
async function assertCanResolveProposal(req: Parameters<typeof assertBoard>[0], proposal: {
kind: string;
targetId: string | null;
}) {
if (proposal.kind === "secret") {
assertSecretDefinitionAdmin(req, req.params.companyId as string);
return;
}
const decision = await bindingApprovalDecision(req, proposal);
if (decision && !decision.allowed) {
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
}
}
async function boardProposalView(req: Parameters<typeof assertBoard>[0], proposal: Awaited<ReturnType<typeof proposals.listForBoard>>[number]) {
if (proposal.status !== "pending") {
return { ...proposal, viewerCanApprove: false, approveBlockReason: "Proposal is no longer pending" };
@ -197,49 +181,6 @@ export function secretRoutes(db: Db, deps: SecretRoutesDeps = {}) {
};
}
async function notifyProposalResolution(input: {
proposal: { originIssueId: string | null; kind: string; proposedName: string | null; configPath: string | null };
status: "approved" | "rejected";
userId: string;
reason?: string | null;
}) {
if (!input.proposal.originIssueId) return;
try {
const issue = await issues.getById(input.proposal.originIssueId);
if (!issue) return;
const subject = input.proposal.kind === "secret"
? `secret proposal \`${input.proposal.proposedName ?? "unnamed"}\``
: `binding proposal \`${input.proposal.configPath ?? "unknown"}\``;
const reason = input.reason ? `\n\nReason: ${input.reason}` : "";
try {
await issues.addComment(
issue.id,
`Secret proposal resolution\n\n- Proposal: ${subject}\n- Status: **${input.status}**${reason}`,
{ userId: input.userId },
);
} catch (err) {
logger.warn(
{ err, issueId: issue.id, proposalStatus: input.status },
"failed to post secret proposal resolution comment",
);
}
await queueIssueAssignmentWakeup({
heartbeat,
issue,
reason: "secret_proposal_resolved",
mutation: `secret_proposal_${input.status}`,
contextSource: "secret.proposal.resolution",
requestedByActorType: "user",
requestedByActorId: input.userId,
});
} catch (err) {
logger.warn(
{ err, issueId: input.proposal.originIssueId, proposalStatus: input.status },
"failed to notify origin issue about secret proposal resolution",
);
}
}
router.post("/agents/me/secret-proposals", async (req, res) => {
const context = await proposalAgentContext(req);
const body = req.body ?? {};
@ -257,7 +198,8 @@ export function secretRoutes(db: Db, deps: SecretRoutesDeps = {}) {
})
: body.kind === "binding"
? await proposals.createBinding({ companyId: context.companyId, heartbeatRunId: context.heartbeatRunId }, {
secretId: body.secretId, secretProposalId: body.secretProposalId, targetAgentId: body.targetAgentId,
secretId: body.secretId, sourceConfigPath: body.sourceConfigPath,
secretProposalId: body.secretProposalId, targetAgentId: body.targetAgentId,
configPath: body.configPath, justification: body.justification, bindingTargetPolicy: "self_and_reports",
})
: (() => { throw unprocessable("kind must be secret or binding"); })();
@ -305,14 +247,27 @@ export function secretRoutes(db: Db, deps: SecretRoutesDeps = {}) {
assertCompanySecretWrite(req, companyId);
const proposal = await proposals.getById(companyId, req.params.id as string);
if (!proposal) throw notFound("Secret proposal not found");
await assertCanResolveProposal(req, proposal);
await assertCanResolveProposal({
db,
actor: req.actor,
companyId,
proposal,
assertSecretDefinitionAdmin: () => assertSecretDefinitionAdmin(req, companyId),
});
const resolvedByUserId = req.actor.userId ?? "board";
const approved = await proposals.approve(companyId, proposal.id, {
resolvedByUserId,
cascade: req.body?.cascade === true,
overrides: req.body?.overrides,
assertCanResolve: (lockedProposal, txDb) => assertCanResolveProposal({
db: txDb,
actor: req.actor,
companyId,
proposal: lockedProposal,
assertSecretDefinitionAdmin: () => assertSecretDefinitionAdmin(req, companyId),
}),
});
await notifyProposalResolution({ proposal, status: "approved", userId: resolvedByUserId });
await notifySecretProposalResolution({ proposal, status: "approved", userId: resolvedByUserId, issues, heartbeat });
res.json(await boardProposalView(req, await proposals.view(approved)));
});
@ -324,12 +279,25 @@ export function secretRoutes(db: Db, deps: SecretRoutesDeps = {}) {
if (!reason) throw unprocessable("Rejection reason is required");
const existing = await proposals.getById(companyId, req.params.id as string);
if (!existing) throw notFound("Secret proposal not found");
await assertCanResolveProposal(req, existing);
await assertCanResolveProposal({
db,
actor: req.actor,
companyId,
proposal: existing,
assertSecretDefinitionAdmin: () => assertSecretDefinitionAdmin(req, companyId),
});
const resolvedByUserId = req.actor.userId ?? "board";
const proposal = await proposals.transition(companyId, req.params.id as string, "rejected", {
resolvedByUserId, reason,
});
await notifyProposalResolution({ proposal: existing, status: "rejected", userId: resolvedByUserId, reason });
await notifySecretProposalResolution({
proposal: existing,
status: "rejected",
userId: resolvedByUserId,
reason,
issues,
heartbeat,
});
res.json(await boardProposalView(req, await proposals.view(proposal)));
});
@ -348,7 +316,10 @@ export function secretRoutes(db: Db, deps: SecretRoutesDeps = {}) {
details: { count: secrets.length },
});
res.json({
secrets: secrets.map(({ secretId: _secretId, bindingId: _bindingId, configPath: _configPath, ...secret }) => secret),
secrets: secrets.map(({ secretId, bindingId: _bindingId, configPath: _configPath, ...secret }) => ({
...secret,
secretRef: secretId,
})),
});
});

View File

@ -41,7 +41,7 @@ function asRecord(value: unknown): Record<string, unknown> | null {
return value as Record<string, unknown>;
}
function collectSecretRefs(adapterConfig: unknown): Array<{
export function collectSecretRefs(adapterConfig: unknown): Array<{
secretId: string;
configPath: string;
versionSelector?: SecretVersionSelector;
@ -91,7 +91,7 @@ function collectSecretRefs(adapterConfig: unknown): Array<{
return refs;
}
function collectUserSecretRefs(adapterConfig: unknown): Array<{
export function collectUserSecretRefs(adapterConfig: unknown): Array<{
definitionKey: string;
configPath: string;
envKey: string;

View File

@ -26,7 +26,12 @@ import {
type AgentApiKeyScope,
} from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import { syncAgentAdapterEnvBindings } from "./agent-secret-bindings.js";
import {
collectSecretRefs,
collectUserSecretRefs,
syncAgentAdapterEnvBindings,
} from "./agent-secret-bindings.js";
import { logActivity } from "./activity-log.js";
import { normalizeAgentPermissions } from "./agent-permissions.js";
import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js";
import {
@ -466,6 +471,8 @@ export function agentService(db: Db) {
async function syncAgentSecretBindings(
agent: { id: string; companyId: string; adapterConfig: unknown },
dbClient: Db = db,
previousAdapterConfig: unknown = null,
actor: RevisionMetadata = {},
) {
const scopedSecretsSvc = dbClient === db ? secretsSvc : secretService(dbClient);
await syncAgentAdapterEnvBindings({
@ -474,6 +481,47 @@ export function agentService(db: Db) {
agentId: agent.id,
adapterConfig: agent.adapterConfig,
});
const previousRefs = new Set([
...collectSecretRefs(previousAdapterConfig).map((ref) => `secret:${ref.secretId}:${ref.configPath}`),
...collectUserSecretRefs(previousAdapterConfig).map((ref) => `user:${ref.definitionKey}:${ref.configPath}`),
]);
const createdRefs = [
...collectSecretRefs(agent.adapterConfig).map((ref) => ({
key: `secret:${ref.secretId}:${ref.configPath}`,
configPath: ref.configPath,
bindingType: "secret_ref",
secretId: ref.secretId,
definitionKey: null,
})),
...collectUserSecretRefs(agent.adapterConfig).map((ref) => ({
key: `user:${ref.definitionKey}:${ref.configPath}`,
configPath: ref.configPath,
bindingType: "user_secret_ref",
secretId: null,
definitionKey: ref.definitionKey,
})),
].filter((ref) => !previousRefs.has(ref.key));
const actorType = actor.createdByUserId ? "user" as const : actor.createdByAgentId ? "agent" as const : "system" as const;
const actorId = actor.createdByUserId ?? actor.createdByAgentId ?? "system";
for (const ref of createdRefs) {
await logActivity(dbClient, {
companyId: agent.companyId,
actorType,
actorId,
agentId: actor.createdByAgentId ?? null,
action: "secret.binding.created",
entityType: "agent",
entityId: agent.id,
details: {
targetType: "agent",
targetId: agent.id,
configPath: ref.configPath,
bindingType: ref.bindingType,
secretId: ref.secretId,
definitionKey: ref.definitionKey,
},
});
}
}
/**
@ -667,7 +715,12 @@ export function agentService(db: Db) {
claudeLogin: options?.claudeLogin,
});
}
await syncAgentSecretBindings(updated, txDb);
await syncAgentSecretBindings(
updated,
txDb,
existing.adapterConfig,
options?.recordRevision,
);
}
const normalizedUpdated = await agentService(txDb).getById(updated.id);
@ -972,7 +1025,7 @@ export function agentService(db: Db) {
environmentId: null,
});
}
await syncAgentSecretBindings(updated, txDb);
await syncAgentSecretBindings(updated, txDb, existing.adapterConfig);
const agent = await agentService(txDb).getById(updated.id);
if (!agent) {
throw notFound("Agent not found");

View File

@ -3,6 +3,7 @@ import { and, asc, desc, eq, inArray, isNotNull, isNull, ne } from "drizzle-orm"
import type { Db } from "@paperclipai/db";
import {
agents,
companySecretProposals,
companies,
documents,
heartbeatRuns,
@ -192,7 +193,7 @@ export function getMergeConfirmationPullRequestReferences(
const payload = row.payload && typeof row.payload === "object" && !Array.isArray(row.payload)
? row.payload as unknown as Record<string, unknown>
: null;
if (!payload || payload.toolAction !== undefined) return [];
if (!payload || payload.toolAction !== undefined || payload.secretProposal !== undefined) return [];
const target = payload.target && typeof payload.target === "object" && !Array.isArray(payload.target)
? payload.target as Record<string, unknown>
@ -264,6 +265,7 @@ export function resolveInteractionPolicy(args: {
requested?: IssueThreadInteractionResolverPolicy;
governance: InteractionResolverGovernance;
hasToolAction: boolean;
hasSecretProposal?: boolean;
}) {
const kindGovernance = args.governance[args.kind];
const requestedPolicyInput = args.requested
@ -275,7 +277,7 @@ export function resolveInteractionPolicy(args: {
let effectiveResolverPolicy = requestedResolverPolicy;
let effectiveResolverPolicySource: IssueThreadInteractionEffectiveResolverPolicySource = "requested";
if (args.hasToolAction) {
if (args.hasToolAction || args.hasSecretProposal) {
effectiveResolverPolicy = "human_only";
effectiveResolverPolicySource = "governed_action";
} else if (kindGovernance?.cap) {
@ -313,8 +315,10 @@ function assertInteractionResolutionAllowed(current: IssueThreadInteractionRow,
current.kind === "request_confirmation"
&& current.payload !== null
&& typeof current.payload === "object"
&& "toolAction" in current.payload
&& current.payload.toolAction !== undefined,
&& (
("toolAction" in current.payload && current.payload.toolAction !== undefined)
|| ("secretProposal" in current.payload && current.payload.secretProposal !== undefined)
),
});
}
@ -734,7 +738,20 @@ function buildAdministrativeOutcomeResult(
items: interaction.result?.items ?? [],
} satisfies RequestItemVerdictsResult;
}
return { version: 1, outcome, reason } as const;
return {
version: 1,
outcome,
reason,
...(linkedSecretProposalId(row)
? {
secretProposal: {
version: 1,
status: outcome === "withdrawn" ? "withdrawn" : "expired",
updatedAt: new Date().toISOString(),
},
}
: {}),
} as const;
}
// Rollback sentinel: the interaction was resolved by another actor between the
@ -777,6 +794,85 @@ async function resolveLinkedToolActionRequests(
));
}
function linkedSecretProposalId(interaction: Pick<IssueThreadInteractionRow, "kind" | "payload">) {
if (interaction.kind !== "request_confirmation") return null;
const payload = interaction.payload && typeof interaction.payload === "object" && !Array.isArray(interaction.payload)
? interaction.payload as unknown as Record<string, unknown>
: null;
const secretProposal = payload?.secretProposal && typeof payload.secretProposal === "object" && !Array.isArray(payload.secretProposal)
? payload.secretProposal as Record<string, unknown>
: null;
return typeof secretProposal?.proposalId === "string" ? secretProposal.proposalId : null;
}
async function lockLinkedSecretProposal(
db: Db,
interaction: Pick<IssueThreadInteractionRow, "id" | "companyId" | "kind" | "payload">,
) {
const proposalId = linkedSecretProposalId(interaction);
if (!proposalId) return;
await db
.select({ id: companySecretProposals.id })
.from(companySecretProposals)
.where(and(
eq(companySecretProposals.id, proposalId),
eq(companySecretProposals.companyId, interaction.companyId),
eq(companySecretProposals.interactionId, interaction.id),
))
.for("update");
}
async function resolveLinkedSecretProposal(
db: Db,
interaction: Pick<IssueThreadInteractionRow, "id" | "companyId" | "kind" | "payload">,
outcome: {
status: "rejected" | "withdrawn" | "expired";
actor: InteractionActor;
reason?: string | null;
now: Date;
},
) {
const proposalId = linkedSecretProposalId(interaction);
if (!proposalId) return;
const [proposal] = await db
.update(companySecretProposals)
.set({
status: outcome.status,
resolvedByUserId: outcome.actor.userId ?? null,
resolvedAt: outcome.now,
resolutionReason: outcome.reason ?? null,
valueCiphertext: null,
ciphertextScrubbedAt: outcome.now,
updatedAt: outcome.now,
})
.where(and(
eq(companySecretProposals.id, proposalId),
eq(companySecretProposals.companyId, interaction.companyId),
eq(companySecretProposals.interactionId, interaction.id),
eq(companySecretProposals.status, "pending"),
))
.returning();
if (!proposal) throw conflict("Linked secret proposal is no longer pending");
const actorType = outcome.actor.userId ? "user" as const : outcome.actor.agentId ? "agent" as const : "system" as const;
const actorId = outcome.actor.userId ?? outcome.actor.agentId ?? outcome.actor.systemId ?? "system";
await logActivity(db, {
companyId: interaction.companyId,
actorType,
actorId,
action: `secret.proposal.${outcome.status}`,
entityType: "company_secret_proposal",
entityId: proposal.id,
agentId: proposal.proposedByAgentId,
runId: proposal.originRunId,
details: {
ciphertextScrubbed: true,
issueId: proposal.originIssueId,
interactionId: interaction.id,
reason: outcome.reason ?? null,
},
});
}
function resolveActorKind(interaction: Pick<IssueThreadInteraction, "resolvedByAgentId" | "resolvedByUserId">) {
if (interaction.resolvedByAgentId) return "agent";
if (interaction.resolvedByUserId) return "user";
@ -1484,9 +1580,11 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
const now = new Date();
const result = await db.transaction(async (tx) => {
// Lock the issue before claiming the interaction. Policy mutations and
// review transitions use the same issue-row lock, so the authoritative
// review policy and requester are stable through the verdict write.
// Policy mutations and review transitions use the same issue-row lock,
// so the authoritative review policy and requester are stable through
// the verdict write. Terminal issue transitions also lock the issue
// before expiring linked proposals, so keep issue -> proposal ->
// interaction as the shared lifecycle order.
const issueContext = await tx
.select({
id: issues.id,
@ -1507,6 +1605,8 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
throw notFound("Issue not found");
}
await lockLinkedSecretProposal(tx as unknown as Db, args.current);
const lockedCurrent = await tx
.select()
.from(issueThreadInteractions)
@ -1668,6 +1768,11 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
throw notFound("Issue not found");
}
// Terminal issue transitions expire linked proposals while holding this
// issue row. Match their issue -> proposal -> interaction order so a
// close/cancel race cannot invert the first two locks.
await lockLinkedSecretProposal(tx as unknown as Db, args.current);
const lockedCurrent = await tx
.select()
.from(issueThreadInteractions)
@ -1695,6 +1800,13 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
args.actor,
);
await resolveLinkedSecretProposal(tx as unknown as Db, lockedCurrent, {
status: "rejected",
actor: args.actor,
reason: reason || null,
now,
});
const [resolved] = await tx
.update(issueThreadInteractions)
.set({
@ -1703,6 +1815,9 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
version: 1,
outcome: "rejected",
reason: reason || null,
...(linkedSecretProposalId(lockedCurrent)
? { secretProposal: { version: 1, status: "rejected", updatedAt: now.toISOString() } }
: {}),
},
resolvedByAgentId: args.actor.agentId ?? null,
resolvedByRunId: args.actor.runId ?? null,
@ -1899,6 +2014,126 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
return row ? hydrateInteraction(row) : null;
},
recordSecretProposalExecutionResult: async (
issue: { id: string; companyId: string },
interactionId: string,
proposalId: string,
execution: { status: "executed" | "failed"; errorCode?: string | null },
) => {
const updated = await db.transaction(async (tx) => {
// Verdict and terminal-transition paths lock issue -> proposal ->
// interaction. Take the same order before recording the receipt so a
// concurrent rejection or issue close cannot deadlock here.
const lockedIssue = await tx
.select({ id: issues.id })
.from(issues)
.where(and(
eq(issues.id, issue.id),
eq(issues.companyId, issue.companyId),
))
.for("update")
.then((rows) => rows[0] ?? null);
if (!lockedIssue) throw notFound("Issue not found");
const proposal = await tx
.select()
.from(companySecretProposals)
.where(and(
eq(companySecretProposals.id, proposalId),
eq(companySecretProposals.companyId, issue.companyId),
))
.for("update")
.then((rows) => rows[0] ?? null);
const current = await tx
.select()
.from(issueThreadInteractions)
.where(eq(issueThreadInteractions.id, interactionId))
.for("update")
.then((rows) => rows[0] ?? null);
if (!current || current.companyId !== issue.companyId || current.issueId !== issue.id) {
throw notFound("Interaction not found");
}
if (
!proposal
|| proposal.interactionId !== interactionId
|| current.status !== "accepted"
|| linkedSecretProposalId(current) !== proposalId
) {
throw conflict("Secret proposal interaction is not awaiting an execution result");
}
const now = new Date();
const payload = current.payload && typeof current.payload === "object" && !Array.isArray(current.payload)
? current.payload as unknown as Record<string, unknown>
: {};
const secretProposalPayload = payload.secretProposal && typeof payload.secretProposal === "object"
&& !Array.isArray(payload.secretProposal)
? payload.secretProposal as Record<string, unknown>
: {};
const proposalAlreadyExecuted = proposal.status === "approved"
&& proposal.appliedBindingConfigPath === secretProposalPayload.configPath;
const executionStatus = proposalAlreadyExecuted ? "executed" : execution.status;
if (executionStatus === "failed" && proposal.status === "pending") {
const resolutionReason = `Interaction acceptance failed: ${execution.errorCode ?? "secret_proposal_execution_failed"}`;
await tx
.update(companySecretProposals)
.set({
status: "rejected",
resolvedByUserId: current.resolvedByUserId ?? null,
resolvedAt: now,
resolutionReason,
valueCiphertext: null,
ciphertextScrubbedAt: now,
updatedAt: now,
})
.where(and(
eq(companySecretProposals.id, proposal.id),
eq(companySecretProposals.status, "pending"),
));
await logActivity(tx as unknown as Db, {
companyId: issue.companyId,
actorType: current.resolvedByUserId ? "user" : "system",
actorId: current.resolvedByUserId ?? "system",
action: "secret.proposal.rejected",
entityType: "company_secret_proposal",
entityId: proposal.id,
agentId: proposal.proposedByAgentId,
runId: proposal.originRunId,
details: {
ciphertextScrubbed: true,
issueId: proposal.originIssueId,
interactionId: current.id,
reason: resolutionReason,
executionFailed: true,
},
});
}
const result = current.result && typeof current.result === "object" && !Array.isArray(current.result)
? current.result as unknown as Record<string, unknown>
: {};
const [row] = await tx
.update(issueThreadInteractions)
.set({
result: {
...result,
version: 1,
outcome: "accepted",
secretProposal: {
version: 1,
status: executionStatus,
errorCode: executionStatus === "failed" ? execution.errorCode ?? null : null,
updatedAt: now.toISOString(),
},
},
updatedAt: now,
})
.where(eq(issueThreadInteractions.id, current.id))
.returning();
await touchIssue(tx, issue.id);
return row;
});
return hydrateInteraction(updated);
},
cancelPendingForDeletedAddressee: async (companyId: string, addresseeAgentId: string) => {
const rows = await db
.select()
@ -2038,6 +2273,7 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
requested: data.resolverPolicy,
governance,
hasToolAction: data.kind === "request_confirmation" && data.payload.toolAction !== undefined,
hasSecretProposal: data.kind === "request_confirmation" && data.payload.secretProposal !== undefined,
});
const normalizedData = { ...data, resolverPolicy: policy.requestedResolverPolicy };
@ -2048,6 +2284,9 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
if (normalizedData.kind === "request_confirmation" && normalizedData.payload.toolAction !== undefined) {
throw unprocessable("Tool-action confirmations cannot be addressed to agents");
}
if (normalizedData.kind === "request_confirmation" && normalizedData.payload.secretProposal !== undefined) {
throw unprocessable("Secret-proposal confirmations cannot be addressed to agents");
}
const addressee = await db
.select({
id: agents.id,
@ -2180,7 +2419,10 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
// result shape. Scoped strictly to the same agent + issue + kind, so
// other agents' or other kinds' pending cards are untouched.
const canSupersedeSiblingCards =
data.kind === "request_confirmation" || data.kind === "ask_user_questions";
(data.kind === "request_confirmation"
&& data.payload.toolAction === undefined
&& data.payload.secretProposal === undefined)
|| data.kind === "ask_user_questions";
if (!actor.agentId || !canSupersedeSiblingCards) {
return { row, supersededRows: [] };
}
@ -2949,6 +3191,12 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
actor,
now,
});
await resolveLinkedSecretProposal(tx as unknown as Db, row, {
status: "expired",
actor,
reason: "Issue closed before the secret proposal was resolved",
now,
});
const [resolved] = await tx
.update(issueThreadInteractions)
.set({
@ -3014,6 +3262,12 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
actor,
now,
});
await resolveLinkedSecretProposal(tx as unknown as Db, current, {
status: "withdrawn",
actor,
reason,
now,
});
if (current.kind === "request_confirmation") {
const active = await tx
.select({ id: toolActionRequests.id })

View File

@ -0,0 +1,41 @@
import type { Db } from "@paperclipai/db";
import { forbidden, unprocessable } from "../errors.js";
import { accessService } from "./access.js";
import { authorizationDeniedDetails, type AuthorizationActor } from "./authorization.js";
type ResolvableSecretProposal = {
kind: string;
targetId: string | null;
};
export async function assertCanResolveProposal(input: {
db: Db;
actor: AuthorizationActor;
companyId: string;
proposal: ResolvableSecretProposal;
assertSecretDefinitionAdmin?: () => void;
}) {
if (input.proposal.kind === "secret") {
if (!input.assertSecretDefinitionAdmin) {
throw forbidden("Company admin access required");
}
input.assertSecretDefinitionAdmin();
return;
}
if (input.proposal.kind !== "binding" || !input.proposal.targetId) {
throw unprocessable("Binding proposal target is missing");
}
const decision = await accessService(input.db).decide({
actor: input.actor,
action: "agent_config:update",
resource: {
type: "agent",
companyId: input.companyId,
agentId: input.proposal.targetId,
},
scope: { requiresChangeGrant: true },
});
if (!decision.allowed) {
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
}
}

View File

@ -0,0 +1,61 @@
import { logger } from "../middleware/logger.js";
import type { issueService } from "./issues.js";
import {
queueIssueAssignmentWakeup,
type IssueAssignmentWakeupDeps,
} from "./issue-assignment-wakeup.js";
type ProposalResolutionNotification = {
originIssueId: string | null;
kind: string;
proposedName: string | null;
configPath: string | null;
};
export async function notifySecretProposalResolution(input: {
proposal: ProposalResolutionNotification;
status: "approved" | "rejected";
userId: string;
reason?: string | null;
issues: Pick<ReturnType<typeof issueService>, "getById" | "addComment">;
heartbeat: IssueAssignmentWakeupDeps;
}) {
if (!input.proposal.originIssueId) return;
try {
const issue = await input.issues.getById(input.proposal.originIssueId);
if (!issue) return;
const subject = input.proposal.kind === "secret"
? `secret proposal \`${input.proposal.proposedName ?? "unnamed"}\``
: `binding proposal \`${input.proposal.configPath ?? "unknown"}\``;
const configPath = input.proposal.kind === "binding"
? `\n- New config path: \`${input.proposal.configPath ?? "unknown"}\`\n- Verify: \`GET /api/agents/me/secrets\``
: "";
const reason = input.reason ? `\n\nReason: ${input.reason}` : "";
try {
await input.issues.addComment(
issue.id,
`Secret proposal resolution\n\n- Proposal: ${subject}\n- Status: **${input.status}**${configPath}${reason}`,
{ userId: input.userId },
);
} catch (err) {
logger.warn(
{ err, issueId: issue.id, proposalStatus: input.status },
"failed to post secret proposal resolution comment",
);
}
await queueIssueAssignmentWakeup({
heartbeat: input.heartbeat,
issue,
reason: "secret_proposal_resolved",
mutation: `secret_proposal_${input.status}`,
contextSource: "secret.proposal.resolution",
requestedByActorType: "user",
requestedByActorId: input.userId,
});
} catch (err) {
logger.warn(
{ err, issueId: input.proposal.originIssueId, proposalStatus: input.status },
"failed to notify origin issue about secret proposal resolution",
);
}
}

View File

@ -1,8 +1,18 @@
import { and, count, desc, eq, gte, lte, or, sql } from "drizzle-orm";
import { and, count, desc, eq, gte, inArray, lte, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agents, companySecretProposals, companySecrets, heartbeatRuns, issues } from "@paperclipai/db";
import {
agents,
companySecretBindings,
companySecretProposals,
companySecrets,
heartbeatRuns,
issueThreadInteractions,
issues,
userSecretDeclarations,
userSecretDefinitions,
} from "@paperclipai/db";
import type { SecretProvider } from "@paperclipai/shared";
import { conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js";
import { badRequest, conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js";
import { getSecretProvider } from "../secrets/provider-registry.js";
import { agentService } from "./agents.js";
import { logActivity } from "./activity-log.js";
@ -158,6 +168,149 @@ export function createSecretProposalsService(db: Db) {
});
}
async function createBindingInteraction(
txDb: Db,
proposal: Proposal,
sourceSecretLabel: string,
) {
if (!proposal.originIssueId || !proposal.targetId || !proposal.configPath) return proposal;
const target = await txDb
.select({ name: agents.name })
.from(agents)
.where(and(eq(agents.id, proposal.targetId), eq(agents.companyId, proposal.companyId)))
.then((rows) => rows[0] ?? null);
if (!target) throw notFound("Target agent not found");
const [interaction] = await txDb
.insert(issueThreadInteractions)
.values({
companyId: proposal.companyId,
issueId: proposal.originIssueId,
kind: "request_confirmation",
status: "pending",
continuationPolicy: "wake_assignee",
requestedResolverPolicy: "human_only",
effectiveResolverPolicy: "human_only",
resolverPolicyProvenance: "explicit",
effectiveResolverPolicySource: "governed_action",
idempotencyKey: `secret-proposal:${proposal.id}`,
sourceRunId: proposal.originRunId,
title: "Confirm secret binding",
summary: `Bind ${sourceSecretLabel} to ${target.name} as ${proposal.configPath}`,
createdByAgentId: proposal.proposedByAgentId,
addresseeAgentId: null,
payload: {
version: 1,
prompt: `Bind secret ${sourceSecretLabel} to ${target.name} as ${proposal.configPath}?`,
acceptLabel: "Create binding",
rejectLabel: "Reject",
rejectRequiresReason: true,
rejectReasonLabel: "Why should this binding not be created?",
allowDeclineReason: true,
supersedeOnUserComment: false,
secretProposal: {
version: 1,
proposalId: proposal.id,
sourceSecretLabel,
configPath: proposal.configPath,
targetAgentId: proposal.targetId,
targetAgentName: target.name,
justification: proposal.justification,
expiresAt: proposal.expiresAt.toISOString(),
},
},
})
.returning();
const [linked] = await txDb
.update(companySecretProposals)
.set({ interactionId: interaction.id, updatedAt: new Date() })
.where(eq(companySecretProposals.id, proposal.id))
.returning();
await txDb.update(issues).set({ updatedAt: new Date() }).where(eq(issues.id, proposal.originIssueId));
await logActivity(txDb, {
companyId: proposal.companyId,
actorType: "agent",
actorId: proposal.proposedByAgentId,
action: "issue.thread_interaction_created",
entityType: "issue",
entityId: proposal.originIssueId,
agentId: proposal.proposedByAgentId,
runId: proposal.originRunId,
details: {
interactionId: interaction.id,
interactionKind: "request_confirmation",
interactionStatus: "pending",
continuationPolicy: "wake_assignee",
addresseeAgentId: null,
requestedResolverPolicy: "board_only",
effectiveResolverPolicy: "board_only",
serverOwnedPayload: "secretProposal",
},
});
return linked;
}
async function reflectProposalLifecycleOnInteraction(
txDb: Db,
proposal: Proposal,
outcome: "approved" | "rejected" | "withdrawn" | "expired",
input: { resolvedByUserId?: string | null; reason?: string | null } = {},
) {
if (!proposal.interactionId) return;
const current = await txDb
.select()
.from(issueThreadInteractions)
.where(and(
eq(issueThreadInteractions.id, proposal.interactionId),
eq(issueThreadInteractions.companyId, proposal.companyId),
))
.for("update")
.then((rows) => rows[0] ?? null);
if (!current || (current.status !== "pending" && !(outcome === "approved" && current.status === "accepted"))) return;
const payload = asRecord(current.payload);
const linked = asRecord(payload.secretProposal);
if (linked.proposalId !== proposal.id) return;
const now = new Date();
const currentResult = asRecord(current.result);
const status = outcome === "approved"
? "accepted"
: outcome === "rejected"
? "rejected"
: outcome === "withdrawn"
? "cancelled"
: "expired";
const resultOutcome = outcome === "approved"
? "accepted"
: outcome === "rejected"
? "rejected"
: "withdrawn";
await txDb
.update(issueThreadInteractions)
.set({
status,
result: {
...currentResult,
version: 1,
outcome: resultOutcome,
...(input.reason ? { reason: input.reason } : {}),
secretProposal: {
version: 1,
status: outcome === "approved" ? "executed" : outcome,
updatedAt: now.toISOString(),
},
},
resolvedByUserId: input.resolvedByUserId ?? current.resolvedByUserId ?? null,
resolvedAt: current.resolvedAt ?? now,
updatedAt: now,
})
.where(and(
eq(issueThreadInteractions.id, current.id),
inArray(issueThreadInteractions.status, outcome === "approved" ? ["pending", "accepted"] : ["pending"]),
));
}
async function createSecret(context: ProposalRunContext, input: {
name: string;
key?: string | null;
@ -204,17 +357,26 @@ export function createSecretProposalsService(db: Db) {
async function createBinding(context: Pick<ProposalRunContext, "companyId" | "heartbeatRunId">, input: {
secretId?: string | null;
sourceConfigPath?: string | null;
secretProposalId?: string | null;
targetAgentId?: string | null;
configPath: string;
justification: string;
bindingTargetPolicy: "self_and_reports";
}) {
if (Boolean(input.secretId) === Boolean(input.secretProposalId)) {
throw unprocessable("Binding proposals require exactly one of secretId or secretProposalId");
const referenceCount = [input.secretId, input.sourceConfigPath, input.secretProposalId]
.filter((value) => Boolean(value)).length;
if (referenceCount !== 1) {
throw badRequest(
"Binding proposals require exactly one of secretId, sourceConfigPath, or secretProposalId",
);
}
if (!CONFIG_PATH_RE.test(input.configPath)) throw unprocessable("configPath must use env.<KEY> or access.<ALIAS>");
if (input.sourceConfigPath && !CONFIG_PATH_RE.test(input.sourceConfigPath)) {
throw unprocessable("sourceConfigPath must use env.<KEY> or access.<ALIAS>");
}
if (!input.justification.trim()) throw unprocessable("Justification is required");
if (input.justification.trim().length > 20_000) throw unprocessable("Justification must be at most 20000 characters");
const { run, originIssueId } = await loadRunContext(db, context);
const targetAgentId = input.targetAgentId ?? run.agentId;
const [proposerAncestors, targetAncestors] = await Promise.all([
@ -224,12 +386,56 @@ export function createSecretProposalsService(db: Db) {
if (!bindingTargetAllowed(run.agentId, targetAgentId, targetAncestors)) {
throw forbidden("Binding proposals may target only the proposing agent or its reports");
}
if (input.secretId) {
let resolvedSecretId = input.secretId ?? null;
let sourceSecretLabel: string | null = null;
if (input.sourceConfigPath) {
const sourceBinding = await db.select({ secretId: companySecretBindings.secretId })
.from(companySecretBindings)
.where(and(
eq(companySecretBindings.companyId, context.companyId),
eq(companySecretBindings.targetType, "agent"),
eq(companySecretBindings.targetId, run.agentId),
eq(companySecretBindings.configPath, input.sourceConfigPath),
))
.then((rows) => rows[0] ?? null);
if (sourceBinding) {
resolvedSecretId = sourceBinding.secretId;
} else if (run.responsibleUserId) {
const sourceDeclaration = await db
.select({ secretId: companySecrets.id })
.from(userSecretDeclarations)
.innerJoin(companySecrets, and(
eq(companySecrets.companyId, context.companyId),
eq(companySecrets.scope, "user"),
eq(companySecrets.ownerUserId, run.responsibleUserId),
eq(companySecrets.userSecretDefinitionId, userSecretDeclarations.userSecretDefinitionId),
eq(companySecrets.status, "active"),
))
.where(and(
eq(userSecretDeclarations.companyId, context.companyId),
eq(userSecretDeclarations.targetType, "agent"),
eq(userSecretDeclarations.targetId, run.agentId),
eq(userSecretDeclarations.configPath, input.sourceConfigPath),
))
.then((rows) => rows[0] ?? null);
resolvedSecretId = sourceDeclaration?.secretId ?? null;
}
if (!resolvedSecretId) throw notFound("Source secret binding not found");
}
if (resolvedSecretId) {
const secret = await db.select().from(companySecrets).where(and(
eq(companySecrets.id, input.secretId),
eq(companySecrets.id, resolvedSecretId),
eq(companySecrets.companyId, context.companyId),
)).then((rows) => rows[0] ?? null);
if (!secret || secret.scope !== "company" || secret.status === "deleted") throw notFound("Secret not found");
const sourceBindingAllowsUserSecret = Boolean(input.sourceConfigPath) && secret?.scope === "user";
if (
!secret
|| secret.status === "deleted"
|| (secret.scope !== "company" && !sourceBindingAllowsUserSecret)
) {
throw notFound("Secret not found");
}
sourceSecretLabel = secret.name;
}
return createWithinQuota(
{ companyId: context.companyId, agentId: run.agentId, runId: run.id, issueId: originIssueId },
@ -245,12 +451,13 @@ export function createSecretProposalsService(db: Db) {
"Prerequisite secret proposal is no longer pending; use secretId to reference an approved secret",
);
}
sourceSecretLabel = dependency.proposedName;
}
const proposal = await txDb.insert(companySecretProposals).values({
companyId: context.companyId,
kind: "binding",
justification: input.justification.trim(),
secretId: input.secretId ?? null,
secretId: resolvedSecretId,
secretProposalId: input.secretProposalId ?? null,
targetType: "agent",
targetId: targetAgentId,
@ -264,7 +471,8 @@ export function createSecretProposalsService(db: Db) {
expiresAt: new Date(Date.now() + PENDING_EXPIRY_MS),
}).returning().then((rows) => rows[0]);
await recordCreated(proposal, txDb);
return proposal;
if (!sourceSecretLabel) throw conflict("Binding proposal source secret label is unavailable");
return createBindingInteraction(txDb, proposal, sourceSecretLabel);
},
);
}
@ -439,17 +647,43 @@ export function createSecretProposalsService(db: Db) {
ciphertextScrubbed: true,
},
});
await reflectProposalLifecycleOnInteraction(txDb, proposal, "approved", {
resolvedByUserId: input.resolvedByUserId,
});
return updated;
}
async function applyBindingApproval(txDb: Db, proposal: Proposal, secretId: string, resolvedByUserId: string) {
async function applyBindingApproval(
txDb: Db,
proposal: Proposal,
secret: typeof companySecrets.$inferSelect,
resolvedByUserId: string,
) {
if (!proposal.targetId || !proposal.configPath) throw conflict("Binding proposal is incomplete");
const agentSvc = agentService(txDb);
const target = await agentSvc.getById(proposal.targetId);
if (!target || target.companyId !== proposal.companyId) throw notFound("Target agent not found");
const adapterConfig = { ...asRecord(target.adapterConfig) };
const [namespace, key] = proposal.configPath.split(".", 2);
const binding = { type: "secret_ref", secretId, version: "latest" };
const userSecretDefinition = secret.scope === "user" && secret.userSecretDefinitionId
? await txDb.select({ key: userSecretDefinitions.key }).from(userSecretDefinitions).where(and(
eq(userSecretDefinitions.id, secret.userSecretDefinitionId),
eq(userSecretDefinitions.companyId, proposal.companyId),
eq(userSecretDefinitions.status, "active"),
)).then((rows) => rows[0] ?? null)
: null;
if (secret.scope === "user" && !userSecretDefinition) {
throw conflict("Binding proposal user secret definition is not active");
}
const binding = userSecretDefinition
? {
type: "user_secret_ref",
key: userSecretDefinition.key,
version: "latest",
required: true,
allowMissingOverride: false,
}
: { type: "secret_ref", secretId: secret.id, version: "latest" };
if (namespace === "env") {
const env = { ...asRecord(adapterConfig.env) };
const existing = env[key];
@ -483,11 +717,13 @@ export function createSecretProposalsService(db: Db) {
resolvedByUserId: string;
cascade?: boolean;
overrides?: { name?: string; description?: string | null; providerConfigId?: string | null };
assertCanResolve?: (proposal: Proposal, txDb: Db) => Promise<void>;
}) {
return db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const proposal = await requirePending(companyId, proposalId, txDb, true);
assertNotExpired(proposal);
await input.assertCanResolve?.(proposal, txDb);
await assertBindingSnapshotCurrent(proposal, txDb, true);
if (proposal.kind === "secret") {
const created = await applySecretApproval(txDb, proposal, input);
@ -522,9 +758,9 @@ export function createSecretProposalsService(db: Db) {
if (!secretId) throw conflict("Binding proposal has no approved secret");
const liveSecret = await secretService(txDb).getById(secretId);
if (!liveSecret || liveSecret.companyId !== companyId || liveSecret.status !== "active") {
throw conflict("Binding proposal secret is not an active company secret");
throw conflict("Binding proposal secret is not active");
}
await applyBindingApproval(txDb, proposal, secretId, input.resolvedByUserId);
await applyBindingApproval(txDb, proposal, liveSecret, input.resolvedByUserId);
return markApproved(txDb, proposal, {
resolvedByUserId: input.resolvedByUserId,
appliedBindingConfigPath: proposal.configPath,
@ -600,7 +836,15 @@ export function createSecretProposalsService(db: Db) {
cascadeFromProposalId: proposal.id,
},
});
await reflectProposalLifecycleOnInteraction(txDb, dependent, "rejected", {
resolvedByUserId: input.resolvedByUserId,
reason: dependent.resolutionReason,
});
}
await reflectProposalLifecycleOnInteraction(txDb, proposal, status, {
resolvedByUserId: input.resolvedByUserId,
reason: input.reason ?? (status === "expired" ? "Pending proposal expired" : null),
});
return updated;
});
}

View File

@ -1405,7 +1405,48 @@ curl -s -X POST \
"$PAPERCLIP_API_BASE/api/agents/me/secret-proposals"
```
A binding must specify exactly one of `secretProposalId` or `secretId`. `configPath` accepts `env.<KEY>` for environment injection or `access.<ALIAS>` for API-only access. Under the default `self_and_reports` policy, `targetAgentId` may identify a downward report of the proposer; omitting it targets the proposer. Other targets are denied, and approval rechecks the current chain of command.
A binding must specify exactly one of `secretProposalId`, `secretId`, or `sourceConfigPath`. `configPath` accepts `env.<KEY>` for environment injection or `access.<ALIAS>` for API-only access. Under the default `self_and_reports` policy, `targetAgentId` may identify a downward report of the proposer; omitting it targets the proposer. Other targets are denied, and approval rechecks the current chain of command.
##### Re-bind an existing secret under a new path (no secret ID)
Use `sourceConfigPath` when the secret is already bound to the proposing agent. The server resolves that agent's own `env.*` or `access.*` binding, so the request never needs a secret ID or `secretRef`:
```bash
PAPERCLIP_API_BASE="${PAPERCLIP_API_URL%/}"
PAPERCLIP_API_BASE="${PAPERCLIP_API_BASE%/api}"
jq -n \
--arg sourceConfigPath "access.openai_api_key" \
--arg configPath "access.evals_openai_api_key" \
--arg justification "Use the existing OpenAI credential under the eval-specific alias" \
'{kind:"binding", sourceConfigPath:$sourceConfigPath, configPath:$configPath, justification:$justification}' |
curl -s -X POST \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- \
"$PAPERCLIP_API_BASE/api/agents/me/secret-proposals"
```
`sourceConfigPath` must name an existing binding on the proposing agent; another agent's path and an unknown path both return `404`. Omit `targetAgentId` to bind the alias back to yourself. Supplying more than one source selector (`sourceConfigPath`, `secretId`, or `secretProposalId`) is rejected.
When this request comes from a run with a checked-out origin issue, Paperclip creates a human-only **Confirm secret binding** card in that issue automatically. Do not create a separate interaction. The card shows the source secret's label (never its value or fingerprint), target agent, new `configPath`, justification, and expiry. A human can select **Create binding** or reject it with a reason.
Card acceptance is not execution. Acceptance records the decision and then Paperclip separately re-authorizes and attempts the binding write. The card's `result.secretProposal.status` is the real outcome:
- `executed`: the binding write completed.
- `failed`: acceptance succeeded but the binding write did not. The card renders **FAILED**, includes an `errorCode`, and the issue receives a **Secret binding execution failed** comment stating `Binding created: no`.
- `rejected`, `withdrawn`, or `expired`: no binding was created.
The card uses `continuationPolicy: "wake_assignee"`. On resolution the issue assignee is woken with `payload.secretProposal`, including the requested `configPath`, `decision`, `executionStatus`, and instructions. Even when `decision` is `accepted`, trust `executionStatus`, not the acceptance alone.
**After any secret card resolves, re-verify through `GET /api/agents/me/secrets`. Acceptance is not execution.** On the resumed run, call:
```bash
curl -s \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
"$PAPERCLIP_API_BASE/api/agents/me/secrets"
```
Confirm the expected secret metadata and delivery are present before using the new binding. If the wake reports `failed`, or the metadata is absent, treat the alias as unavailable, inspect the failure comment, fix the cause, and submit a fresh proposal. Never infer success merely because the card says accepted.
`GET /api/agents/me/secret-proposals` returns `{ "proposals": [...] }` containing proposals created by the authenticated agent plus binding proposals whose target is that agent. Secret values, value fingerprints, and value lengths are omitted. `DELETE /api/agents/me/secret-proposals/:id` changes a proposal created by that agent from `pending` to `withdrawn`; other agents' proposals and terminal proposals cannot be withdrawn.
@ -1424,6 +1465,7 @@ List response:
"secrets": [
{
"key": "github_token",
"secretRef": "11111111-1111-4111-8111-111111111111",
"name": "GitHub token",
"description": null,
"delivery": "env",
@ -1436,7 +1478,7 @@ List response:
}
```
`delivery` is `env`, `api`, or `both`. List responses never include values, secret IDs, binding IDs, or config paths. Successful lists write `activity_log.action = secret.access.listed` but do not create `secret_access_events` rows.
`delivery` is `env`, `api`, or `both`. `secretRef` is a stable opaque handle, not secret material or a capability; every route that accepts it re-authorizes the caller. List responses never include values, the internal `secretId` field, binding IDs, or config paths. Successful lists write `activity_log.action = secret.access.listed` but do not create `secret_access_events` rows.
Value response (`Cache-Control: no-store`):

View File

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
failedSecretProposalInteraction,
pendingSecretProposalInteraction,
} from "../fixtures/issueThreadInteractionFixtures";
import { replaceResolvedInteraction } from "./AttentionInteractionResolver";
describe("replaceResolvedInteraction", () => {
it("immediately replaces a pending secret proposal with its stitched failure receipt", () => {
const resolved = {
...failedSecretProposalInteraction,
id: pendingSecretProposalInteraction.id,
};
const next = replaceResolvedInteraction([pendingSecretProposalInteraction], resolved);
expect(next).toHaveLength(1);
expect(next[0]?.status).toBe("accepted");
expect(next[0]?.kind).toBe("request_confirmation");
if (next[0]?.kind === "request_confirmation") {
expect(next[0].result?.secretProposal).toMatchObject({
status: "failed",
errorCode: "binding_snapshot_stale",
});
}
});
it("retains the stitched receipt when the interaction cache was empty", () => {
expect(replaceResolvedInteraction(undefined, failedSecretProposalInteraction)).toEqual([
failedSecretProposalInteraction,
]);
});
});

View File

@ -28,6 +28,18 @@ interface AttentionInteractionResolverProps {
onResolved?: () => void;
}
export function replaceResolvedInteraction(
current: IssueThreadInteraction[] | undefined,
resolvedInteraction: IssueThreadInteraction,
): IssueThreadInteraction[] {
const existing = current ?? [];
const index = existing.findIndex((entry) => entry.id === resolvedInteraction.id);
if (index < 0) return [...existing, resolvedInteraction];
return existing.map((entry, entryIndex) =>
entryIndex === index ? resolvedInteraction : entry,
);
}
/**
* Lazily fetches the full issue-thread interaction referenced by an attention
* row and renders the existing {@link IssueThreadInteractionCard} inline, so
@ -56,7 +68,17 @@ export function AttentionInteractionResolver({
return match && isIssueThreadInteraction(match) ? match : null;
}, [interactions, interactionId]);
const invalidate = () => {
const invalidate = (resolvedInteraction?: IssueThreadInteraction) => {
// The accept route returns the fully stitched interaction (including
// secretProposal.executed/failed). Put that receipt in the cache before
// invalidating so the attention surface never flashes a generic Accepted
// state while the refetch catches up.
if (resolvedInteraction) {
queryClient.setQueryData<IssueThreadInteraction[] | undefined>(
queryKeys.issues.interactions(issueId),
(current) => replaceResolvedInteraction(current, resolvedInteraction),
);
}
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(issueId) });
queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
onResolved?.();

View File

@ -28,6 +28,7 @@ import {
issueChatLongThreadLinkedRuns,
issueChatLongThreadTranscriptsByRunId,
} from "../fixtures/issueChatLongThreadFixture";
import { expiredSecretProposalInteraction } from "../fixtures/issueThreadInteractionFixtures";
import type {
IssueChatLinkedRun,
IssueChatTranscriptEntry,
@ -2779,6 +2780,38 @@ describe("IssueChatThread", () => {
});
});
it("renders expired secret proposals as full receipts by default", async () => {
const root = createRoot(container);
await act(async () => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
interactions={[expiredSecretProposalInteraction]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
showComposer={false}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
expect(container.textContent).toContain("Secret binding requested");
expect(container.textContent).toContain("OpenAI API key");
expect(container.textContent).toContain("access.evals_openai_api_key");
expect(container.textContent).toContain("EvalsEngineer");
expect(container.textContent).toContain("A fresh proposal is required");
expect(container.textContent).not.toContain("updated this task");
act(() => {
root.unmount();
});
});
it("renders the transcript directly from stable Paperclip messages", () => {
const root = createRoot(container);

View File

@ -2929,7 +2929,11 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
}
if (custom.kind === "interaction" && interaction) {
if (interaction.kind === "request_confirmation" && interaction.status === "expired") {
if (
interaction.kind === "request_confirmation"
&& interaction.status === "expired"
&& !interaction.payload.secretProposal
) {
return (
<ExpiredRequestConfirmationActivity
message={message}

View File

@ -23,6 +23,11 @@ import {
pendingRequestConfirmationInteraction,
pendingToolActionDestructiveInteraction,
pendingToolActionWriteInteraction,
pendingSecretProposalInteraction,
executedSecretProposalInteraction,
failedSecretProposalInteraction,
rejectedSecretProposalInteraction,
expiredSecretProposalInteraction,
planApprovalResumeFailedRequestConfirmationInteraction,
pendingRequestItemVerdictsInteraction,
pendingSuggestedTasksInteraction,
@ -1119,6 +1124,98 @@ describe("IssueThreadInteractionCard tool-action card", () => {
});
describe("IssueThreadInteractionCard secret-proposal card", () => {
it("renders only safe proposal metadata and exposes accept/reject actions", async () => {
const onAcceptInteraction = vi.fn(async () => undefined);
const onRejectInteraction = vi.fn(async () => undefined);
const host = renderCard({
interaction: pendingSecretProposalInteraction,
onAcceptInteraction,
onRejectInteraction,
});
expect(host.textContent).toContain("Secret binding requested");
expect(host.textContent).toContain("OpenAI API key");
expect((host.textContent ?? "").split("OpenAI API key")).toHaveLength(2);
expect(host.textContent).toContain("access.evals_openai_api_key");
expect(host.textContent).toContain("EvalsEngineer");
expect(host.textContent).toContain("Reason given by the agent");
expect(host.textContent).toContain("evaluation runner needs the existing credential");
expect(host.textContent).toContain("Expires");
expect(host.textContent).not.toContain(
pendingSecretProposalInteraction.payload.secretProposal?.proposalId,
);
expect(host.textContent).not.toContain(
pendingSecretProposalInteraction.payload.secretProposal?.targetAgentId,
);
expect(host.textContent?.toLowerCase()).not.toContain("fingerprint");
const statusBadge = host.querySelector('[data-testid="interaction-status-badge"]');
expect(statusBadge?.querySelector(".flex-col")?.textContent).toBe(
"Secret binding/Awaiting approval",
);
expect(statusBadge?.querySelector(".hidden")?.textContent).toBe("/");
const actions = host.querySelector('[data-testid="confirmation-actions"]');
expect(actions?.getAttribute("data-mobile-layout")).toBe("stacked");
expect(actions?.classList.contains("grid-cols-2")).toBe(true);
const configPath = Array.from(host.querySelectorAll("dd")).find((node) =>
node.textContent === "access.evals_openai_api_key"
);
expect(configPath?.parentElement?.classList.contains("sm:col-span-2")).toBe(true);
const approve = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Approve & bind"),
);
await act(async () => {
approve?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onAcceptInteraction).toHaveBeenCalledWith(pendingSecretProposalInteraction);
const reject = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.trim() === "Reject",
);
await act(async () => {
reject?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onRejectInteraction).toHaveBeenCalledWith(
pendingSecretProposalInteraction,
undefined,
);
});
it("renders an accepted proposal as executed rather than merely accepted", () => {
const host = renderCard({ interaction: executedSecretProposalInteraction });
expect(host.textContent).toContain("Executed");
expect(host.textContent).toContain("Binding created");
expect(host.textContent).not.toContain("Accepted");
expect(host.querySelector("button")).toBeNull();
});
it("renders accepted execution failure as visibly FAILED with its error code", () => {
const host = renderCard({ interaction: failedSecretProposalInteraction });
expect(host.textContent).toContain("FAILED");
expect(host.textContent).toContain("binding_snapshot_stale");
expect(host.textContent).toContain("binding was not created");
expect(host.textContent).not.toContain("Approve & bind");
});
it("distinguishes rejected and expired proposals as non-executed terminal states", () => {
const rejected = renderCard({ interaction: rejectedSecretProposalInteraction });
expect(rejected.textContent).toContain("Rejected");
expect(rejected.textContent).toContain("The binding was not created");
expect(rejected.textContent).toContain("project-scoped credential");
act(() => root?.unmount());
rejected.remove();
root = null;
const expired = renderCard({ interaction: expiredSecretProposalInteraction });
expect(expired.textContent).toContain("Expired");
expect(expired.textContent).toContain("A fresh proposal is required");
expect(expired.textContent).not.toContain("Approve & bind");
});
});
/**
* The effective audience is shown *before* anyone responds, so a reader never
* has to guess whether an open card is waiting on them (PAP-17280).

View File

@ -1,6 +1,6 @@
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import type { Agent } from "@paperclipai/shared";
import { AlertTriangle, ArrowUpRight, Bot, Check, CheckCircle2, ChevronDown, ChevronRight, CircleDashed, Clock, ExternalLink, FileText, GitBranch, ImagePlus, Loader2, MessageSquareQuote, MinusCircle, ShieldAlert, ThumbsUp, TriangleAlert, Wrench, X, XCircle } from "lucide-react";
import { AlertTriangle, ArrowUpRight, Bot, Check, CheckCircle2, ChevronDown, ChevronRight, CircleDashed, Clock, ExternalLink, FileText, GitBranch, ImagePlus, KeyRound, Loader2, MessageSquareQuote, MinusCircle, ShieldAlert, ThumbsUp, TriangleAlert, Wrench, X, XCircle } from "lucide-react";
import { Link } from "@/lib/router";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { describeInteractionAudience, type InteractionAudienceDescription } from "../lib/interaction-audience";
@ -40,6 +40,7 @@ import { SHOW_TASK_PRIORITY_UI } from "../lib/ui-flags";
import { Textarea } from "./ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { ProposalJustification } from "../pages/secrets/proposal-review";
const OTHER_ANSWER_ID = "__paperclip_other__";
@ -330,6 +331,17 @@ function isToolActionConfirmation(interaction: IssueThreadInteraction): boolean
return toolActionPayload(interaction) != null;
}
function secretProposalPayload(
interaction: IssueThreadInteraction,
): NonNullable<RequestConfirmationInteraction["payload"]["secretProposal"]> | null {
if (interaction.kind !== "request_confirmation") return null;
return interaction.payload.secretProposal ?? null;
}
function isSecretProposalConfirmation(interaction: IssueThreadInteraction): boolean {
return secretProposalPayload(interaction) != null;
}
type ToolActionCardState =
| "pending"
| "running"
@ -1985,6 +1997,286 @@ function RequestToolActionCard({
);
}
type SecretProposalCardState = ToolActionCardState;
function secretProposalCardState(
interaction: RequestConfirmationInteraction,
): SecretProposalCardState {
const proposalStatus = interaction.result?.secretProposal?.status ?? null;
if (interaction.status === "pending") return "pending";
if (proposalStatus === "executed") return "executed";
if (proposalStatus === "failed" || interaction.status === "failed") return "failed";
if (proposalStatus === "expired" || interaction.status === "expired") return "expired";
if (
proposalStatus === "rejected"
|| proposalStatus === "withdrawn"
|| interaction.status === "rejected"
|| interaction.status === "cancelled"
) {
return "declined";
}
return "running";
}
function secretProposalStatusClasses(state: SecretProposalCardState) {
if (state === "failed") {
return {
shell: "border-2 border-red-500/80 bg-transparent",
badge: "border-red-500/60 bg-red-500/10 text-red-900 dark:bg-red-500/15 dark:text-red-100",
label: "FAILED",
Icon: XCircle,
};
}
return toolActionStatusClasses(state);
}
function SecretProposalIdentityHeader({
state,
}: {
state: SecretProposalCardState;
}) {
const dimmed = state === "declined" || state === "expired";
return (
<div className={cn("flex items-start gap-3", dimmed && "opacity-60 grayscale")}>
<div
aria-hidden
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/60 text-foreground"
>
<KeyRound className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="text-base font-bold leading-tight text-foreground">
Bind an existing secret
</div>
</div>
</div>
);
}
function SecretProposalDetails({
payload,
}: {
payload: NonNullable<RequestConfirmationInteraction["payload"]["secretProposal"]>;
}) {
return (
<dl className="grid gap-3 rounded-sm border border-border/70 bg-muted/30 p-3 sm:grid-cols-2">
<div className="min-w-0 space-y-1">
<dt className="text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow) text-muted-foreground">
Source secret
</dt>
<dd className="truncate text-sm font-medium text-foreground">{payload.sourceSecretLabel}</dd>
</div>
<div className="min-w-0 space-y-1">
<dt className="text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow) text-muted-foreground">
Target agent
</dt>
<dd className="truncate text-sm font-medium text-foreground">{payload.targetAgentName}</dd>
</div>
<div className="min-w-0 space-y-1 sm:col-span-2">
<dt className="text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow) text-muted-foreground">
New config path
</dt>
<dd className="break-all font-mono text-sm text-foreground">{payload.configPath}</dd>
</div>
</dl>
);
}
function SecretProposalResolution({
interaction,
state,
resolvedByLabel,
}: {
interaction: RequestConfirmationInteraction;
state: SecretProposalCardState;
resolvedByLabel: string | null;
}) {
const result = interaction.result?.secretProposal ?? null;
const who = resolvedByLabel ?? "the board";
const when = interaction.resolvedAt
? formatDateTime(interaction.resolvedAt)
: result?.updatedAt
? formatDateTime(result.updatedAt)
: null;
if (state === "running") {
return (
<div aria-live="polite" className="flex items-start gap-2 rounded-sm border border-amber-500/50 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-100">
<Loader2 className="mt-0.5 h-4 w-4 shrink-0 animate-spin" />
<div>
<div className="font-medium">Approved by {who} creating the binding</div>
<p className="mt-1 text-amber-900/80 dark:text-amber-100/80">
Paperclip is re-checking authority and the proposal snapshot before writing.
</p>
</div>
</div>
);
}
if (state === "executed") {
return (
<div aria-live="polite" className="flex items-start gap-2 rounded-sm border border-green-500/50 bg-green-500/10 px-4 py-3 text-sm text-green-900 dark:text-green-100">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-medium">Binding created · approved by {who}</div>
<p className="mt-1 text-green-900/80 dark:text-green-100/80">
The target agent can now use the proposed config path{when ? ` · ${when}` : ""}.
</p>
</div>
</div>
);
}
if (state === "failed") {
const errorCode = result?.errorCode?.trim();
return (
<div aria-live="assertive" className="space-y-2 rounded-sm border border-red-500/60 bg-red-500/10 px-4 py-3 text-sm text-red-900 dark:text-red-100">
<div className="flex items-start gap-2">
<XCircle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-semibold uppercase tracking-(--tracking-eyebrow)">
FAILED · binding was not created
</div>
<p className="mt-1 text-red-900/80 dark:text-red-100/80">
The request was accepted, but execution failed closed. No secret value was exposed.
</p>
</div>
</div>
{errorCode ? (
<div className="rounded-sm border border-red-500/50 bg-background/60 px-3 py-2">
<span className="text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow)">
Error code
</span>{" "}
<code className="font-mono text-foreground">{errorCode}</code>
</div>
) : null}
</div>
);
}
if (state === "declined") {
const reason = interaction.result?.reason?.trim();
return (
<div className="space-y-2 rounded-sm border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-900 dark:text-red-100">
<div className="flex items-start gap-2">
<XCircle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-medium">Rejected by {who}</div>
<p className="mt-1 text-red-900/80 dark:text-red-100/80">The binding was not created.</p>
</div>
</div>
{reason ? (
<div className="rounded-sm border border-red-500/40 bg-background/60 px-3 py-2 text-foreground">
<MarkdownBody>{reason}</MarkdownBody>
</div>
) : null}
</div>
);
}
return (
<div className="flex items-start gap-2 rounded-sm border border-border bg-muted/50 px-4 py-3 text-sm text-muted-foreground">
<Clock className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-medium text-foreground">Proposal expired{when ? ` · ${when}` : ""}</div>
<p className="mt-1">The binding was not created. A fresh proposal is required.</p>
</div>
</div>
);
}
function RequestSecretProposalCard({
interaction,
state,
resolvedByLabel,
onAcceptInteraction,
onRejectInteraction,
}: {
interaction: RequestConfirmationInteraction;
state: SecretProposalCardState;
resolvedByLabel: string | null;
onAcceptInteraction?: (interaction: RequestConfirmationInteraction) => Promise<void> | void;
onRejectInteraction?: (
interaction: RequestConfirmationInteraction,
reason?: string,
) => Promise<void> | void;
}) {
const payload = interaction.payload.secretProposal!;
const [working, setWorking] = useState<"accept" | "reject" | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const resolutionErrorMessage = useResolutionErrorMessage();
const isPending = state === "pending";
useEffect(() => {
setActionError(null);
if (!isPending) setWorking(null);
}, [interaction.id, isPending]);
async function handleAccept() {
if (!onAcceptInteraction) return;
setWorking("accept");
setActionError(null);
try {
await onAcceptInteraction(interaction);
} catch (error) {
setActionError(resolutionErrorMessage(error));
} finally {
setWorking(null);
}
}
async function handleReject(reason?: string) {
if (!onRejectInteraction) return;
setWorking("reject");
setActionError(null);
try {
await onRejectInteraction(interaction, reason);
} catch (error) {
setActionError(resolutionErrorMessage(error));
} finally {
setWorking(null);
}
}
return (
<div className="space-y-4">
<SecretProposalIdentityHeader state={state} />
<SecretProposalDetails payload={payload} />
<ProposalJustification justification={payload.justification} />
<div className="flex items-center gap-2 text-(length:--text-micro) text-muted-foreground">
<Clock className="h-3.5 w-3.5" />
Expires {formatDateTime(payload.expiresAt)}
</div>
{isPending ? (
<ConfirmationActionRow
resetKey={`${interaction.id}:${interaction.status}`}
approveLabel={interaction.payload.acceptLabel ?? "Approve & bind"}
reviseLabel="Add reason…"
rejectLabel={interaction.payload.rejectLabel ?? "Reject"}
approveVariant="cta"
allowRevise={interaction.payload.allowDeclineReason !== false}
rejectRequiresReason={interaction.payload.rejectRequiresReason === true}
reasonPlaceholder={interaction.payload.declineReasonPlaceholder ?? "Optional: explain why this binding should not be created."}
working={working}
actionError={actionError}
canApprove={Boolean(onAcceptInteraction)}
canReject={Boolean(onRejectInteraction)}
onApprove={() => void handleAccept()}
onReject={(reason) => void handleReject(reason)}
stackActionsOnMobile
/>
) : (
<SecretProposalResolution
interaction={interaction}
state={state}
resolvedByLabel={resolvedByLabel}
/>
)}
</div>
);
}
/**
* The single approval grammar shared by every plan / task-approval card
* (PAP-418): **Approve · Revise · Reject**. "Revise…" reveals an attached text
@ -2025,6 +2317,7 @@ function ConfirmationActionRow({
composeReason,
extraReasonSatisfied = false,
revisePanelChildren,
stackActionsOnMobile = false,
}: {
/** Changing this (interaction id + status) collapses the revise panel and
* clears its draft text the row is reused across interaction updates. */
@ -2052,6 +2345,9 @@ function ConfirmationActionRow({
extraReasonSatisfied?: boolean;
/** Extra affordances rendered inside the revise panel (e.g. screenshot attach). */
revisePanelChildren?: ReactNode;
/** Give domain cards with longer action labels an intentional narrow-screen
* hierarchy instead of relying on opportunistic flex wrapping. */
stackActionsOnMobile?: boolean;
}) {
const [revising, setRevising] = useState(false);
const [reason, setReason] = useState("");
@ -2075,14 +2371,19 @@ function ConfirmationActionRow({
return (
<div className="space-y-3">
<div
data-testid="confirmation-actions"
data-mobile-layout={stackActionsOnMobile ? "stacked" : "inline"}
className={cn(
"flex flex-wrap items-center justify-end gap-2",
stackActionsOnMobile
? "grid grid-cols-2 items-stretch gap-2 sm:flex sm:flex-wrap sm:items-center sm:justify-end"
: "flex flex-wrap items-center justify-end gap-2",
primaryActionOnRight && "flex-row-reverse justify-start",
)}
>
<Button
size="sm"
variant={revising ? "outline" : approveVariant}
className={stackActionsOnMobile ? "col-span-2 w-full sm:col-auto sm:w-auto" : undefined}
disabled={!canApprove || working !== null || approveDisabled}
onClick={onApprove}
>
@ -2099,6 +2400,7 @@ function ConfirmationActionRow({
<Button
size="sm"
variant="outline"
className={stackActionsOnMobile ? "w-full sm:w-auto" : undefined}
disabled={!canReject || working !== null}
onClick={() => {
setAttempted(false);
@ -2112,6 +2414,7 @@ function ConfirmationActionRow({
<Button
size="sm"
variant="ghost"
className={stackActionsOnMobile ? "w-full sm:w-auto" : undefined}
disabled={!canReject || working !== null}
onClick={() => onReject(undefined)}
>
@ -3247,11 +3550,20 @@ export function IssueThreadInteractionCard({
const isPlan = isPlanConfirmation(interaction);
const isToolAction =
interaction.kind === "request_confirmation" && isToolActionConfirmation(interaction);
const isSecretProposal =
interaction.kind === "request_confirmation" && isSecretProposalConfirmation(interaction);
const toolActionState =
isToolAction && interaction.kind === "request_confirmation"
? toolActionCardState(interaction)
: null;
const toolActionStyles = toolActionState ? toolActionStatusClasses(toolActionState) : null;
const secretProposalState =
isSecretProposal && interaction.kind === "request_confirmation"
? secretProposalCardState(interaction)
: null;
const secretProposalStyles = secretProposalState
? secretProposalStatusClasses(secretProposalState)
: null;
const resumeFailure = requestConfirmationResumeFailure(interaction);
const planStyles = isPlan
? planStatusClasses(
@ -3260,7 +3572,7 @@ export function IssueThreadInteractionCard({
interaction.result && "outcome" in interaction.result ? interaction.result.outcome : null,
)
: null;
const activeStyles = toolActionStyles ?? planStyles;
const activeStyles = secretProposalStyles ?? toolActionStyles ?? planStyles;
const adminOutcome = getAdministrativeOutcome(interaction);
const adminReason = adminOutcome ? getAdministrativeReason(interaction) : null;
// P4 (design review R2): a withdrawal is a neutral administrative retraction by
@ -3279,7 +3591,7 @@ export function IssueThreadInteractionCard({
: activeStyles
? activeStyles.Icon
: statusIcon(interaction.status);
const iconSpin = toolActionStyles?.spin ?? false;
const iconSpin = secretProposalStyles?.spin ?? toolActionStyles?.spin ?? false;
const styles = withdrawnStyles ?? activeStyles ?? statusClasses(interaction.status);
const createdByLabel = resolveActorLabel({
agentId: interaction.createdByAgentId,
@ -3335,11 +3647,27 @@ export function IssueThreadInteractionCard({
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1 basis-64">
<div className="flex flex-wrap items-center gap-2">
<span className={cn("inline-flex items-center gap-1 rounded-sm border px-2.5 py-1 text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow)", styles.badge)}>
<span
data-testid="interaction-status-badge"
className={cn(
"inline-flex items-center gap-1 rounded-sm border px-2.5 py-1 text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow)",
styles.badge,
)}
>
<StatusIcon className={cn("h-3.5 w-3.5", iconSpin && "animate-spin")} />
{isPlan ? "Plan" : interactionKindLabel(interaction.kind)}
<span className="text-current/60">/</span>
{statusText}
{isSecretProposal ? (
<span className="flex flex-col sm:flex-row sm:items-center sm:gap-1">
<span>Secret binding</span>
<span className="hidden text-current/60 sm:inline">/</span>
<span>{statusText}</span>
</span>
) : (
<>
{isPlan ? "Plan" : interactionKindLabel(interaction.kind)}
<span className="text-current/60">/</span>
{statusText}
</>
)}
</span>
{addresseeLabel ? (
<Tooltip>
@ -3373,6 +3701,8 @@ export function IssueThreadInteractionCard({
: "Questions to answer")
: interaction.kind === "request_checkbox_confirmation"
? "Checkbox confirmation requested"
: isSecretProposal
? "Secret binding requested"
: isToolAction
? "Tool approval requested"
: interaction.kind === "request_item_verdicts"
@ -3429,6 +3759,14 @@ export function IssueThreadInteractionCard({
onRejectInteraction={onRejectInteraction}
externalReferences={externalReferences}
/>
) : isSecretProposal && interaction.kind === "request_confirmation" && secretProposalState ? (
<RequestSecretProposalCard
interaction={interaction}
state={secretProposalState}
resolvedByLabel={resolvedByLabel}
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
/>
) : isToolAction && interaction.kind === "request_confirmation" && toolActionState ? (
<RequestToolActionCard
interaction={interaction}

View File

@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { RequestConfirmationInteraction } from "@/lib/issue-thread-interactions";
import { ThemeProvider } from "@/context/ThemeContext";
import { TooltipProvider } from "@/components/ui/tooltip";
import { expiredSecretProposalInteraction } from "@/fixtures/issueThreadInteractionFixtures";
import { TaskChatInteractionCard } from "./TaskChatInteractionCard";
import { TaskChatThreadView } from "./TaskChatThreadView";
import type { TaskChatInteractionItem } from "./task-chat-model";
@ -117,6 +118,25 @@ describe("TaskChatInteractionCard", () => {
expect(container.textContent).toContain("Approve the plan");
expect(container.textContent).toContain("expired");
});
it("renders an expired secret proposal as a full receipt", () => {
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatInteractionCard item={interactionItem(expiredSecretProposalInteraction)} />
</ThemeProvider>
</TooltipProvider>,
);
});
expect(container.querySelector('[data-testid="task-chat-interaction"]')).not.toBeNull();
expect(container.textContent).toContain("Secret binding requested");
expect(container.textContent).toContain("OpenAI API key");
expect(container.textContent).toContain("access.evals_openai_api_key");
expect(container.textContent).toContain("EvalsEngineer");
expect(container.textContent).toContain("A fresh proposal is required");
});
});
describe("TaskChatThreadView interaction items", () => {

View File

@ -14,12 +14,18 @@ export interface TaskChatInteractionCardProps extends InteractionCardProps {
* question card, suggested tasks) inside the redesigned thread: cards are the
* only rows that get bubble-level emphasis, so the shared
* IssueThreadInteractionCard renders full-width with the standard bubble
* entrance. Expired confirmations demote to a marker row superseded asks are
* history, not calls to action (legacy-thread parity).
* entrance. Generic expired confirmations demote to a marker row superseded
* asks are history, not calls to action (legacy-thread parity). Secret proposals
* remain full receipts because their safe binding metadata and recovery guidance
* are part of the terminal outcome.
*/
export function TaskChatInteractionCard({ item, ...cardProps }: TaskChatInteractionCardProps) {
const interaction = item.interaction;
if (interaction.kind === "request_confirmation" && interaction.status === "expired") {
if (
interaction.kind === "request_confirmation"
&& interaction.status === "expired"
&& !interaction.payload.secretProposal
) {
return (
<TaskChatMarker
item={{

View File

@ -68,6 +68,31 @@ describe("isSuppressedThreadInteraction", () => {
).toBe(true);
});
it("keeps superseded secret proposals as terminal audit receipts", () => {
expect(
isSuppressedThreadInteraction(
confirmation({
status: "expired",
payload: {
version: 1,
prompt: "Approve this secret binding?",
secretProposal: {
version: 1,
proposalId: "proposal-1",
sourceSecretLabel: "OpenAI API key",
configPath: "access.evals_openai_api_key",
targetAgentId: "agent-2",
targetAgentName: "EvalsEngineer",
justification: "The runner needs this binding.",
expiresAt: "2026-05-04T15:02:00.000Z",
},
},
result: result("superseded_by_newer_request"),
}),
),
).toBe(false);
});
it("keeps accepted, rejected, and still-pending confirmations", () => {
expect(isSuppressedThreadInteraction(confirmation())).toBe(false);
expect(

View File

@ -49,6 +49,12 @@ function interactionOutcome(interaction: IssueThreadInteraction): string | null
* so it never stacks above the confirmation that replaced it.
*/
export function isSuppressedThreadInteraction(interaction: IssueThreadInteraction): boolean {
// A secret proposal is also a terminal audit receipt: even when a newer
// request superseded it, the safe source/target/path metadata and recovery
// guidance must remain visible in the issue where the proposal happened.
if (interaction.kind === "request_confirmation" && interaction.payload.secretProposal) {
return false;
}
if (!CONFIRMATION_KINDS.has(interaction.kind)) return false;
const outcome = interactionOutcome(interaction);
return outcome != null && SUPPRESSED_CONFIRMATION_OUTCOMES.has(outcome);

View File

@ -10,6 +10,7 @@ import type {
IssueThreadInteractionBase,
RequestCheckboxConfirmationInteraction,
RequestConfirmationInteraction,
RequestConfirmationSecretProposalPayload,
RequestConfirmationToolActionPayload,
RequestItemVerdictsInteraction,
SuggestTasksInteraction,
@ -764,6 +765,125 @@ export const expiredToolActionInteraction = createToolActionConfirmationInteract
},
});
// ---------------------------------------------------------------------------
// Secret-binding proposal fixtures. These mirror the server-owned
// `payload.secretProposal` block and intentionally contain display metadata
// only: no secret ids, values, fingerprints, or version material.
// ---------------------------------------------------------------------------
const secretProposalBase: RequestConfirmationSecretProposalPayload = {
version: 1,
proposalId: "eeeeeee5-5555-4555-8555-5555555555e5",
sourceSecretLabel: "OpenAI API key",
configPath: "access.evals_openai_api_key",
targetAgentId: "ffffffff-6666-4666-8666-6666666666f6",
targetAgentName: "EvalsEngineer",
justification:
"The evaluation runner needs the existing credential under its canonical config name.",
expiresAt: expiresInMinutes(14 * 24 * 60),
};
function createSecretProposalConfirmationInteraction(
overrides: Partial<RequestConfirmationInteraction> & {
secretProposal?: Partial<RequestConfirmationSecretProposalPayload>;
},
): RequestConfirmationInteraction {
const { secretProposal: secretProposalOverrides, payload, ...rest } = overrides;
return createRequestConfirmationInteraction({
id: "interaction-secret-proposal-default",
title: undefined,
summary: "Review a proposed alias for an existing secret binding.",
createdByAgentId: "agent-codex",
resolverPolicy: "human_only",
requestedResolverPolicy: "human_only",
effectiveResolverPolicy: "human_only",
payload: {
version: 1,
prompt: "Approve this secret binding?",
acceptLabel: "Approve & bind",
rejectLabel: "Reject",
allowDeclineReason: true,
...payload,
secretProposal: { ...secretProposalBase, ...secretProposalOverrides },
},
...rest,
});
}
export const pendingSecretProposalInteraction = createSecretProposalConfirmationInteraction({
id: "interaction-secret-proposal-pending",
});
export const executedSecretProposalInteraction = createSecretProposalConfirmationInteraction({
id: "interaction-secret-proposal-executed",
status: "accepted",
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
resolvedAt: new Date("2026-04-20T15:02:00.000Z"),
updatedAt: new Date("2026-04-20T15:02:03.000Z"),
result: {
version: 1,
outcome: "accepted",
secretProposal: {
version: 1,
status: "executed",
updatedAt: "2026-04-20T15:02:03.000Z",
},
},
});
export const failedSecretProposalInteraction = createSecretProposalConfirmationInteraction({
id: "interaction-secret-proposal-failed",
status: "accepted",
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
resolvedAt: new Date("2026-04-20T15:02:00.000Z"),
updatedAt: new Date("2026-04-20T15:02:03.000Z"),
result: {
version: 1,
outcome: "accepted",
secretProposal: {
version: 1,
status: "failed",
errorCode: "binding_snapshot_stale",
updatedAt: "2026-04-20T15:02:03.000Z",
},
},
});
export const rejectedSecretProposalInteraction = createSecretProposalConfirmationInteraction({
id: "interaction-secret-proposal-rejected",
status: "rejected",
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
resolvedAt: new Date("2026-04-20T15:02:00.000Z"),
updatedAt: new Date("2026-04-20T15:02:00.000Z"),
result: {
version: 1,
outcome: "rejected",
reason: "Use the project-scoped credential instead.",
secretProposal: {
version: 1,
status: "rejected",
updatedAt: "2026-04-20T15:02:00.000Z",
},
},
});
export const expiredSecretProposalInteraction = createSecretProposalConfirmationInteraction({
id: "interaction-secret-proposal-expired",
status: "expired",
resolvedAt: new Date("2026-05-04T15:02:00.000Z"),
updatedAt: new Date("2026-05-04T15:02:00.000Z"),
secretProposal: { expiresAt: "2026-05-04T15:02:00.000Z" },
result: {
version: 1,
outcome: "superseded_by_newer_request",
secretProposal: {
version: 1,
status: "expired",
updatedAt: "2026-05-04T15:02:00.000Z",
},
},
});
export const commentExpiredRequestConfirmationInteraction = createRequestConfirmationInteraction({
id: "interaction-confirmation-expired-comment",
status: "expired",

View File

@ -18,6 +18,8 @@ export type {
RequestConfirmationIssueDocumentTarget,
RequestConfirmationPayload,
RequestConfirmationResult,
RequestConfirmationSecretProposalPayload,
RequestConfirmationSecretProposalResult,
RequestConfirmationTarget,
RequestConfirmationToolActionPayload,
RequestConfirmationToolActionResult,

View File

@ -16,6 +16,11 @@ import {
expiredToolActionInteraction,
failedRequestConfirmationInteraction,
failedToolActionInteraction,
pendingSecretProposalInteraction,
executedSecretProposalInteraction,
failedSecretProposalInteraction,
rejectedSecretProposalInteraction,
expiredSecretProposalInteraction,
genericPendingRequestConfirmationInteraction,
agentAddressedRequestConfirmationInteraction,
companyCappedRequestConfirmationInteraction,
@ -802,6 +807,116 @@ export const ToolActionLegacyGeneric: Story = {
),
};
function SecretProposalCard({
interaction,
interactive = false,
}: {
interaction: RequestConfirmationInteraction;
interactive?: boolean;
}) {
return (
<IssueThreadInteractionCard
interaction={interaction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
onAcceptInteraction={interactive ? () => undefined : undefined}
onRejectInteraction={interactive ? () => undefined : undefined}
/>
);
}
export const SecretProposalPending: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Pending secret binding"
description="A human reviews safe binding metadata, the agent-authored reason, and expiry before approving the real write."
>
<SecretProposalCard interaction={pendingSecretProposalInteraction} interactive />
</ScenarioCard>
</StoryFrame>
),
};
export const SecretProposalExecuted: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Secret binding executed"
description="Acceptance is only shown as successful after the existing proposal transaction creates the binding."
>
<SecretProposalCard interaction={executedSecretProposalInteraction} />
</ScenarioCard>
</StoryFrame>
),
};
export const SecretProposalFailed: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Secret binding failed"
description="An accepted request that fails execution is unmistakably FAILED and exposes the safe error code."
>
<SecretProposalCard interaction={failedSecretProposalInteraction} />
</ScenarioCard>
</StoryFrame>
),
};
export const SecretProposalRejected: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Secret binding rejected"
description="The rejection reason remains in the thread and the card states that no binding was created."
>
<SecretProposalCard interaction={rejectedSecretProposalInteraction} />
</ScenarioCard>
</StoryFrame>
),
};
export const SecretProposalExpired: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Secret binding expired"
description="Expired proposals are neutral, non-actionable receipts that require a fresh proposal."
>
<SecretProposalCard interaction={expiredSecretProposalInteraction} />
</ScenarioCard>
</StoryFrame>
),
};
export const SecretProposalAllStates: Story = {
render: () => (
<StoryFrame>
<Section eyebrow="Secret binding proposal" title="All lifecycle states">
<div className="grid gap-6 xl:grid-cols-2">
<ScenarioCard title="1 · Pending" description="Safe metadata and approval actions.">
<SecretProposalCard interaction={pendingSecretProposalInteraction} interactive />
</ScenarioCard>
<ScenarioCard title="2 · Executed" description="The binding was created.">
<SecretProposalCard interaction={executedSecretProposalInteraction} />
</ScenarioCard>
<ScenarioCard title="3 · FAILED" description="Accepted, then failed closed.">
<SecretProposalCard interaction={failedSecretProposalInteraction} />
</ScenarioCard>
<ScenarioCard title="4 · Rejected" description="The binding was not created.">
<SecretProposalCard interaction={rejectedSecretProposalInteraction} />
</ScenarioCard>
<ScenarioCard title="5 · Expired" description="A fresh proposal is required.">
<SecretProposalCard interaction={expiredSecretProposalInteraction} />
</ScenarioCard>
</div>
</Section>
</StoryFrame>
),
};
/**
* PAP-17280: the audience row every pending card now carries. `Anyone` is the
* default a requester gets by omitting `resolverPolicy`; the rest are the