feat(secrets): add run-bound agent secret access (#9921)
## Thinking Path > - Paperclip is the open source control plane people use to manage AI-agent companies. > - Agents already receive selected company secrets through `env.*` bindings at run launch, but environment injection is ambient, long-lived, and not suitable for every secret consumer. > - The existing binding and secret-access-event models already provide company-scoped authorization and per-resolution audit seams. > - Agents need an explicit way to discover only the secrets granted to them and fetch a value on demand without exposing the wider company catalog. > - That capability must remain run-bound, preserve low-trust token carve-outs, and make every value read visible in both security and operator audit trails. > - This pull request adds an `access.*` delivery namespace, two run-bound agent routes, dual audit logging, documentation, and an operator grants editor. > - The benefit is least-privilege, revocable, auditable secret access while preserving existing env injection behavior. ## Linked Issues or Issue Description No pre-existing public issue. Related work: - Refs #9797 — existing in-sheet agent access UI that this PR extends to distinguish env and API delivery. - Refs #9918 — complementary searchable-agent picker improvement for the same secrets sheet. - Refs #9530 — related company-wide metadata catalog proposal; this PR intentionally exposes only the authenticated run's granted aliases and values. **Problem / motivation:** Agents can currently consume secrets only through process environment injection. This keeps values resident for the run, does not support on-demand consumers, and cannot provide a discrete operator-visible activity event for each agent-initiated read. **Proposed solution:** Treat `company_secret_bindings` as the source of truth for agent secret grants. Keep `env.KEY` as env delivery and add `access.ALIAS` for API-only delivery; an env binding also implies read access because the value is already present in the agent process. Add run-bound list/fetch endpoints that derive scope from the authenticated heartbeat run and never accept caller-selected overlays. **Alternatives considered:** A company-wide agent-readable catalog was rejected for this value path because it increases reconnaissance and does not prove a per-secret grant. Reusing the ephemeral environment-probe resolver was rejected because it lacks binding enforcement. Approval-gated reads and user-scoped secrets remain deferred beyond v1. **Roadmap alignment:** This extends the completed **Secrets Manager with per-agent access** roadmap capability from launch-time env injection to explicit run-bound API delivery without duplicating a separate planned initiative. ## What Changed - Added `access.*` agent binding validation and a dedicated run-bound resolver that combines `secrets:read` authorization with binding-context enforcement. - Added `GET /api/agents/me/secrets` for minimal granted metadata and `POST /api/agents/me/secrets/:key/value` for on-demand value fetches with `Cache-Control: no-store`. - Preserved the existing denials for low-trust review agents, task-bridge credentials, and skill-test tokens; standard long-lived agent API keys cannot call the run-bound routes. - Added dual audit behavior: value attempts write `secret_access_events` and `activity_log` (`secret.value.read`), while metadata listing writes the lighter `secret.access.listed` activity event. - Kept env compatibility: `env.*` remains injected at launch and also implies API read for the same bound agent; `access.*` never becomes an environment variable. - Added the agent-settings **Secret access** editor plus delivery-mode/alias surfacing on the Secrets page, with focused UI tests and tokenized layout styles. - Updated OpenAPI, shared types, agent-facing skill documentation, and API reference documentation. ### UI Screenshots P3 produced and reviewed three screenshots using mock data; images are intentionally not committed to the repository: - `secret-access-editor.png` — agent settings grant editor. - `secret-access-light.png` — Secrets-page delivery surfacing in light mode. - `secret-access-dark.png` — Secrets-page delivery surfacing in dark mode. The source attachments are retained with the implementation task and linked in the internal handoff; the public page publisher was unavailable in the PR-prep runtime. ## Verification - `pnpm exec vitest run server/src/__tests__/agent-secrets-routes.test.ts server/src/__tests__/secrets-service.test.ts server/src/__tests__/secrets-routes.test.ts ui/src/lib/secret-delivery.test.ts ui/src/components/AgentSecretAccessEditor.test.tsx` — 5 files, 122 tests passed. - Security follow-up: `pnpm exec vitest run server/src/__tests__/agent-secrets-routes.test.ts server/src/__tests__/secrets-service.test.ts` — 2 files, 73 tests passed after active-run and version-consistency fixes. - Final-head CI: all feature, typecheck, build, e2e, security, and review gates pass; `General tests (server (1/3))` remains red after one rerun because unrelated `heartbeat-retry-scheduling.test.ts` cleanup deletes `heartbeat_runs` before referenced `activity_log` rows. - `pnpm --filter @paperclipai/server typecheck` — passed. - `pnpm --filter @paperclipai/shared typecheck` — passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm check:token-gates` — feature-local arbitrary-value violations fixed; command still reports five unchanged `#9627` literals outside this PR. - End-to-end QA passed all eight acceptance criteria: grant/list, fetch, dual audit, env-implies-read, denial matrix, revocation, UI rendering, and env-injection regression. Evidence: https://github.com/paperclipai/paperclip/pull/9921#issuecomment-5027455492 - Security review returned PASS-with-required-changes; the implementation uses the required dedicated binding-enforcing resolver, run-bound JWT restriction, run-derived overlays, minimal metadata, and a resolver redaction-registration hook. Evidence: https://github.com/paperclipai/paperclip/pull/9921#issuecomment-5027455382 ## Risks - A compromised agent can exfiltrate any secret explicitly granted to it; explicit company-scoped/run-scoped grants, revocation, and audit reduce but cannot remove that inherent capability risk. - The resolver invokes a redaction-registration hook before returning values, but the current route has no persistent cross-request per-run redaction registry. Paperclip-owned later comments/events therefore cannot yet guarantee automatic scrubbing of a deliberately copied fetched value; QA classified this as non-blocking residual hardening. - Audit-event insertion currently fails open if the security-event insert itself fails; the operator activity event provides partial redundancy, but a future hardening change should define fail-closed behavior for value delivery. - This PR overlaps `ui/src/pages/Secrets.tsx` with #9918 and may require a straightforward rebase after that PR moves. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, `gpt-5.3-codex`, with reasoning, repository tool use, terminal execution, Paperclip API access, and GitHub CLI capabilities. Context-window size is not exposed by the runtime. - Anthropic Claude Opus 4.8 with 1M context and tool use assisted with the UI implementation commit. ## 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> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5ed0b74b34
commit
0b496c9c03
|
|
@ -3,7 +3,62 @@ title: Secrets
|
|||
summary: Secrets CRUD
|
||||
---
|
||||
|
||||
Manage encrypted secrets that agents reference in their environment configuration.
|
||||
Manage encrypted secrets that agents receive through environment bindings or fetch on demand.
|
||||
|
||||
## Agent List and Fetch
|
||||
|
||||
These routes require the current run-bound agent JWT. They are not available to
|
||||
long-lived agent keys, low-trust review agents, task-bridge keys, or skill-test
|
||||
tokens.
|
||||
|
||||
List the secrets accessible to the current run without materializing values:
|
||||
|
||||
```
|
||||
GET /api/agents/me/secrets
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"secrets": [
|
||||
{
|
||||
"key": "github_token",
|
||||
"name": "GitHub token",
|
||||
"description": null,
|
||||
"delivery": "env",
|
||||
"projectionClass": "unclassified",
|
||||
"latestVersion": 2,
|
||||
"versionSelector": "latest",
|
||||
"resolvedVersion": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
Fetch a value only when it is needed. The request has no body and the response
|
||||
uses `Cache-Control: no-store`:
|
||||
|
||||
```
|
||||
POST /api/agents/me/secrets/github_token/value
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "github_token",
|
||||
"value": "decrypted-secret-value",
|
||||
"version": 2
|
||||
}
|
||||
```
|
||||
|
||||
Prefer env injection when the adapter or its child processes need the value on
|
||||
every run. Prefer on-demand fetch for values used only on some runs, large or
|
||||
structured values, or skills and tools that do not inherit adapter env. Every
|
||||
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.
|
||||
|
||||
## List Secrets
|
||||
|
||||
|
|
|
|||
|
|
@ -290,7 +290,7 @@ export interface SecretAccessEvent {
|
|||
credentialSubjectId: string | null;
|
||||
actorType: "agent" | "user" | "system" | "plugin";
|
||||
actorId: string | null;
|
||||
consumerType: SecretBindingTargetType | "plugin_worker";
|
||||
consumerType: SecretBindingTargetType | "agent_api" | "plugin_worker";
|
||||
consumerId: string;
|
||||
configPath: string | null;
|
||||
issueId: string | null;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,245 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
companies,
|
||||
companySecretBindings,
|
||||
companySecretProviderConfigs,
|
||||
companySecretVersions,
|
||||
companySecrets,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
secretAccessEvents,
|
||||
} from "@paperclipai/db";
|
||||
import { LOW_TRUST_REVIEW_PRESET, type AgentApiKeyScope } from "@paperclipai/shared";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { secretRoutes } from "../routes/secrets.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
describeEmbeddedPostgres("agent secret routes", () => {
|
||||
let stopDb: (() => Promise<void>) | null = null;
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
const secretsTmpDir = path.join(os.tmpdir(), `paperclip-agent-secret-routes-${randomUUID()}`);
|
||||
|
||||
beforeAll(async () => {
|
||||
mkdirSync(secretsTmpDir, { recursive: true });
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key");
|
||||
const started = await startEmbeddedPostgresTestDatabase("agent-secret-routes");
|
||||
stopDb = started.cleanup;
|
||||
db = createDb(started.connectionString);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(secretAccessEvents);
|
||||
await db.delete(companySecretBindings);
|
||||
await db.delete(companySecretVersions);
|
||||
await db.delete(companySecrets);
|
||||
await db.delete(companySecretProviderConfigs);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await stopDb?.();
|
||||
if (previousKeyFile === undefined) delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
else process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
|
||||
rmSync(secretsTmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function seedAgentRun(permissions: Record<string, unknown> = {}) {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const heartbeatRunId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Agent secret routes",
|
||||
issuePrefix: `S${companyId.slice(0, 7)}`.toUpperCase(),
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Secret reader",
|
||||
role: "engineer",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
permissions,
|
||||
status: "idle",
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: heartbeatRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "running",
|
||||
contextSnapshot: {},
|
||||
});
|
||||
return { companyId, agentId, heartbeatRunId };
|
||||
}
|
||||
|
||||
function createApp(
|
||||
fixture: Awaited<ReturnType<typeof seedAgentRun>>,
|
||||
keyScope: AgentApiKeyScope = { kind: "standard" },
|
||||
source: "agent_jwt" | "agent_key" = "agent_jwt",
|
||||
) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = {
|
||||
type: "agent",
|
||||
agentId: fixture.agentId,
|
||||
companyId: fixture.companyId,
|
||||
runId: fixture.heartbeatRunId,
|
||||
keyScope,
|
||||
keyId: source === "agent_key" ? randomUUID() : undefined,
|
||||
source,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", secretRoutes(db));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
it("lists metadata only, reads env and access grants, and audits success and failure", async () => {
|
||||
const fixture = await seedAgentRun();
|
||||
const svc = secretService(db);
|
||||
const envSecret = await svc.create(fixture.companyId, {
|
||||
key: "ENV_ONLY_KEY",
|
||||
name: "Env only",
|
||||
description: "Injected and API-readable",
|
||||
provider: "local_encrypted",
|
||||
value: "env-secret-value",
|
||||
});
|
||||
const apiSecret = await svc.create(fixture.companyId, {
|
||||
key: "API_ONLY_KEY",
|
||||
name: "API only",
|
||||
provider: "local_encrypted",
|
||||
value: "api-secret-value",
|
||||
});
|
||||
const unboundSecret = await svc.create(fixture.companyId, {
|
||||
key: "UNBOUND_KEY",
|
||||
name: "Unbound",
|
||||
provider: "local_encrypted",
|
||||
value: "unbound-secret-value",
|
||||
});
|
||||
const projectSecret = await svc.create(fixture.companyId, {
|
||||
key: "PROJECT_KEY",
|
||||
name: "Project layer",
|
||||
provider: "local_encrypted",
|
||||
value: "project-secret-value",
|
||||
});
|
||||
await svc.createBinding({
|
||||
companyId: fixture.companyId,
|
||||
secretId: envSecret.id,
|
||||
targetType: "agent",
|
||||
targetId: fixture.agentId,
|
||||
configPath: "env.ENV_ONLY_KEY",
|
||||
});
|
||||
await svc.createBinding({
|
||||
companyId: fixture.companyId,
|
||||
secretId: apiSecret.id,
|
||||
targetType: "agent",
|
||||
targetId: fixture.agentId,
|
||||
configPath: "access.API_ONLY_KEY",
|
||||
projectionClass: "class_2_runtime_only",
|
||||
});
|
||||
const projectBinding = await svc.createBinding({
|
||||
companyId: fixture.companyId,
|
||||
secretId: projectSecret.id,
|
||||
targetType: "project",
|
||||
targetId: randomUUID(),
|
||||
configPath: "env.PROJECT_KEY",
|
||||
});
|
||||
await db.update(heartbeatRuns).set({
|
||||
contextSnapshot: {
|
||||
paperclipSecrets: {
|
||||
manifest: [{
|
||||
bindingId: projectBinding.id,
|
||||
secretId: projectSecret.id,
|
||||
configPath: projectBinding.configPath,
|
||||
}],
|
||||
},
|
||||
},
|
||||
}).where(eq(heartbeatRuns.id, fixture.heartbeatRunId));
|
||||
|
||||
const list = await request(createApp(fixture)).get("/api/agents/me/secrets");
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.body.secrets).toEqual([
|
||||
expect.objectContaining({ key: "api_only_key", delivery: "api", projectionClass: "class_2_runtime_only" }),
|
||||
expect.objectContaining({ key: "env_only_key", delivery: "env" }),
|
||||
expect.objectContaining({ key: "project_key", delivery: "env" }),
|
||||
]);
|
||||
expect(JSON.stringify(list.body)).not.toContain("secret-value");
|
||||
expect(await db.select().from(secretAccessEvents)).toEqual([]);
|
||||
expect(await db.select().from(activityLog)).toEqual([
|
||||
expect.objectContaining({ action: "secret.access.listed", runId: fixture.heartbeatRunId }),
|
||||
]);
|
||||
|
||||
const fetched = await request(createApp(fixture)).post("/api/agents/me/secrets/env_only_key/value");
|
||||
expect(fetched.status).toBe(200);
|
||||
expect(fetched.headers["cache-control"]).toBe("no-store");
|
||||
expect(fetched.body).toEqual({ key: "env_only_key", value: "env-secret-value", version: 1 });
|
||||
expect(await db.select().from(secretAccessEvents)).toEqual([
|
||||
expect.objectContaining({ secretId: envSecret.id, outcome: "success", consumerType: "agent_api" }),
|
||||
]);
|
||||
expect(await db.select().from(activityLog)).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ action: "secret.value.read", entityId: envSecret.id }),
|
||||
]));
|
||||
|
||||
const projectFetched = await request(createApp(fixture)).post("/api/agents/me/secrets/project_key/value");
|
||||
expect(projectFetched.status).toBe(200);
|
||||
expect(projectFetched.body).toEqual({ key: "project_key", value: "project-secret-value", version: 1 });
|
||||
expect(await db.select().from(secretAccessEvents)).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ secretId: projectSecret.id, outcome: "success", consumerType: "agent_api" }),
|
||||
]));
|
||||
|
||||
const denied = await request(createApp(fixture)).post("/api/agents/me/secrets/unbound_key/value");
|
||||
expect(denied.status).toBe(403);
|
||||
expect(await db.select().from(secretAccessEvents)).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ secretId: unboundSecret.id, outcome: "failure", errorCode: "binding_missing" }),
|
||||
]));
|
||||
expect(await db.select().from(activityLog)).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ action: "secret.value.read", entityId: unboundSecret.id }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("denies low-trust, task-bridge, and skill-test callers on both routes", async () => {
|
||||
const lowTrust = await seedAgentRun({
|
||||
trustPreset: LOW_TRUST_REVIEW_PRESET,
|
||||
authorizationPolicy: { trustBoundary: { mode: LOW_TRUST_REVIEW_PRESET, projectIds: [randomUUID()] } },
|
||||
});
|
||||
const standard = await seedAgentRun();
|
||||
const cases = [
|
||||
{ name: "low trust", fixture: lowTrust, scope: { kind: "standard" } as const, source: "agent_jwt" as const },
|
||||
{ name: "task bridge", fixture: standard, scope: { kind: "task_bridge", parentIssueId: randomUUID() } as const, source: "agent_key" as const },
|
||||
{ name: "skill test", fixture: standard, scope: { kind: "skill_test", issueId: randomUUID() } as const, source: "agent_jwt" as const },
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
expect(
|
||||
(await request(createApp(testCase.fixture, testCase.scope, testCase.source)).get("/api/agents/me/secrets")).status,
|
||||
`${testCase.name} list`,
|
||||
).toBe(403);
|
||||
expect(
|
||||
(await request(createApp(testCase.fixture, testCase.scope, testCase.source)).post("/api/agents/me/secrets/ANY/value")).status,
|
||||
`${testCase.name} fetch`,
|
||||
).toBe(403);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -100,13 +100,33 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
|
|||
});
|
||||
|
||||
async function cleanupRetryFixture() {
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
await cleanupRetryFixtureOnce();
|
||||
return;
|
||||
} catch (error) {
|
||||
if (attempt === 4) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupHeartbeatRunDependents() {
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(activityLog);
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(activityLog);
|
||||
}
|
||||
|
||||
async function cleanupRetryFixtureOnce() {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(environmentLeases);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(issues);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await cleanupHeartbeatRunDependents();
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(agentRuntimeState);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const mockSecretService = vi.hoisted(() => ({
|
|||
setDefaultProviderConfig: vi.fn(),
|
||||
checkProviderConfigHealth: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
getByKey: vi.fn(),
|
||||
create: vi.fn(),
|
||||
rotate: vi.fn(),
|
||||
update: vi.fn(),
|
||||
|
|
@ -36,6 +37,8 @@ const mockSecretService = vi.hoisted(() => ({
|
|||
importRemoteSecrets: vi.fn(),
|
||||
listBindingReferences: vi.fn(),
|
||||
listAccessEvents: vi.fn(),
|
||||
listAgentSecretAccess: vi.fn(),
|
||||
resolveSecretValueForAgentAccess: vi.fn(),
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import path from "node:path";
|
|||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
companies,
|
||||
companyMemberships,
|
||||
|
|
@ -13,10 +14,12 @@ import {
|
|||
companySecretVersions,
|
||||
companySecrets,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
secretAccessEvents,
|
||||
userSecretDeclarations,
|
||||
userSecretDefinitions,
|
||||
} from "@paperclipai/db";
|
||||
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { awsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js";
|
||||
import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js";
|
||||
|
|
@ -48,6 +51,7 @@ describeEmbeddedPostgres("secretService", () => {
|
|||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await db.delete(activityLog);
|
||||
await db.delete(secretAccessEvents);
|
||||
await db.delete(userSecretDeclarations);
|
||||
await db.delete(companySecretBindings);
|
||||
|
|
@ -56,6 +60,7 @@ describeEmbeddedPostgres("secretService", () => {
|
|||
await db.delete(userSecretDefinitions);
|
||||
await db.delete(companySecretProviderConfigs);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
|
@ -99,6 +104,33 @@ describeEmbeddedPostgres("secretService", () => {
|
|||
});
|
||||
}
|
||||
|
||||
async function seedAgentRun(companyId: string, permissions: Record<string, unknown> = {}) {
|
||||
const agentId = randomUUID();
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Secret reader",
|
||||
role: "engineer",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
permissions,
|
||||
status: "idle",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
const heartbeatRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: heartbeatRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "running",
|
||||
startedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
return { agentId, heartbeatRunId };
|
||||
}
|
||||
|
||||
it("rejects cross-company secret references during env normalization", async () => {
|
||||
const companyA = await seedCompany("A");
|
||||
const companyB = await seedCompany("B");
|
||||
|
|
@ -149,6 +181,211 @@ describeEmbeddedPostgres("secretService", () => {
|
|||
).rejects.toThrow(/already exists/i);
|
||||
});
|
||||
|
||||
it("validates the access namespace as agent-only with env-style aliases", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const secret = await svc.create(companyId, {
|
||||
name: `access-validation-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: "runtime-secret",
|
||||
});
|
||||
|
||||
await expect(svc.createBinding({
|
||||
companyId,
|
||||
secretId: secret.id,
|
||||
targetType: "project",
|
||||
targetId: randomUUID(),
|
||||
configPath: "access.API_KEY",
|
||||
})).rejects.toThrow(/must target an agent/i);
|
||||
|
||||
await expect(svc.createBinding({
|
||||
companyId,
|
||||
secretId: secret.id,
|
||||
targetType: "agent",
|
||||
targetId: randomUUID(),
|
||||
configPath: "access.invalid-alias",
|
||||
})).rejects.toThrow(/invalid agent secret access alias/i);
|
||||
});
|
||||
|
||||
it("resolves env and access bindings through the run-bound agent resolver with dual audit", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const { agentId, heartbeatRunId } = await seedAgentRun(companyId);
|
||||
const secret = await svc.create(companyId, {
|
||||
name: `agent-read-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: "runtime-secret",
|
||||
});
|
||||
await svc.createBinding({
|
||||
companyId,
|
||||
secretId: secret.id,
|
||||
targetType: "agent",
|
||||
targetId: agentId,
|
||||
configPath: "access.API_KEY",
|
||||
});
|
||||
await svc.createBinding({
|
||||
companyId,
|
||||
secretId: secret.id,
|
||||
targetType: "agent",
|
||||
targetId: agentId,
|
||||
configPath: "env.API_KEY",
|
||||
});
|
||||
const redactedValues: string[] = [];
|
||||
|
||||
for (const configPath of ["access.API_KEY", "env.API_KEY"]) {
|
||||
await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", {
|
||||
agentId,
|
||||
configPath,
|
||||
actorSource: "agent_jwt",
|
||||
heartbeatRunId,
|
||||
registerForRedaction: (value) => redactedValues.push(value),
|
||||
})).resolves.toEqual({ value: "runtime-secret", version: 1 });
|
||||
}
|
||||
|
||||
expect(redactedValues).toEqual(["runtime-secret", "runtime-secret"]);
|
||||
const events = await svc.listAccessEvents(companyId, secret.id);
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
consumerType: "agent_api",
|
||||
consumerId: agentId,
|
||||
configPath: "access.API_KEY",
|
||||
actorType: "agent",
|
||||
actorId: agentId,
|
||||
heartbeatRunId,
|
||||
outcome: "success",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
consumerType: "agent_api",
|
||||
consumerId: agentId,
|
||||
configPath: "env.API_KEY",
|
||||
outcome: "success",
|
||||
}),
|
||||
]));
|
||||
const activities = await db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.entityId, secret.id));
|
||||
expect(activities).toHaveLength(2);
|
||||
expect(activities.every((entry) => entry.action === "secret.value.read")).toBe(true);
|
||||
expect(activities.every((entry) => entry.runId === heartbeatRunId)).toBe(true);
|
||||
expect(JSON.stringify([...events, ...activities])).not.toContain("runtime-secret");
|
||||
});
|
||||
|
||||
it("rejects long-lived, mismatched-run, and unbound agent secret reads", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const { agentId, heartbeatRunId } = await seedAgentRun(companyId);
|
||||
const secret = await svc.create(companyId, {
|
||||
name: `agent-read-denied-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: "runtime-secret",
|
||||
});
|
||||
await svc.createBinding({
|
||||
companyId,
|
||||
secretId: secret.id,
|
||||
targetType: "agent",
|
||||
targetId: agentId,
|
||||
configPath: "access.GRANTED",
|
||||
});
|
||||
const registerForRedaction = vi.fn();
|
||||
|
||||
await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", {
|
||||
agentId,
|
||||
configPath: "access.API_KEY",
|
||||
actorSource: "agent_key",
|
||||
heartbeatRunId,
|
||||
registerForRedaction,
|
||||
})).rejects.toThrow(/run-bound agent token/i);
|
||||
|
||||
await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", {
|
||||
agentId,
|
||||
configPath: "access.GRANTED",
|
||||
actorSource: "agent_jwt",
|
||||
keyScope: { kind: "skill_test", issueId: randomUUID() },
|
||||
heartbeatRunId,
|
||||
registerForRedaction,
|
||||
})).rejects.toThrow(/skill-test.*secret/i);
|
||||
|
||||
await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", {
|
||||
agentId,
|
||||
configPath: "access.API_KEY",
|
||||
actorSource: "agent_jwt",
|
||||
heartbeatRunId: randomUUID(),
|
||||
registerForRedaction,
|
||||
})).rejects.toThrow(/verified heartbeat run/i);
|
||||
|
||||
await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", {
|
||||
agentId,
|
||||
configPath: "access.API_KEY",
|
||||
actorSource: "agent_jwt",
|
||||
heartbeatRunId,
|
||||
registerForRedaction,
|
||||
})).rejects.toThrow(/not granted/i);
|
||||
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, heartbeatRunId));
|
||||
await expect(svc.listAgentSecretAccess(companyId, {
|
||||
agentId,
|
||||
actorSource: "agent_jwt",
|
||||
heartbeatRunId,
|
||||
})).rejects.toThrow(/verified heartbeat run/i);
|
||||
await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", {
|
||||
agentId,
|
||||
configPath: "access.GRANTED",
|
||||
actorSource: "agent_jwt",
|
||||
heartbeatRunId,
|
||||
registerForRedaction,
|
||||
})).rejects.toThrow(/verified heartbeat run/i);
|
||||
|
||||
expect(registerForRedaction).not.toHaveBeenCalled();
|
||||
const events = await svc.listAccessEvents(companyId, secret.id);
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({
|
||||
consumerType: "agent_api",
|
||||
consumerId: agentId,
|
||||
configPath: "access.API_KEY",
|
||||
outcome: "failure",
|
||||
errorCode: "binding_missing",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves low-trust authorization denial for agent secret reads", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const { agentId, heartbeatRunId } = await seedAgentRun(companyId, {
|
||||
trustPreset: LOW_TRUST_REVIEW_PRESET,
|
||||
authorizationPolicy: {
|
||||
trustBoundary: {
|
||||
mode: LOW_TRUST_REVIEW_PRESET,
|
||||
projectIds: [randomUUID()],
|
||||
},
|
||||
},
|
||||
});
|
||||
const secret = await svc.create(companyId, {
|
||||
name: `low-trust-agent-read-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: "runtime-secret",
|
||||
});
|
||||
await svc.createBinding({
|
||||
companyId,
|
||||
secretId: secret.id,
|
||||
targetType: "agent",
|
||||
targetId: agentId,
|
||||
configPath: "access.API_KEY",
|
||||
});
|
||||
|
||||
await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", {
|
||||
agentId,
|
||||
configPath: "access.API_KEY",
|
||||
actorSource: "agent_jwt",
|
||||
heartbeatRunId,
|
||||
registerForRedaction: vi.fn(),
|
||||
})).rejects.toThrow(/low[_-]trust.*secrets:read/i);
|
||||
|
||||
expect(await svc.listAccessEvents(companyId, secret.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it("syncs top-level secret refs idempotently", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
|
|
|
|||
|
|
@ -1503,6 +1503,47 @@ registry.registerPath({
|
|||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
});
|
||||
|
||||
const AgentSecretListResponseSchema = z.object({
|
||||
secrets: z.array(z.object({
|
||||
key: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
delivery: z.enum(["env", "api", "both"]),
|
||||
projectionClass: z.string(),
|
||||
latestVersion: z.number().int().nonnegative(),
|
||||
versionSelector: z.union([z.literal("latest"), z.number().int().positive()]),
|
||||
resolvedVersion: z.number().int().positive(),
|
||||
})),
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/agents/me/secrets",
|
||||
tags: ["secrets"],
|
||||
summary: "List secrets accessible to the current agent run",
|
||||
responses: {
|
||||
200: { description: "Accessible secret metadata", content: { "application/json": { schema: AgentSecretListResponseSchema } } },
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/agents/me/secrets/{key}/value",
|
||||
tags: ["secrets"],
|
||||
summary: "Fetch one secret value for the current agent run",
|
||||
request: { params: z.object({ key: z.string() }) },
|
||||
responses: {
|
||||
200: {
|
||||
description: "Decrypted secret value",
|
||||
content: { "application/json": { schema: z.object({ key: z.string(), value: z.string(), version: z.number().int().positive() }) } },
|
||||
},
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/agents/me/connections/{connectionId}/token",
|
||||
|
|
|
|||
|
|
@ -60,6 +60,66 @@ export function secretRoutes(db: Db) {
|
|||
const svc = secretService(db);
|
||||
const defaultProvider = getConfiguredSecretProvider();
|
||||
|
||||
function agentSecretContext(req: Parameters<typeof assertBoard>[0]) {
|
||||
if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId || !req.actor.runId) {
|
||||
throw forbidden("Run-bound agent authentication required");
|
||||
}
|
||||
return {
|
||||
companyId: req.actor.companyId,
|
||||
agentId: req.actor.agentId,
|
||||
actorSource: req.actor.source === "agent_jwt" ? "agent_jwt" as const : "agent_key" as const,
|
||||
keyId: req.actor.keyId ?? null,
|
||||
keyScope: req.actor.keyScope ?? null,
|
||||
heartbeatRunId: req.actor.runId,
|
||||
responsibleUserId: req.actor.onBehalfOfUserId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
router.get("/agents/me/secrets", async (req, res) => {
|
||||
const context = agentSecretContext(req);
|
||||
const secrets = await svc.listAgentSecretAccess(context.companyId, context);
|
||||
await logActivity(db, {
|
||||
companyId: context.companyId,
|
||||
actorType: "agent",
|
||||
actorId: context.agentId,
|
||||
action: "secret.access.listed",
|
||||
entityType: "agent",
|
||||
entityId: context.agentId,
|
||||
agentId: context.agentId,
|
||||
runId: context.heartbeatRunId,
|
||||
details: { count: secrets.length },
|
||||
});
|
||||
res.json({
|
||||
secrets: secrets.map(({ secretId: _secretId, bindingId: _bindingId, configPath: _configPath, ...secret }) => secret),
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/agents/me/secrets/:key/value", async (req, res) => {
|
||||
const context = agentSecretContext(req);
|
||||
const available = await svc.listAgentSecretAccess(context.companyId, context);
|
||||
const secret = available.find((entry) => entry.key === req.params.key);
|
||||
const unresolvedSecret = secret ? null : await svc.getByKey(context.companyId, req.params.key);
|
||||
if (!secret && !unresolvedSecret) throw forbidden("Secret access is not granted for this agent");
|
||||
const resolution = await svc.resolveSecretValueForAgentAccess(
|
||||
context.companyId,
|
||||
secret?.secretId ?? unresolvedSecret!.id,
|
||||
secret?.versionSelector ?? "latest",
|
||||
{
|
||||
...context,
|
||||
configPath: secret?.configPath ?? `access.${req.params.key}`,
|
||||
bindingId: secret?.bindingId ?? null,
|
||||
issueId: null,
|
||||
registerForRedaction: () => undefined,
|
||||
},
|
||||
);
|
||||
res.set("Cache-Control", "no-store");
|
||||
res.json({
|
||||
key: secret?.key ?? unresolvedSecret!.key,
|
||||
value: resolution.value,
|
||||
version: resolution.version,
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/secret-providers", (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
userSecretDefinitions,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
AgentApiKeyScope,
|
||||
AgentEnvConfig,
|
||||
CompanySecretBindingTarget,
|
||||
EnvBinding,
|
||||
|
|
@ -62,8 +63,10 @@ import type {
|
|||
import { isSecretProviderClientError } from "../secrets/types.js";
|
||||
import { authorizationDeniedDetails, authorizationService } from "./authorization.js";
|
||||
import { findActiveServerAdapter } from "../adapters/index.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
|
||||
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const AGENT_ACCESS_CONFIG_PATH_PREFIX = "access.";
|
||||
const SENSITIVE_ENV_KEY_RE =
|
||||
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
|
||||
const REDACTED_SENTINEL = "***REDACTED***";
|
||||
|
|
@ -394,7 +397,7 @@ type CanonicalEnvBinding =
|
|||
allowMissingOverride: boolean;
|
||||
};
|
||||
|
||||
type SecretAccessConsumerType = SecretBindingTargetType | "plugin_worker";
|
||||
type SecretAccessConsumerType = SecretBindingTargetType | "agent_api" | "plugin_worker";
|
||||
|
||||
type SecretConsumerContext = {
|
||||
consumerType: SecretAccessConsumerType;
|
||||
|
|
@ -420,6 +423,33 @@ type SecretResolutionOptions = {
|
|||
allowUserSecretScope?: boolean;
|
||||
};
|
||||
|
||||
export type AgentSecretReadContext = {
|
||||
agentId: string;
|
||||
configPath: string;
|
||||
bindingId?: string | null;
|
||||
actorSource: "agent_jwt" | "agent_key";
|
||||
keyId?: string | null;
|
||||
keyScope?: AgentApiKeyScope | null;
|
||||
heartbeatRunId: string;
|
||||
issueId?: string | null;
|
||||
responsibleUserId?: string | null;
|
||||
registerForRedaction: (value: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export type AgentSecretAccessEntry = {
|
||||
secretId: string;
|
||||
bindingId: string;
|
||||
configPath: string;
|
||||
key: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
delivery: "env" | "api" | "both";
|
||||
projectionClass: SecretProjectionClass;
|
||||
latestVersion: number;
|
||||
versionSelector: SecretVersionSelector;
|
||||
resolvedVersion: number;
|
||||
};
|
||||
|
||||
type ResolveAdapterConfigForRuntimeOptions = {
|
||||
adapterType?: string | null;
|
||||
skipUserSecrets?: boolean;
|
||||
|
|
@ -454,7 +484,9 @@ export type MissingRuntimeBinding = {
|
|||
};
|
||||
|
||||
function missingRuntimeConsumerType(consumerType: SecretAccessConsumerType): SecretBindingTargetType {
|
||||
return consumerType === "plugin_worker" ? "plugin" : consumerType;
|
||||
if (consumerType === "plugin_worker") return "plugin";
|
||||
if (consumerType === "agent_api") return "agent";
|
||||
return consumerType;
|
||||
}
|
||||
|
||||
type RuntimeSecretResolution = {
|
||||
|
|
@ -595,6 +627,20 @@ function secretResolutionErrorCode(error: unknown): SecretResolutionErrorCode {
|
|||
return "provider_error";
|
||||
}
|
||||
|
||||
function assertSecretBindingConfigPath(input: {
|
||||
targetType: SecretBindingTargetType;
|
||||
configPath: string;
|
||||
}) {
|
||||
if (!input.configPath.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) return;
|
||||
if (input.targetType !== "agent") {
|
||||
throw unprocessable("API-only secret access bindings must target an agent");
|
||||
}
|
||||
const alias = input.configPath.slice(AGENT_ACCESS_CONFIG_PATH_PREFIX.length);
|
||||
if (!ENV_KEY_RE.test(alias)) {
|
||||
throw unprocessable(`Invalid agent secret access alias: ${alias || "(empty)"}`);
|
||||
}
|
||||
}
|
||||
|
||||
function missingUserSecretDefinitionRuntimeBinding(
|
||||
entry: {
|
||||
key: string;
|
||||
|
|
@ -670,6 +716,19 @@ export function secretService(db: Db) {
|
|||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function getByKey(companyId: string, key: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(companySecrets)
|
||||
.where(and(
|
||||
eq(companySecrets.companyId, companyId),
|
||||
eq(companySecrets.key, key),
|
||||
eq(companySecrets.scope, "company"),
|
||||
ne(companySecrets.status, "deleted"),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function getUserSecretDefinitionById(
|
||||
companyId: string,
|
||||
definitionId: string,
|
||||
|
|
@ -1174,6 +1233,278 @@ export function secretService(db: Db) {
|
|||
})).value;
|
||||
}
|
||||
|
||||
async function resolveSecretValueForAgentAccess(
|
||||
companyId: string,
|
||||
secretId: string,
|
||||
version: number | "latest",
|
||||
context: AgentSecretReadContext,
|
||||
): Promise<{ value: string; version: number }> {
|
||||
if (context.actorSource !== "agent_jwt") {
|
||||
throw forbidden("Agent secret access requires a run-bound agent token");
|
||||
}
|
||||
if (!isUuidLike(context.heartbeatRunId)) {
|
||||
throw forbidden("Agent secret access requires a verified heartbeat run");
|
||||
}
|
||||
if (!context.configPath.startsWith("env.") && !context.configPath.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) {
|
||||
throw forbidden("Secret access is not granted for this binding path");
|
||||
}
|
||||
assertSecretBindingConfigPath({ targetType: "agent", configPath: context.configPath });
|
||||
|
||||
const run = await db
|
||||
.select({ id: heartbeatRuns.id, contextSnapshot: heartbeatRuns.contextSnapshot })
|
||||
.from(heartbeatRuns)
|
||||
.where(and(
|
||||
eq(heartbeatRuns.id, context.heartbeatRunId),
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
eq(heartbeatRuns.agentId, context.agentId),
|
||||
eq(heartbeatRuns.status, "running"),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!run) {
|
||||
throw forbidden("Agent secret access requires a verified heartbeat run");
|
||||
}
|
||||
|
||||
const decision = await authorization.decide({
|
||||
actor: {
|
||||
type: "agent",
|
||||
agentId: context.agentId,
|
||||
companyId,
|
||||
source: "agent_jwt",
|
||||
keyId: context.keyId ?? null,
|
||||
keyScope: context.keyScope ?? null,
|
||||
runId: context.heartbeatRunId,
|
||||
},
|
||||
action: "secrets:read",
|
||||
resource: { type: "company", companyId },
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
}
|
||||
|
||||
let bindingContext: SecretBindingContext = {
|
||||
consumerType: "agent",
|
||||
consumerId: context.agentId,
|
||||
configPath: context.configPath,
|
||||
responsibleUserId: context.responsibleUserId ?? null,
|
||||
actorType: "agent",
|
||||
actorId: context.agentId,
|
||||
actorSource: context.actorSource,
|
||||
issueId: context.issueId ?? null,
|
||||
heartbeatRunId: context.heartbeatRunId,
|
||||
};
|
||||
if (context.bindingId) {
|
||||
const binding = await db
|
||||
.select()
|
||||
.from(companySecretBindings)
|
||||
.where(and(
|
||||
eq(companySecretBindings.id, context.bindingId),
|
||||
eq(companySecretBindings.companyId, companyId),
|
||||
eq(companySecretBindings.secretId, secretId),
|
||||
eq(companySecretBindings.configPath, context.configPath),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!binding) throw forbidden("Secret access is not granted for this agent");
|
||||
|
||||
const runContext = asRecord(run.contextSnapshot) ?? {};
|
||||
const manifest = (asRecord(runContext.paperclipSecrets) ?? {}).manifest;
|
||||
const manifestBindingIds = new Set(
|
||||
Array.isArray(manifest)
|
||||
? manifest.flatMap((entry) => {
|
||||
const record = asRecord(entry) ?? {};
|
||||
return typeof record.bindingId === "string" ? [record.bindingId] : [];
|
||||
})
|
||||
: [],
|
||||
);
|
||||
const isDirectAgentBinding = binding.targetType === "agent" && binding.targetId === context.agentId;
|
||||
if (!isDirectAgentBinding && !manifestBindingIds.has(binding.id)) {
|
||||
throw forbidden("Secret access is not granted for this agent run");
|
||||
}
|
||||
bindingContext = {
|
||||
...bindingContext,
|
||||
consumerType: binding.targetType as SecretBindingTargetType,
|
||||
consumerId: binding.targetId,
|
||||
};
|
||||
}
|
||||
|
||||
const runContext = asRecord(run.contextSnapshot) ?? {};
|
||||
const effectiveIssueId = context.issueId ?? (
|
||||
typeof runContext.issueId === "string"
|
||||
? runContext.issueId
|
||||
: typeof (asRecord(runContext.paperclipIssue) ?? {}).id === "string"
|
||||
? String((asRecord(runContext.paperclipIssue) ?? {}).id)
|
||||
: null
|
||||
);
|
||||
bindingContext.issueId = effectiveIssueId;
|
||||
|
||||
const accessContext: SecretConsumerContext = {
|
||||
consumerType: "agent_api",
|
||||
consumerId: context.agentId,
|
||||
configPath: context.configPath,
|
||||
responsibleUserId: context.responsibleUserId ?? null,
|
||||
actorType: "agent",
|
||||
actorId: context.agentId,
|
||||
actorSource: context.actorSource,
|
||||
issueId: effectiveIssueId,
|
||||
heartbeatRunId: context.heartbeatRunId,
|
||||
};
|
||||
|
||||
try {
|
||||
const resolution = await resolveSecretValueInternal(companyId, secretId, version, {
|
||||
bindingContext,
|
||||
accessContext,
|
||||
});
|
||||
await context.registerForRedaction(resolution.value);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "agent",
|
||||
actorId: context.agentId,
|
||||
action: "secret.value.read",
|
||||
entityType: "secret",
|
||||
entityId: secretId,
|
||||
agentId: context.agentId,
|
||||
runId: context.heartbeatRunId,
|
||||
issueId: effectiveIssueId,
|
||||
details: {
|
||||
configPath: context.configPath,
|
||||
outcome: "success",
|
||||
version: resolution.manifestEntry.version,
|
||||
},
|
||||
});
|
||||
return {
|
||||
value: resolution.value,
|
||||
version: resolution.manifestEntry.version,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorCode = secretResolutionErrorCode(error);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "agent",
|
||||
actorId: context.agentId,
|
||||
action: "secret.value.read",
|
||||
entityType: "secret",
|
||||
entityId: secretId,
|
||||
agentId: context.agentId,
|
||||
runId: context.heartbeatRunId,
|
||||
issueId: effectiveIssueId,
|
||||
details: {
|
||||
configPath: context.configPath,
|
||||
outcome: "failure",
|
||||
errorCode,
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
if (errorCode === "binding_missing" || errorCode === "secret_scope_invalid") {
|
||||
throw forbidden("Secret access is not granted for this agent");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function listAgentSecretAccess(
|
||||
companyId: string,
|
||||
context: Omit<AgentSecretReadContext, "configPath" | "bindingId" | "registerForRedaction">,
|
||||
): Promise<AgentSecretAccessEntry[]> {
|
||||
if (context.actorSource !== "agent_jwt" || !isUuidLike(context.heartbeatRunId)) {
|
||||
throw forbidden("Agent secret access requires a run-bound agent token");
|
||||
}
|
||||
const run = await db
|
||||
.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
|
||||
.from(heartbeatRuns)
|
||||
.where(and(
|
||||
eq(heartbeatRuns.id, context.heartbeatRunId),
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
eq(heartbeatRuns.agentId, context.agentId),
|
||||
eq(heartbeatRuns.status, "running"),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!run) throw forbidden("Agent secret access requires a verified heartbeat run");
|
||||
|
||||
const decision = await authorization.decide({
|
||||
actor: {
|
||||
type: "agent",
|
||||
agentId: context.agentId,
|
||||
companyId,
|
||||
source: "agent_jwt",
|
||||
keyId: context.keyId ?? null,
|
||||
keyScope: context.keyScope ?? null,
|
||||
runId: context.heartbeatRunId,
|
||||
},
|
||||
action: "secrets:read",
|
||||
resource: { type: "company", companyId },
|
||||
});
|
||||
if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
|
||||
const runContext = asRecord(run.contextSnapshot) ?? {};
|
||||
const manifest = (asRecord(runContext.paperclipSecrets) ?? {}).manifest;
|
||||
const manifestBindingIds = Array.isArray(manifest)
|
||||
? manifest.flatMap((entry) => {
|
||||
const bindingId = (asRecord(entry) ?? {}).bindingId;
|
||||
return typeof bindingId === "string" ? [bindingId] : [];
|
||||
})
|
||||
: [];
|
||||
const [directBindings, runtimeBindings] = await Promise.all([
|
||||
db.select().from(companySecretBindings).where(and(
|
||||
eq(companySecretBindings.companyId, companyId),
|
||||
eq(companySecretBindings.targetType, "agent"),
|
||||
eq(companySecretBindings.targetId, context.agentId),
|
||||
or(
|
||||
like(companySecretBindings.configPath, "env.%"),
|
||||
like(companySecretBindings.configPath, `${AGENT_ACCESS_CONFIG_PATH_PREFIX}%`),
|
||||
),
|
||||
)),
|
||||
manifestBindingIds.length > 0
|
||||
? db.select().from(companySecretBindings).where(and(
|
||||
eq(companySecretBindings.companyId, companyId),
|
||||
inArray(companySecretBindings.id, manifestBindingIds),
|
||||
))
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const bindings = [...new Map([...directBindings, ...runtimeBindings].map((binding) => [binding.id, binding])).values()];
|
||||
if (bindings.length === 0) return [];
|
||||
|
||||
const secrets = await db
|
||||
.select()
|
||||
.from(companySecrets)
|
||||
.where(and(
|
||||
eq(companySecrets.companyId, companyId),
|
||||
eq(companySecrets.scope, "company"),
|
||||
eq(companySecrets.status, "active"),
|
||||
inArray(companySecrets.id, [...new Set(bindings.map((binding) => binding.secretId))]),
|
||||
));
|
||||
const secretsById = new Map(secrets.map((secret) => [secret.id, secret]));
|
||||
const bindingsBySecret = new Map<string, typeof bindings>();
|
||||
for (const binding of bindings) {
|
||||
const current = bindingsBySecret.get(binding.secretId) ?? [];
|
||||
current.push(binding);
|
||||
bindingsBySecret.set(binding.secretId, current);
|
||||
}
|
||||
|
||||
return [...bindingsBySecret.entries()].flatMap(([secretId, secretBindings]) => {
|
||||
const secret = secretsById.get(secretId);
|
||||
if (!secret) return [];
|
||||
const accessBinding = secretBindings.find((binding) => binding.configPath.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX));
|
||||
const selectedBinding = accessBinding ?? secretBindings[0];
|
||||
const hasEnv = secretBindings.some((binding) => binding.configPath.startsWith("env."));
|
||||
const hasApi = Boolean(accessBinding);
|
||||
const versionSelector: SecretVersionSelector = selectedBinding.versionSelector === "latest"
|
||||
? "latest"
|
||||
: Number(selectedBinding.versionSelector);
|
||||
const delivery: AgentSecretAccessEntry["delivery"] = hasEnv && hasApi ? "both" : hasEnv ? "env" : "api";
|
||||
return [{
|
||||
secretId,
|
||||
bindingId: selectedBinding.id,
|
||||
configPath: selectedBinding.configPath,
|
||||
key: secret.key,
|
||||
name: secret.name,
|
||||
description: secret.description ?? null,
|
||||
delivery,
|
||||
projectionClass: (selectedBinding.projectionClass ?? "unclassified") as SecretProjectionClass,
|
||||
latestVersion: secret.latestVersion,
|
||||
versionSelector,
|
||||
resolvedVersion: versionSelector === "latest" ? secret.latestVersion : versionSelector,
|
||||
}];
|
||||
}).sort((left, right) => left.key.localeCompare(right.key));
|
||||
}
|
||||
|
||||
async function resolveSecretVersion(
|
||||
companyId: string,
|
||||
secretId: string,
|
||||
|
|
@ -3018,8 +3349,11 @@ export function secretService(db: Db) {
|
|||
|
||||
getById,
|
||||
getByName,
|
||||
getByKey,
|
||||
resolveSecretValue,
|
||||
resolveSecretVersion,
|
||||
resolveSecretValueForAgentAccess,
|
||||
listAgentSecretAccess,
|
||||
resolveSecretValueForEphemeralAccess,
|
||||
|
||||
create: async (
|
||||
|
|
@ -3498,6 +3832,7 @@ export function secretService(db: Db) {
|
|||
projectionAllowlistKey?: string | null;
|
||||
}) => {
|
||||
await assertSecretInCompany(input.companyId, input.secretId);
|
||||
assertSecretBindingConfigPath(input);
|
||||
assertClass3StaticLeaseAllowed({
|
||||
targetType: input.targetType,
|
||||
configPath: input.configPath,
|
||||
|
|
@ -3560,6 +3895,7 @@ export function secretService(db: Db) {
|
|||
}> = [];
|
||||
for (const ref of refs) {
|
||||
await assertSecretInCompany(companyId, ref.secretId);
|
||||
assertSecretBindingConfigPath({ targetType: target.targetType, configPath: ref.configPath });
|
||||
const projectionClass = ref.projectionClass ?? "unclassified";
|
||||
const projectionAllowlistKey = ref.projectionAllowlistKey ?? null;
|
||||
assertClass3StaticLeaseAllowed({
|
||||
|
|
|
|||
|
|
@ -352,6 +352,32 @@ When an issue needs browser/manual QA or a preview server, inspect its current e
|
|||
For commands, response fields, and MCP tools, read:
|
||||
`skills/paperclip/references/issue-workspaces.md`
|
||||
|
||||
## Reading Granted Secrets
|
||||
|
||||
When authenticated with the current run's agent JWT, list the secrets available to that run before fetching a value:
|
||||
|
||||
```bash
|
||||
PAPERCLIP_API_BASE="${PAPERCLIP_API_URL%/}"
|
||||
PAPERCLIP_API_BASE="${PAPERCLIP_API_BASE%/api}"
|
||||
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_BASE/api/agents/me/secrets"
|
||||
```
|
||||
|
||||
The list is metadata-only. Fetch a specific value only when needed; the request has no body:
|
||||
|
||||
```bash
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_BASE/api/agents/me/secrets/github_token/value"
|
||||
```
|
||||
|
||||
- An `env.*` secret binding also grants API read access; `access.*` bindings grant API access without env injection.
|
||||
- Prefer env injection for values needed on every run by the adapter or its child processes.
|
||||
- Prefer on-demand fetch for values used only on some runs, large or structured values, or skills/tools that do not inherit adapter env.
|
||||
- Every value fetch, including failures, is audited in `secret_access_events` and `activity_log`; never print, persist, or paste fetched values into task comments.
|
||||
- These endpoints require the current run-bound agent JWT. Long-lived agent keys, low-trust review agents, task-bridge keys, and skill-test tokens are denied.
|
||||
|
||||
Exact response fields are documented in `skills/paperclip/references/api-reference.md`.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- **Never retry a 409.** The task belongs to someone else.
|
||||
|
|
|
|||
|
|
@ -1293,6 +1293,43 @@ Terminal states: `done`, `cancelled`
|
|||
| GET | `/api/companies/:companyId/secrets` | List secrets (metadata only) |
|
||||
| POST | `/api/companies/:companyId/secrets` | Create secret |
|
||||
| PATCH | `/api/secrets/:secretId` | Update secret value (creates new version) |
|
||||
| GET | `/api/agents/me/secrets` | List secrets accessible to the current run (metadata only) |
|
||||
| POST | `/api/agents/me/secrets/:key/value` | Fetch one granted secret value; request body is empty |
|
||||
|
||||
Agent secret access requires the current run-bound agent JWT. An `env.*` binding implies API read access; an `access.*` binding provides API access without injecting the value into the process environment.
|
||||
|
||||
List response:
|
||||
|
||||
```json
|
||||
{
|
||||
"secrets": [
|
||||
{
|
||||
"key": "github_token",
|
||||
"name": "GitHub token",
|
||||
"description": null,
|
||||
"delivery": "env",
|
||||
"projectionClass": "unclassified",
|
||||
"latestVersion": 2,
|
||||
"versionSelector": "latest",
|
||||
"resolvedVersion": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
Value response (`Cache-Control: no-store`):
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "github_token",
|
||||
"value": "decrypted-secret-value",
|
||||
"version": 2
|
||||
}
|
||||
```
|
||||
|
||||
Every successful or failed value fetch writes both `secret_access_events` and `activity_log.action = secret.value.read`. Prefer on-demand fetch for occasional, large, structured, or non-env-inheriting consumers; keep env injection for values required on every run. Never log or paste fetched values into issues, comments, or documents.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
AdapterEnvironmentTestResult,
|
||||
CompanySecret,
|
||||
EnvBinding,
|
||||
EnvSecretRefBinding,
|
||||
Environment,
|
||||
} from "@paperclipai/shared";
|
||||
import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, supportedEnvironmentDriversForAdapter } from "@paperclipai/shared";
|
||||
|
|
@ -51,6 +52,8 @@ import {
|
|||
EnvironmentVariablesEditor,
|
||||
type EnvironmentVariablesEditorHandle,
|
||||
} from "./environment-variables-editor";
|
||||
import { AgentSecretAccessEditor } from "./AgentSecretAccessEditor";
|
||||
import { AGENT_ACCESS_CONFIG_PATH_PREFIX } from "../lib/secret-delivery";
|
||||
import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config";
|
||||
import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata";
|
||||
import { getAdapterDisplay, getAdapterLabel } from "../adapters/adapter-display-registry";
|
||||
|
|
@ -324,6 +327,29 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
return environmentVariablesEditorRef.current?.flushPendingDraft() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the agent's API-access grants (top-level `access.<ALIAS>` keys) with
|
||||
* the complete set emitted by the Secret access editor. Added/changed aliases
|
||||
* are marked into the overlay; aliases dropped from the set are marked
|
||||
* `undefined` so `buildAgentUpdatePatch` strips them.
|
||||
*/
|
||||
const applyAccessGrants = useCallback((next: Record<string, EnvSecretRefBinding>) => {
|
||||
if (isCreate) return;
|
||||
setOverlay((prev) => {
|
||||
const effective = { ...(props.agent.adapterConfig ?? {}), ...prev.adapterConfig } as Record<string, unknown>;
|
||||
const nextAdapterConfig: Record<string, unknown> = { ...prev.adapterConfig };
|
||||
for (const [alias, binding] of Object.entries(next)) {
|
||||
nextAdapterConfig[`${AGENT_ACCESS_CONFIG_PATH_PREFIX}${alias}`] = binding;
|
||||
}
|
||||
for (const key of Object.keys(effective)) {
|
||||
if (!key.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) continue;
|
||||
const alias = key.slice(AGENT_ACCESS_CONFIG_PATH_PREFIX.length);
|
||||
if (!(alias in next)) nextAdapterConfig[key] = undefined;
|
||||
}
|
||||
return { ...prev, adapterConfig: nextAdapterConfig };
|
||||
});
|
||||
}, [isCreate, !isCreate ? props.agent : undefined]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
/** Build accumulated patch and send to parent */
|
||||
const handleCancel = useCallback(() => {
|
||||
setOverlay({ ...emptyOverlay });
|
||||
|
|
@ -1381,6 +1407,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
/>
|
||||
</Field>
|
||||
|
||||
{!isCreate && (
|
||||
<Field label="Secret access" hint={help.secretAccess}>
|
||||
<AgentSecretAccessEditor
|
||||
config={{ ...config, ...overlay.adapterConfig }}
|
||||
secrets={availableSecrets}
|
||||
onChange={applyAccessGrants}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Edit-only: timeout + grace period */}
|
||||
{!isCreate && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CompanySecret, EnvSecretRefBinding } from "@paperclipai/shared";
|
||||
|
||||
// Stub SecretBindingPicker so the editor renders without CompanyContext /
|
||||
// react-query. The stub exposes a button that binds a fixed secret.
|
||||
vi.mock("./SecretBindingPicker", () => ({
|
||||
SecretBindingPicker: ({
|
||||
onChange,
|
||||
}: {
|
||||
onChange: (next: { secretId: string; version?: number | "latest" } | null) => void;
|
||||
}) => (
|
||||
<button type="button" data-testid="pick-secret" onClick={() => onChange({ secretId: "s1", version: "latest" })}>
|
||||
pick
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import {
|
||||
AgentSecretAccessEditor,
|
||||
parseAccessGrants,
|
||||
parseEnvSecretRefs,
|
||||
rowsToAccessMap,
|
||||
summarizeAgentBindings,
|
||||
} from "./AgentSecretAccessEditor";
|
||||
|
||||
function makeSecret(id: string, name: string): CompanySecret {
|
||||
return {
|
||||
id,
|
||||
companyId: "co",
|
||||
scope: "company",
|
||||
ownerUserId: null,
|
||||
userSecretDefinitionId: null,
|
||||
key: id,
|
||||
name,
|
||||
provider: "local_encrypted",
|
||||
status: "active",
|
||||
managedMode: "paperclip_managed",
|
||||
externalRef: null,
|
||||
providerConfigId: null,
|
||||
providerMetadata: null,
|
||||
latestVersion: 1,
|
||||
description: null,
|
||||
lastResolvedAt: null,
|
||||
lastRotatedAt: null,
|
||||
deletedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentSecretAccessEditor model", () => {
|
||||
const config = {
|
||||
env: {
|
||||
GH_TOKEN: { type: "secret_ref", secretId: "s1", version: 2 },
|
||||
PLAIN: { type: "plain", value: "hi" },
|
||||
},
|
||||
"access.STRIPE": { type: "secret_ref", secretId: "s1" },
|
||||
"access.BROKEN": { type: "plain", value: "nope" },
|
||||
model: "claude",
|
||||
};
|
||||
|
||||
it("parses env secret refs, ignoring plain values", () => {
|
||||
expect(parseEnvSecretRefs(config)).toEqual([{ name: "GH_TOKEN", secretId: "s1", version: 2 }]);
|
||||
});
|
||||
|
||||
it("parses only well-formed top-level access.* secret refs", () => {
|
||||
expect(parseAccessGrants(config)).toEqual([{ name: "STRIPE", secretId: "s1", version: "latest" }]);
|
||||
});
|
||||
|
||||
it("summarizes bindings per secret with both delivery modes", () => {
|
||||
const summary = summarizeAgentBindings(parseEnvSecretRefs(config), parseAccessGrants(config));
|
||||
expect(summary).toEqual([{ secretId: "s1", envKeys: ["GH_TOKEN"], apiAliases: ["STRIPE"] }]);
|
||||
});
|
||||
|
||||
it("drops incomplete, invalid-alias, and unselected rows from the emitted access map", () => {
|
||||
expect(
|
||||
rowsToAccessMap([
|
||||
{ id: "1", alias: "OK", secretId: "s1", version: "latest" },
|
||||
{ id: "2", alias: "", secretId: "s1", version: "latest" }, // no alias
|
||||
{ id: "3", alias: "1BAD", secretId: "s1", version: "latest" }, // invalid alias
|
||||
{ id: "4", alias: "NOSECRET", secretId: "", version: "latest" }, // no secret
|
||||
]),
|
||||
).toEqual({ OK: { type: "secret_ref", secretId: "s1", version: "latest" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentSecretAccessEditor component", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
root = null;
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
root = createRoot(container);
|
||||
flushSync(() => root!.render(node));
|
||||
}
|
||||
|
||||
const secrets = [makeSecret("s1", "STRIPE_KEY")];
|
||||
|
||||
it("shows the delivery-mode overview for existing bindings", () => {
|
||||
render(
|
||||
<AgentSecretAccessEditor
|
||||
config={{
|
||||
env: { GH_TOKEN: { type: "secret_ref", secretId: "s1" } },
|
||||
"access.STRIPE": { type: "secret_ref", secretId: "s1" },
|
||||
}}
|
||||
secrets={secrets}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(container.textContent).toContain("STRIPE_KEY");
|
||||
expect(container.textContent).toContain("Env var");
|
||||
expect(container.textContent).toContain("API access");
|
||||
expect(container.textContent).toContain("env.GH_TOKEN");
|
||||
expect(container.textContent).toContain("access.STRIPE");
|
||||
});
|
||||
|
||||
it("adds an API-access grant, emitting an access.<ALIAS> secret_ref", () => {
|
||||
const emitted: Array<Record<string, EnvSecretRefBinding>> = [];
|
||||
render(
|
||||
<AgentSecretAccessEditor
|
||||
config={{}}
|
||||
secrets={secrets}
|
||||
onChange={(next) => emitted.push(next)}
|
||||
/>,
|
||||
);
|
||||
|
||||
// "Add API access" appends an editable row.
|
||||
const addButton = [...container.querySelectorAll("button")].find(
|
||||
(button) => button.textContent?.includes("Add API access"),
|
||||
)!;
|
||||
flushSync(() => addButton.click());
|
||||
|
||||
// Type an alias.
|
||||
const aliasInput = container.querySelector<HTMLInputElement>('input[aria-label="Access alias"]')!;
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
|
||||
setter.call(aliasInput, "STRIPE");
|
||||
flushSync(() => aliasInput.dispatchEvent(new Event("input", { bubbles: true })));
|
||||
|
||||
// Bind a secret via the stubbed picker.
|
||||
const pick = container.querySelector<HTMLButtonElement>('[data-testid="pick-secret"]')!;
|
||||
flushSync(() => pick.click());
|
||||
|
||||
const last = emitted.at(-1)!;
|
||||
expect(last).toEqual({ STRIPE: { type: "secret_ref", secretId: "s1", version: "latest" } });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,348 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { KeyRound, Plus, ServerCog, Trash2, Variable } from "lucide-react";
|
||||
import type { CompanySecret, EnvSecretRefBinding, SecretVersionSelector } from "@paperclipai/shared";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SecretBindingPicker, type SecretBindingValue } from "./SecretBindingPicker";
|
||||
import {
|
||||
AGENT_ACCESS_CONFIG_PATH_PREFIX,
|
||||
ENV_CONFIG_PATH_PREFIX,
|
||||
SECRET_ALIAS_RE,
|
||||
deliveryModeDescription,
|
||||
} from "../lib/secret-delivery";
|
||||
import { envKeyFromSecretName } from "./environment-variables-editor/model";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Pure model (exported for tests) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface AgentSecretRefEntry {
|
||||
/** env KEY (env delivery) or access ALIAS (API-access delivery). */
|
||||
name: string;
|
||||
secretId: string;
|
||||
version: SecretVersionSelector;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function readSecretRef(raw: unknown): { secretId: string; version: SecretVersionSelector } | null {
|
||||
const binding = asRecord(raw);
|
||||
if (!binding || binding.type !== "secret_ref") return null;
|
||||
const secretId = typeof binding.secretId === "string" ? binding.secretId : "";
|
||||
if (!secretId) return null;
|
||||
const version: SecretVersionSelector = typeof binding.version === "number" ? binding.version : "latest";
|
||||
return { secretId, version };
|
||||
}
|
||||
|
||||
/** Secret-ref bindings delivered as environment variables (`config.env.<KEY>`). */
|
||||
export function parseEnvSecretRefs(config: Record<string, unknown> | null | undefined): AgentSecretRefEntry[] {
|
||||
const env = asRecord(config?.env);
|
||||
if (!env) return [];
|
||||
const entries: AgentSecretRefEntry[] = [];
|
||||
for (const [key, raw] of Object.entries(env)) {
|
||||
const ref = readSecretRef(raw);
|
||||
if (ref) entries.push({ name: key, ...ref });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Secret-ref bindings delivered via the agent API (top-level `access.<ALIAS>`). */
|
||||
export function parseAccessGrants(config: Record<string, unknown> | null | undefined): AgentSecretRefEntry[] {
|
||||
if (!config) return [];
|
||||
const entries: AgentSecretRefEntry[] = [];
|
||||
for (const [key, raw] of Object.entries(config)) {
|
||||
if (!key.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) continue;
|
||||
const ref = readSecretRef(raw);
|
||||
if (ref) entries.push({ name: key.slice(AGENT_ACCESS_CONFIG_PATH_PREFIX.length), ...ref });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export interface AgentSecretBindingSummary {
|
||||
secretId: string;
|
||||
envKeys: string[];
|
||||
apiAliases: string[];
|
||||
}
|
||||
|
||||
/** Group env + API bindings by secret so the overview can show delivery mode per secret. */
|
||||
export function summarizeAgentBindings(
|
||||
envBindings: readonly AgentSecretRefEntry[],
|
||||
apiBindings: readonly AgentSecretRefEntry[],
|
||||
): AgentSecretBindingSummary[] {
|
||||
const bySecret = new Map<string, AgentSecretBindingSummary>();
|
||||
const ensure = (secretId: string) => {
|
||||
let summary = bySecret.get(secretId);
|
||||
if (!summary) {
|
||||
summary = { secretId, envKeys: [], apiAliases: [] };
|
||||
bySecret.set(secretId, summary);
|
||||
}
|
||||
return summary;
|
||||
};
|
||||
for (const entry of envBindings) ensure(entry.secretId).envKeys.push(entry.name);
|
||||
for (const entry of apiBindings) ensure(entry.secretId).apiAliases.push(entry.name);
|
||||
return [...bySecret.values()];
|
||||
}
|
||||
|
||||
let accessRowCounter = 0;
|
||||
function nextAccessRowId(): string {
|
||||
accessRowCounter += 1;
|
||||
return `access-row-${accessRowCounter}`;
|
||||
}
|
||||
|
||||
interface AccessRow {
|
||||
id: string;
|
||||
alias: string;
|
||||
secretId: string;
|
||||
version: SecretVersionSelector;
|
||||
}
|
||||
|
||||
function entriesToRows(entries: readonly AgentSecretRefEntry[]): AccessRow[] {
|
||||
return entries.map((entry) => ({
|
||||
id: nextAccessRowId(),
|
||||
alias: entry.name,
|
||||
secretId: entry.secretId,
|
||||
version: entry.version,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Complete, valid API-access grants keyed by alias. Incomplete/invalid/duplicate rows are dropped. */
|
||||
export function rowsToAccessMap(rows: readonly AccessRow[]): Record<string, EnvSecretRefBinding> {
|
||||
const map: Record<string, EnvSecretRefBinding> = {};
|
||||
for (const row of rows) {
|
||||
const alias = row.alias.trim();
|
||||
if (!alias || !SECRET_ALIAS_RE.test(alias) || !row.secretId) continue;
|
||||
map[alias] = { type: "secret_ref", secretId: row.secretId, version: row.version };
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Stable key for change-detection between the controlled value and the local draft. */
|
||||
export function normalizeAccessMapKey(map: Record<string, EnvSecretRefBinding>): string {
|
||||
return JSON.stringify(
|
||||
Object.keys(map)
|
||||
.sort()
|
||||
.map((alias) => {
|
||||
const binding = map[alias]!;
|
||||
return [alias, binding.secretId, binding.version ?? "latest"];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface AgentSecretAccessEditorProps {
|
||||
/** Effective adapter config (env + top-level `access.*`), reflecting unsaved edits. */
|
||||
config: Record<string, unknown>;
|
||||
secrets: readonly CompanySecret[];
|
||||
/**
|
||||
* Emit the complete desired set of API-access grants (alias → secret_ref). The
|
||||
* parent diffs this against the current `access.*` keys to add/remove them.
|
||||
*/
|
||||
onChange: (next: Record<string, EnvSecretRefBinding>) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function DeliveryBadge({ mode }: { mode: "env" | "api" }) {
|
||||
if (mode === "env") {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 gap-1 px-1.5 text-(length:--text-nano) font-normal border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300"
|
||||
>
|
||||
<Variable className="size-3" /> Env var
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 gap-1 px-1.5 text-(length:--text-nano) font-normal border-violet-500/30 bg-violet-500/10 text-violet-700 dark:text-violet-300"
|
||||
>
|
||||
<ServerCog className="size-3" /> API access
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentSecretAccessEditor({ config, secrets, onChange, disabled }: AgentSecretAccessEditorProps) {
|
||||
const envBindings = useMemo(() => parseEnvSecretRefs(config), [config]);
|
||||
const apiBindings = useMemo(() => parseAccessGrants(config), [config]);
|
||||
const summaries = useMemo(() => summarizeAgentBindings(envBindings, apiBindings), [envBindings, apiBindings]);
|
||||
|
||||
const incomingMap = useMemo(() => rowsToAccessMap(entriesToRows(apiBindings)), [apiBindings]);
|
||||
const incomingKey = useMemo(() => normalizeAccessMapKey(incomingMap), [incomingMap]);
|
||||
|
||||
const [rows, setRows] = useState<AccessRow[]>(() => entriesToRows(apiBindings));
|
||||
const lastEmittedKeyRef = useRef(incomingKey);
|
||||
const lastIncomingKeyRef = useRef(incomingKey);
|
||||
|
||||
// Controlled sync (mirrors the env editor): adopt genuine external changes
|
||||
// (Cancel / agent refetch) but never clobber a local draft that produced the
|
||||
// incoming value (the echo of our own emit) or an in-progress incomplete row.
|
||||
useEffect(() => {
|
||||
if (incomingKey === lastIncomingKeyRef.current) return;
|
||||
lastIncomingKeyRef.current = incomingKey;
|
||||
if (incomingKey === lastEmittedKeyRef.current) return;
|
||||
setRows(entriesToRows(apiBindings));
|
||||
}, [incomingKey, apiBindings]);
|
||||
|
||||
const secretName = (secretId: string): string =>
|
||||
secrets.find((secret) => secret.id === secretId)?.name ?? `${secretId.slice(0, 8)}…`;
|
||||
|
||||
function emit(nextRows: AccessRow[]) {
|
||||
setRows(nextRows);
|
||||
const map = rowsToAccessMap(nextRows);
|
||||
lastEmittedKeyRef.current = normalizeAccessMapKey(map);
|
||||
onChange(map);
|
||||
}
|
||||
|
||||
function patchRow(id: string, patch: Partial<AccessRow>) {
|
||||
emit(rows.map((row) => (row.id === id ? { ...row, ...patch } : row)));
|
||||
}
|
||||
|
||||
function removeRow(id: string) {
|
||||
emit(rows.filter((row) => row.id !== id));
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
setRows((prev) => [...prev, { id: nextAccessRowId(), alias: "", secretId: "", version: "latest" }]);
|
||||
}
|
||||
|
||||
const aliasCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
const alias = row.alias.trim();
|
||||
if (alias) counts.set(alias, (counts.get(alias) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}, [rows]);
|
||||
|
||||
const hasBindings = summaries.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Overview: every secret bound to this agent + how it is delivered. */}
|
||||
{hasBindings ? (
|
||||
<div className="space-y-1.5">
|
||||
{summaries.map((summary) => (
|
||||
<div
|
||||
key={summary.secretId}
|
||||
className="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-md border border-border bg-muted/30 px-2.5 py-1.5 text-xs"
|
||||
>
|
||||
<KeyRound className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="font-medium">{secretName(summary.secretId)}</span>
|
||||
{summary.envKeys.length > 0 ? <DeliveryBadge mode="env" /> : null}
|
||||
{summary.apiAliases.length > 0 ? <DeliveryBadge mode="api" /> : null}
|
||||
<span className="min-w-0 truncate font-mono text-(length:--text-micro) text-muted-foreground">
|
||||
{[
|
||||
...summary.envKeys.map((key) => `${ENV_CONFIG_PATH_PREFIX}${key}`),
|
||||
...summary.apiAliases.map((alias) => `${AGENT_ACCESS_CONFIG_PATH_PREFIX}${alias}`),
|
||||
].join(" · ")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No secrets are bound to this agent yet.</p>
|
||||
)}
|
||||
|
||||
{/* Editable API-access grants (access.<ALIAS>). */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
|
||||
API access (no env var)
|
||||
</div>
|
||||
{rows.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{rows.map((row) => {
|
||||
const trimmedAlias = row.alias.trim();
|
||||
const aliasInvalid = Boolean(trimmedAlias) && !SECRET_ALIAS_RE.test(trimmedAlias);
|
||||
const aliasDuplicate = Boolean(trimmedAlias) && (aliasCounts.get(trimmedAlias) ?? 0) > 1;
|
||||
const bindingValue: SecretBindingValue | null = row.secretId
|
||||
? { secretId: row.secretId, version: row.version }
|
||||
: null;
|
||||
return (
|
||||
<div key={row.id} className="space-y-1">
|
||||
<div className="grid grid-cols-(--gtc-65) items-start gap-1.5">
|
||||
<div>
|
||||
<Input
|
||||
value={row.alias}
|
||||
onChange={(event) => patchRow(row.id, { alias: event.target.value })}
|
||||
onBlur={(event) => {
|
||||
const next = event.target.value.trim();
|
||||
if (next && !SECRET_ALIAS_RE.test(next)) {
|
||||
const suggested = envKeyFromSecretName(next);
|
||||
if (suggested && suggested !== next) patchRow(row.id, { alias: suggested });
|
||||
}
|
||||
}}
|
||||
placeholder="ALIAS"
|
||||
aria-label="Access alias"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"h-9 font-mono text-sm",
|
||||
(aliasInvalid || aliasDuplicate) && "border-destructive text-destructive",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<SecretBindingPicker
|
||||
value={bindingValue}
|
||||
onChange={(next) =>
|
||||
patchRow(row.id, {
|
||||
secretId: next?.secretId ?? "",
|
||||
version: next?.version ?? "latest",
|
||||
alias:
|
||||
!row.alias.trim() && next?.secretId
|
||||
? envKeyFromSecretName(secretName(next.secretId))
|
||||
: row.alias,
|
||||
})
|
||||
}
|
||||
label=""
|
||||
placeholder="Select secret"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(row.id)}
|
||||
disabled={disabled}
|
||||
aria-label="Remove API access"
|
||||
className="mt-1 inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{aliasInvalid ? (
|
||||
<p className="pl-0.5 text-(length:--text-micro) text-destructive">
|
||||
Invalid alias — use letters, digits and _
|
||||
</p>
|
||||
) : aliasDuplicate ? (
|
||||
<p className="pl-0.5 text-(length:--text-micro) text-destructive">Duplicate alias</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
disabled={disabled}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add API access
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-(length:--text-micro) text-muted-foreground/70">
|
||||
{deliveryModeDescription("api")} The agent reads them by alias through <code>GET /agents/me/secrets</code>.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -46,6 +46,8 @@ export const help: Record<string, string> = {
|
|||
args: "Command-line arguments, comma-separated.",
|
||||
extraArgs: "Extra CLI arguments for local adapters, comma-separated.",
|
||||
envVars: "Environment variables injected into the adapter process. Use plain values or secret references.",
|
||||
secretAccess:
|
||||
"Secrets this agent can reach. Env-var bindings are injected at run start; API-access bindings are fetched on demand via the run-bound agent API and never written to the environment.",
|
||||
bootstrapPrompt: "Only sent when Paperclip starts a fresh session. Use this for stable setup guidance that should not be repeated on every heartbeat.",
|
||||
payloadTemplateJson: "Optional JSON merged into remote adapter request payloads before Paperclip adds its standard wake and workspace fields.",
|
||||
webhookUrl: "The URL that receives POST requests when the agent is invoked.",
|
||||
|
|
|
|||
|
|
@ -1835,6 +1835,7 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
|
|||
--gtc-62: 1fr minmax(0,18rem);
|
||||
--gtc-63: 280px 1fr;
|
||||
--gtc-64: minmax(0,20rem) 1fr;
|
||||
--gtc-65: minmax(8rem,0.38fr) minmax(0,0.62fr) auto;
|
||||
--gtr-1: auto auto; /* Extracted from ui/src/components/ui/card.tsx (grid-rows-[auto_auto]). */
|
||||
--gtr-2: 1fr; /* Extracted from ui/src/pages/CompanySkills.tsx (grid-rows-[1fr]). */
|
||||
--gtr-3: 0fr; /* Extracted from ui/src/pages/CompanySkills.tsx (grid-rows-[0fr]). */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
aliasFromConfigPath,
|
||||
consumerTypeLabel,
|
||||
deliveryModeForConfigPath,
|
||||
deliveryModeLabel,
|
||||
} from "./secret-delivery";
|
||||
|
||||
describe("deliveryModeForConfigPath", () => {
|
||||
it("classifies env config paths", () => {
|
||||
expect(deliveryModeForConfigPath("env.OPENAI_API_KEY")).toBe("env");
|
||||
});
|
||||
|
||||
it("classifies API-access config paths", () => {
|
||||
expect(deliveryModeForConfigPath("access.STRIPE")).toBe("api");
|
||||
});
|
||||
|
||||
it("falls back to config for other paths and empties", () => {
|
||||
expect(deliveryModeForConfigPath("headers.authorization")).toBe("config");
|
||||
expect(deliveryModeForConfigPath(null)).toBe("config");
|
||||
expect(deliveryModeForConfigPath("")).toBe("config");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deliveryModeLabel", () => {
|
||||
it("maps each mode to a human label", () => {
|
||||
expect(deliveryModeLabel("env")).toBe("Env var");
|
||||
expect(deliveryModeLabel("api")).toBe("API access");
|
||||
expect(deliveryModeLabel("config")).toBe("Config");
|
||||
});
|
||||
});
|
||||
|
||||
describe("aliasFromConfigPath", () => {
|
||||
it("strips the delivery prefix", () => {
|
||||
expect(aliasFromConfigPath("env.GH_TOKEN")).toBe("GH_TOKEN");
|
||||
expect(aliasFromConfigPath("access.STRIPE")).toBe("STRIPE");
|
||||
});
|
||||
|
||||
it("returns the raw path when no known prefix applies", () => {
|
||||
expect(aliasFromConfigPath("headers.authorization")).toBe("headers.authorization");
|
||||
expect(aliasFromConfigPath(null)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumerTypeLabel", () => {
|
||||
it("renders agent_api and other multiword consumers sensibly", () => {
|
||||
expect(consumerTypeLabel("agent_api")).toBe("Agent API");
|
||||
expect(consumerTypeLabel("plugin_worker")).toBe("Plugin worker");
|
||||
expect(consumerTypeLabel("tool_connection")).toBe("Tool connection");
|
||||
});
|
||||
|
||||
it("capitalizes single-word consumer types", () => {
|
||||
expect(consumerTypeLabel("agent")).toBe("Agent");
|
||||
expect(consumerTypeLabel("project")).toBe("Project");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import type { SecretAccessEvent } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* Delivery mode for an agent secret binding, derived from its `configPath`.
|
||||
*
|
||||
* The server (see `AGENT_ACCESS_CONFIG_PATH_PREFIX` in
|
||||
* `server/src/services/secrets.ts`) treats a binding's config path as the
|
||||
* source of truth for how a secret reaches the runtime:
|
||||
* - `env.<KEY>` — injected as an environment variable at run start.
|
||||
* - `access.<ALIAS>` — fetched on demand via the run-bound agent API
|
||||
* (`GET /agents/me/secrets`), never written to the env.
|
||||
* - anything else — a generic adapter config path (rendered as "Config").
|
||||
*/
|
||||
export type SecretDeliveryMode = "env" | "api" | "config";
|
||||
|
||||
/** Prefix for env-var delivery config paths. Mirrors the server convention. */
|
||||
export const ENV_CONFIG_PATH_PREFIX = "env.";
|
||||
/** Prefix for API-access (no env var) delivery config paths. Mirrors the server's `AGENT_ACCESS_CONFIG_PATH_PREFIX`. */
|
||||
export const AGENT_ACCESS_CONFIG_PATH_PREFIX = "access.";
|
||||
|
||||
/** Valid env-var name / access alias (matches the server's `ENV_KEY_RE`). */
|
||||
export const SECRET_ALIAS_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
export function deliveryModeForConfigPath(configPath: string | null | undefined): SecretDeliveryMode {
|
||||
if (!configPath) return "config";
|
||||
if (configPath.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) return "api";
|
||||
if (configPath.startsWith(ENV_CONFIG_PATH_PREFIX)) return "env";
|
||||
return "config";
|
||||
}
|
||||
|
||||
/** Short human label for a delivery mode. */
|
||||
export function deliveryModeLabel(mode: SecretDeliveryMode): string {
|
||||
switch (mode) {
|
||||
case "env":
|
||||
return "Env var";
|
||||
case "api":
|
||||
return "API access";
|
||||
default:
|
||||
return "Config";
|
||||
}
|
||||
}
|
||||
|
||||
/** One-line explanation of a delivery mode, for tooltips/hints. */
|
||||
export function deliveryModeDescription(mode: SecretDeliveryMode): string {
|
||||
switch (mode) {
|
||||
case "env":
|
||||
return "Injected as an environment variable at run start.";
|
||||
case "api":
|
||||
return "Fetched on demand via the run-bound agent API. Never written to the environment.";
|
||||
default:
|
||||
return "Provided through adapter configuration.";
|
||||
}
|
||||
}
|
||||
|
||||
/** The env KEY / access ALIAS carried by a config path (the part after the prefix). */
|
||||
export function aliasFromConfigPath(configPath: string | null | undefined): string {
|
||||
if (!configPath) return "";
|
||||
if (configPath.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) {
|
||||
return configPath.slice(AGENT_ACCESS_CONFIG_PATH_PREFIX.length);
|
||||
}
|
||||
if (configPath.startsWith(ENV_CONFIG_PATH_PREFIX)) {
|
||||
return configPath.slice(ENV_CONFIG_PATH_PREFIX.length);
|
||||
}
|
||||
return configPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human label for a secret access-event `consumerType`. Runtime consumers are
|
||||
* emitted as raw enum values (e.g. `agent_api`, `plugin_worker`) which read
|
||||
* poorly when merely capitalized; map the ones that need help explicitly.
|
||||
*/
|
||||
export function consumerTypeLabel(consumerType: SecretAccessEvent["consumerType"]): string {
|
||||
switch (consumerType) {
|
||||
case "agent_api":
|
||||
return "Agent API";
|
||||
case "plugin_worker":
|
||||
return "Plugin worker";
|
||||
case "tool_connection":
|
||||
return "Tool connection";
|
||||
default:
|
||||
return consumerType.charAt(0).toUpperCase() + consumerType.slice(1);
|
||||
}
|
||||
}
|
||||
|
|
@ -65,6 +65,13 @@ import { ApiError } from "../api/client";
|
|||
import { accessApi, type CompanyUserDirectoryEntry } from "../api/access";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { envKeyFromSecretName } from "../components/environment-variables-editor/model";
|
||||
import {
|
||||
AGENT_ACCESS_CONFIG_PATH_PREFIX,
|
||||
aliasFromConfigPath,
|
||||
consumerTypeLabel,
|
||||
deliveryModeForConfigPath,
|
||||
deliveryModeLabel,
|
||||
} from "../lib/secret-delivery";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -4224,6 +4231,25 @@ function envKeysReferencingSecret(env: unknown, reference: AgentAccessReference)
|
|||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level `access.<ALIAS>` keys in an agent's adapter config that resolve to
|
||||
* this secret (API-access delivery). Only company secrets support API access;
|
||||
* user secrets remain env-only.
|
||||
*/
|
||||
function apiAliasesReferencingSecret(adapterConfig: unknown, reference: AgentAccessReference): string[] {
|
||||
if (reference.kind !== "company") return [];
|
||||
if (typeof adapterConfig !== "object" || adapterConfig === null || Array.isArray(adapterConfig)) return [];
|
||||
return Object.entries(adapterConfig as Record<string, unknown>)
|
||||
.filter(([key, binding]) => {
|
||||
if (!key.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) return false;
|
||||
if (typeof binding !== "object" || binding === null) return false;
|
||||
const record = binding as Record<string, unknown>;
|
||||
return record.type === "secret_ref" && record.secretId === reference.secret.id;
|
||||
})
|
||||
.map(([key]) => key.slice(AGENT_ACCESS_CONFIG_PATH_PREFIX.length))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function AgentAccessSection({
|
||||
companyId,
|
||||
reference,
|
||||
|
|
@ -4253,14 +4279,15 @@ function AgentAccessSection({
|
|||
const agentAccess = useMemo(
|
||||
() =>
|
||||
agents
|
||||
.map((agent) => ({
|
||||
agent,
|
||||
envKeys: envKeysReferencingSecret(
|
||||
(agent.adapterConfig as Record<string, unknown> | null)?.env,
|
||||
reference,
|
||||
),
|
||||
}))
|
||||
.filter((entry) => entry.envKeys.length > 0),
|
||||
.map((agent) => {
|
||||
const adapterConfig = (agent.adapterConfig as Record<string, unknown> | null) ?? null;
|
||||
return {
|
||||
agent,
|
||||
envKeys: envKeysReferencingSecret(adapterConfig?.env, reference),
|
||||
apiAliases: apiAliasesReferencingSecret(adapterConfig, reference),
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.envKeys.length > 0 || entry.apiAliases.length > 0),
|
||||
[agents, reference],
|
||||
);
|
||||
const grantableAgents = useMemo(
|
||||
|
|
@ -4325,8 +4352,10 @@ function AgentAccessSection({
|
|||
const adapterConfig = { ...((detail.adapterConfig ?? {}) as Record<string, unknown>) };
|
||||
const env = { ...((adapterConfig.env ?? {}) as Record<string, unknown>) };
|
||||
const keys = envKeysReferencingSecret(env, reference);
|
||||
if (keys.length === 0) return detail;
|
||||
const aliases = apiAliasesReferencingSecret(adapterConfig, reference);
|
||||
if (keys.length === 0 && aliases.length === 0) return detail;
|
||||
for (const key of keys) delete env[key];
|
||||
for (const alias of aliases) delete adapterConfig[`${AGENT_ACCESS_CONFIG_PATH_PREFIX}${alias}`];
|
||||
return agentsApi.update(
|
||||
agentId,
|
||||
{ adapterConfig: { ...adapterConfig, env }, replaceAdapterConfig: true },
|
||||
|
|
@ -4352,7 +4381,7 @@ function AgentAccessSection({
|
|||
</div>
|
||||
<p className="mt-0.5 text-(length:--text-micro) text-muted-foreground">
|
||||
{reference.kind === "company"
|
||||
? "These agents receive this secret as an environment variable at run start."
|
||||
? "Add here to inject this secret as an environment variable at run start. API-access grants (fetched on demand, no env var) are managed from each agent's Secret access settings and shown below."
|
||||
: "These agents resolve the responsible user's value as an environment variable at run start."}
|
||||
</p>
|
||||
{agentsQuery.isPending ? (
|
||||
|
|
@ -4365,15 +4394,30 @@ function AgentAccessSection({
|
|||
<>
|
||||
{agentAccess.length > 0 ? (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{agentAccess.map(({ agent, envKeys }) => (
|
||||
{agentAccess.map(({ agent, envKeys, apiAliases }) => (
|
||||
<li
|
||||
key={agent.id}
|
||||
className="flex items-center gap-2 rounded border border-border/60 bg-background px-2 py-1"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">{agent.name}</span>
|
||||
<code className="shrink-0 font-mono text-(length:--text-micro) text-muted-foreground">
|
||||
{envKeys.join(", ")}
|
||||
</code>
|
||||
<span className="flex shrink-0 flex-wrap items-center justify-end gap-1">
|
||||
{envKeys.length > 0 ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 px-1.5 text-(length:--text-nano) font-normal border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300"
|
||||
>
|
||||
Env · {envKeys.join(", ")}
|
||||
</Badge>
|
||||
) : null}
|
||||
{apiAliases.length > 0 ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 px-1.5 text-(length:--text-nano) font-normal border-violet-500/30 bg-violet-500/10 text-violet-700 dark:text-violet-300"
|
||||
>
|
||||
API · {apiAliases.join(", ")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
|
@ -4517,7 +4561,7 @@ function DetailRow({ label, children }: { label: string; children: React.ReactNo
|
|||
);
|
||||
}
|
||||
|
||||
function SecretUsageTab({ loading, bindings }: { loading: boolean; bindings: CompanySecretUsageBinding[] }) {
|
||||
export function SecretUsageTab({ loading, bindings }: { loading: boolean; bindings: CompanySecretUsageBinding[] }) {
|
||||
if (loading) {
|
||||
return <div className="py-6 text-center text-xs text-muted-foreground">Loading…</div>;
|
||||
}
|
||||
|
|
@ -4530,42 +4574,65 @@ function SecretUsageTab({ loading, bindings }: { loading: boolean; bindings: Com
|
|||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{bindings.map((binding) => (
|
||||
<div
|
||||
key={binding.id}
|
||||
className="rounded-md border border-border bg-muted/30 p-2 text-xs"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium capitalize">{binding.target.type}</span>
|
||||
<span className="font-mono text-muted-foreground">v{binding.versionSelector}</span>
|
||||
{bindings.map((binding) => {
|
||||
const deliveryMode = deliveryModeForConfigPath(binding.configPath);
|
||||
return (
|
||||
<div
|
||||
key={binding.id}
|
||||
className="rounded-md border border-border bg-muted/30 p-2 text-xs"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="font-medium capitalize">{binding.target.type}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 px-1.5 text-(length:--text-nano) font-normal",
|
||||
deliveryMode === "api"
|
||||
? "border-violet-500/30 bg-violet-500/10 text-violet-700 dark:text-violet-300"
|
||||
: deliveryMode === "env"
|
||||
? "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300"
|
||||
: null,
|
||||
)}
|
||||
>
|
||||
{deliveryModeLabel(deliveryMode)}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="font-mono text-muted-foreground">v{binding.versionSelector}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-2">
|
||||
{binding.target.href ? (
|
||||
<Link to={binding.target.href} className="truncate font-medium text-primary hover:underline">
|
||||
{binding.target.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="truncate font-medium">{binding.target.label}</span>
|
||||
)}
|
||||
{binding.target.status ? (
|
||||
<Badge variant="outline" className="h-5 px-1.5 text-(length:--text-nano) font-normal">
|
||||
{binding.target.status.replaceAll("_", " ")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="font-mono text-(length:--text-micro) text-muted-foreground break-all">
|
||||
{binding.targetId}
|
||||
</div>
|
||||
<div className="text-(length:--text-micro) text-muted-foreground">
|
||||
{deliveryMode === "api" ? (
|
||||
<>API alias <span className="font-mono">{aliasFromConfigPath(binding.configPath)}</span></>
|
||||
) : (
|
||||
<span className="font-mono">{binding.configPath}</span>
|
||||
)}{" "}
|
||||
{binding.required ? "· required" : "· optional"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-2">
|
||||
{binding.target.href ? (
|
||||
<Link to={binding.target.href} className="truncate font-medium text-primary hover:underline">
|
||||
{binding.target.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="truncate font-medium">{binding.target.label}</span>
|
||||
)}
|
||||
{binding.target.status ? (
|
||||
<Badge variant="outline" className="h-5 px-1.5 text-(length:--text-nano) font-normal">
|
||||
{binding.target.status.replaceAll("_", " ")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="font-mono text-(length:--text-micro) text-muted-foreground break-all">
|
||||
{binding.targetId}
|
||||
</div>
|
||||
<div className="text-(length:--text-micro) text-muted-foreground">
|
||||
{binding.configPath} {binding.required ? "· required" : "· optional"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SecretEventsTab({
|
||||
export function SecretEventsTab({
|
||||
loading,
|
||||
events,
|
||||
companyId,
|
||||
|
|
@ -4608,8 +4675,9 @@ function SecretEventsTab({
|
|||
{events.map((event) => (
|
||||
<div key={event.id} className="rounded border border-border px-2 py-1.5 text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-1.5 capitalize">
|
||||
{event.consumerType} · {event.outcome}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span>{consumerTypeLabel(event.consumerType)}</span>
|
||||
<span className="capitalize">· {event.outcome}</span>
|
||||
{event.secretScope === "user" ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
|
|
|
|||
Loading…
Reference in New Issue