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:
parent
b446ff59bf
commit
6b8e42168e
|
|
@ -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
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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'`),
|
||||
|
|
|
|||
|
|
@ -1044,6 +1044,8 @@ export type {
|
|||
RequestConfirmationResult,
|
||||
RequestConfirmationToolActionPayload,
|
||||
RequestConfirmationToolActionResult,
|
||||
RequestConfirmationSecretProposalPayload,
|
||||
RequestConfirmationSecretProposalResult,
|
||||
RequestCheckboxConfirmationOption,
|
||||
RequestCheckboxConfirmationPayload,
|
||||
RequestCheckboxConfirmationResult,
|
||||
|
|
|
|||
|
|
@ -687,6 +687,8 @@ export type {
|
|||
RequestConfirmationResult,
|
||||
RequestConfirmationToolActionPayload,
|
||||
RequestConfirmationToolActionResult,
|
||||
RequestConfirmationSecretProposalPayload,
|
||||
RequestConfirmationSecretProposalResult,
|
||||
RequestCheckboxConfirmationOption,
|
||||
RequestCheckboxConfirmationPayload,
|
||||
RequestCheckboxConfirmationResult,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -446,6 +446,8 @@ export {
|
|||
requestConfirmationPayloadSchema,
|
||||
requestConfirmationResumeFailureSchema,
|
||||
requestConfirmationResultSchema,
|
||||
requestConfirmationSecretProposalPayloadSchema,
|
||||
requestConfirmationSecretProposalResultSchema,
|
||||
requestCheckboxConfirmationOptionSchema,
|
||||
requestCheckboxConfirmationPayloadSchema,
|
||||
requestCheckboxConfirmationResultSchema,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`):
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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?.();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ export type {
|
|||
RequestConfirmationIssueDocumentTarget,
|
||||
RequestConfirmationPayload,
|
||||
RequestConfirmationResult,
|
||||
RequestConfirmationSecretProposalPayload,
|
||||
RequestConfirmationSecretProposalResult,
|
||||
RequestConfirmationTarget,
|
||||
RequestConfirmationToolActionPayload,
|
||||
RequestConfirmationToolActionResult,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue