From b4cbba83f186ff8c27fcda42d913010832f48238 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 6 Oct 2026 10:42:29 -0500 Subject: [PATCH] feat: give managed agents persistent cryptographic identities Create encrypted Ed25519 identities on agent creation or first managed run. Deliver runtime credentials, expose public identity, and preserve safe copy and recovery behavior. Co-Authored-By: Paperclip --- cli/src/__tests__/worktree.test.ts | 7 +- cli/src/commands/worktree-lib.ts | 3 +- doc/AGENT-IDENTITY.md | 86 ++++ doc/DATABASE.md | 9 + doc/SPEC-implementation.md | 12 + doc/SPEC.md | 9 + .../adapter-utils/src/acpx-engine/execute.ts | 16 +- .../src/acpx-engine/run-contracts.ts | 1 + .../src/execution-target-sandbox.test.ts | 25 + packages/adapter-utils/src/index.ts | 1 + .../adapter-utils/src/server-utils.test.ts | 9 + packages/adapter-utils/src/server-utils.ts | 23 +- packages/adapter-utils/src/types.ts | 8 + .../claude-local/src/server/execute.ts | 4 +- .../codex-local/src/server/execute.ts | 9 +- .../cursor-cloud/src/server/execute.ts | 2 +- .../cursor-local/src/server/execute.ts | 2 +- .../gemini-local/src/server/execute.ts | 2 +- .../adapters/grok-local/src/server/execute.ts | 2 +- .../adapters/hermes/src/server/execute.ts | 2 +- .../adapters/kimi-local/src/server/execute.ts | 2 +- .../opencode-local/src/server/execute.ts | 2 +- .../adapters/pi-local/src/server/execute.ts | 2 +- packages/db/src/migrations/0306_windy_leo.sql | 20 + ...{0301_snapshot.json => 0306_snapshot.json} | 453 +++++++++++++++++- packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/agent_identity_keys.ts | 20 + packages/db/src/schema/index.ts | 2 + .../runner-core/src/acpx_sidecar_transport.rs | 3 + .../crates/runner-core/src/codex_provider.rs | 8 + .../crates/runner-core/src/durable/mod.rs | 5 +- .../crates/runner-core/src/durable/state.rs | 23 + .../src/agent-identity-environment.test.ts | 34 ++ .../src/contracts/native-execution.ts | 4 +- .../durable-prp-control-plane.test.ts | 6 + .../durable-prp-control-plane.ts | 3 + .../src/drivers/acpx/environment.ts | 1 + .../src/drivers/acpx/runtime-sandbox.ts | 8 + .../src/drivers/codex/app-server-transport.ts | 7 +- .../drivers/codex/codex-security-config.ts | 5 +- .../opencode/opencode-server-driver.ts | 6 + .../src/live/runnerd-codex-transport.ts | 5 + packages/shared/src/index.ts | 1 + packages/shared/src/types/agent.ts | 8 + packages/shared/src/types/index.ts | 1 + .../agent-cross-tenant-authz-routes.test.ts | 6 + .../agent-identity-master-key.test.ts | 36 ++ server/src/__tests__/agent-identity.test.ts | 223 +++++++++ ...at-accepted-plan-workspace-refresh.test.ts | 5 +- server/src/adapters/process/execute.ts | 2 +- server/src/routes/agents.ts | 7 + .../src/secrets/local-encrypted-provider.ts | 24 +- .../src/services/agent-identity-redaction.ts | 37 ++ server/src/services/agent-identity.ts | 88 ++++ server/src/services/agents.ts | 3 + server/src/services/heartbeat.ts | 42 +- .../native-runtime/native-execution-input.ts | 2 + .../native-session-executor.test.ts | 17 + .../native-runtime/native-session-executor.ts | 13 + .../paperclip-control-plane-port.test.ts | 43 +- .../paperclip-control-plane-port.ts | 9 +- ui/src/api/agents.ts | 3 + ui/src/lib/queryKeys.ts | 1 + ui/src/pages/AgentDetail.tsx | 19 +- 64 files changed, 1401 insertions(+), 47 deletions(-) create mode 100644 doc/AGENT-IDENTITY.md create mode 100644 packages/db/src/migrations/0306_windy_leo.sql rename packages/db/src/migrations/meta/{0301_snapshot.json => 0306_snapshot.json} (99%) create mode 100644 packages/db/src/schema/agent_identity_keys.ts create mode 100644 packages/paperclip-runner/src/agent-identity-environment.test.ts create mode 100644 server/src/__tests__/agent-identity-master-key.test.ts create mode 100644 server/src/__tests__/agent-identity.test.ts create mode 100644 server/src/services/agent-identity-redaction.ts create mode 100644 server/src/services/agent-identity.ts diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 0fb6b2aee32..a2164aa7e4e 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -511,7 +511,8 @@ describe("worktree helpers", () => { expect(minimal.excludedTables).toContain("agent_task_sessions"); expect(minimal.nullifyColumns.issues).toEqual(["checkout_run_id", "execution_run_id"]); - expect(full.excludedTables).toEqual([]); + expect(full.excludedTables).toEqual(["agent_identity_keys"]); + expect(minimal.excludedTables).toContain("agent_identity_keys"); expect(full.nullifyColumns).toEqual({}); }); @@ -2045,8 +2046,8 @@ describe("worktree helpers", () => { } }); - it("uses streaming backup selection for full seeds and transformed backup selection for minimal seeds", () => { - expect(resolveWorktreeSeedBackupEngine(resolveWorktreeSeedPlan("full"))).toBe("auto"); + it("uses transformed backups for both seed modes to omit agent identities", () => { + expect(resolveWorktreeSeedBackupEngine(resolveWorktreeSeedPlan("full"))).toBe("javascript"); expect(resolveWorktreeSeedBackupEngine(resolveWorktreeSeedPlan("minimal"))).toBe("javascript"); }); diff --git a/cli/src/commands/worktree-lib.ts b/cli/src/commands/worktree-lib.ts index 4cdf7ef5364..8d3aa01f6fe 100644 --- a/cli/src/commands/worktree-lib.ts +++ b/cli/src/commands/worktree-lib.ts @@ -58,6 +58,7 @@ export type WorktreeSeedPlan = { }; const MINIMAL_WORKTREE_EXCLUDED_TABLES = [ + "agent_identity_keys", "activity_log", "agent_runtime_state", "agent_task_sessions", @@ -118,7 +119,7 @@ export function resolveWorktreeSeedPlan(mode: WorktreeSeedMode): WorktreeSeedPla if (mode === "full") { return { mode, - excludedTables: [], + excludedTables: ["agent_identity_keys"], nullifyColumns: {}, }; } diff --git a/doc/AGENT-IDENTITY.md b/doc/AGENT-IDENTITY.md new file mode 100644 index 00000000000..c10108092dd --- /dev/null +++ b/doc/AGENT-IDENTITY.md @@ -0,0 +1,86 @@ +# Agent cryptographic identity + +Each agent has one persistent Ed25519 keypair. New agents receive it in the +agent-creation transaction. Existing agents receive it when Paperclip prepares +their next managed run. Migration, startup, public API reads, and opening the +agent page do not provision existing agents. There is no backfill command. + +## Storage + +`agent_identity_keys` lives in the agent's home instance database, including a +hosted stack's database. Its agent primary key and composite company/agent +foreign key enforce one identity and company-consistent ownership. Deleting an +agent cascades to its identity. Creation locks the agent row inside a transaction, +so simultaneous first runs use the same stored pair. + +Public keys use SPKI PEM and private keys use PKCS#8 PEM. `keyId` is `sha256:` +followed by the base64url SHA-256 digest of the public key's SPKI DER bytes. +Private material is encrypted using the existing `local_encrypted` provider and +stored separately from company secrets. The wrapping key is the stack's +`PAPERCLIP_SECRETS_MASTER_KEY` or the local instance's secrets master-key file. +The file is published atomically with mode `0600` on first use. No per-agent +Cloud secret or Cloud registry record is needed. + +Decryption and public/private key validation happen before launch. A wrong +master key, corrupt ciphertext, or mismatched public key fails the run; none +causes automatic key replacement. Renames, model changes, pauses, resumes, and +restarts preserve the keypair. + +## Managed runs + +Paperclip supplies these server-owned environment variables: + +```text +PAPERCLIP_AGENT_KEY_ID +PAPERCLIP_AGENT_PUBLIC_KEY +PAPERCLIP_AGENT_PRIVATE_KEY +``` + +The two key values are multiline PEM strings. Runtime-only adapter context and +native runner environment fields deliver them to managed processes. Configured +or inherited environment values cannot replace the assigned identity. Session +compatibility includes the public key ID, so an older warm process is replaced +before its next turn. Private values are excluded from invocation metadata and +execution configuration; output and failure diagnostics redact known private +material. The run-log redactor handles split stdout/stderr chunks independently. + +Independently hosted HTTP and gateway agents, including Runner's API-hosted Claude Managed and AWS AgentCore providers, do not receive private keys in v1. +Their new agent records still get stored identities; their existing records stay +unprovisioned until a supported managed run. + +An agent can sign using an ordinary cryptographic library, for example Node.js: + +```js +import { sign } from "node:crypto"; +const signature = sign(null, challengeBytes, process.env.PAPERCLIP_AGENT_PRIVATE_KEY); +``` + +This identity does not change Paperclip bearer-token authentication. There is no +signing API, private-key download endpoint, Git integration, rotation UI, or +external-key import. Agents possess their private keys. External consumers must +define their own authorization, expiry, and revocation rules. + +## Public identity + +`GET /api/agents/:id/identity` applies the existing agent-read access checks. It +returns `null` before provisioning, otherwise: + +```ts +{ algorithm: "Ed25519", keyId: string, publicKeyPem: string, createdAt: string } +``` + +The agent's Identity section shows a shortened fingerprint and copies the full +public PEM. Before provisioning it shows “Not created yet” and explains that +creation happens on the next managed run. + +## Copies and recovery + +Company/template imports and duplication use normal agent creation and get new +identities. Minimal and full development seeds omit identity **rows**, while +retaining the schema, even with live-work preservation. Those copied agents get +fresh identities on their next managed runs. + +Ordinary disaster-recovery backups include encrypted identities. Recovery needs +both the database and the matching wrapping key; back up the key separately. +Do not use a disaster-recovery backup as a development clone when agents should +have distinct identities. diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 2c87b1a3c70..e99cefd9127 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -566,3 +566,12 @@ reservation cannot silently disappear. Failed cleanup or an ambiguous storage write requires operator reconciliation before an unattached reservation is removed. The table stores no response bodies. See `doc/runner-api-tools.md` for limits and the operator override. + +## Agent identity keys and backups + +`agent_identity_keys` stores one encrypted Ed25519 identity per agent. Its migration +creates schema only: existing agents provision on their next managed run. Public +reads and server startup do not provision them. Normal backups preserve identity +rows and need the matching secrets master key for recovery. Both development seed +modes omit identity rows, including with live-work preservation, so copied agents +get fresh identities. See [Agent cryptographic identity](AGENT-IDENTITY.md). diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 3fac229ead7..f3ce1168db3 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -188,6 +188,18 @@ Invariants: Invariant: plaintext key shown once at creation; only hash stored. +### Agent cryptographic identity + +Each agent also has one Ed25519 identity in `agent_identity_keys`, separate from +API bearer keys and company secrets. New-agent creation provisions it atomically; +existing agents provision lazily before their first managed run. The schema-only +migration and public reads never provision existing agents. Private PKCS#8 PEM +material uses `local_encrypted`; public SPKI PEM and its SHA-256 key ID are readable +through `GET /api/agents/:id/identity` and the agent Identity section. Managed +processes receive the pair through runtime-only environment fields. See +[Agent cryptographic identity](AGENT-IDENTITY.md) for storage, runtime, and copy +semantics. + ## 7.4 `goals` - `id` uuid pk diff --git a/doc/SPEC.md b/doc/SPEC.md index 844aa3a1d2c..e32f9eef561 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -85,6 +85,15 @@ The Board sets Company-level budgets. The CEO can set budgets for Agents below t Every employee is an agent. Agents are the workforce. +### Cryptographic identity + +Agents have persistent Ed25519 identities, encrypted in their home instance. +New agents receive keys during creation; existing agents receive them lazily on +their next managed run. Agents can read their keys from the managed process +environment, and authorized readers can view or copy the public key from the +agent page. This does not grant external authorization or change bearer-token +access. See [the implementation contract](AGENT-IDENTITY.md). + ### Agent Identity (Adapter-Level) Concepts like SOUL.md (identity/mission) and HEARTBEAT.md (loop definition) are **not part of the Paperclip protocol**. They are adapter-specific configurations. For example, an OpenClaw adapter might use SOUL.md and HEARTBEAT.md files. A Claude Code adapter might use CLAUDE.md. A bare Python script might use command-line args. diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 28e5b620259..a08e17bbea6 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -1491,9 +1491,10 @@ function buildCodexStartupConfig(input: { requestedModel: string; requestedThinkingEffort: string; fastMode: boolean; + identityEnvironmentKeys?: string[]; }): { value: string | null; invalidExistingConfig: boolean } { const hasRuntimeConfig = Boolean( - input.requestedModel || input.requestedThinkingEffort || input.fastMode, + input.requestedModel || input.requestedThinkingEffort || input.fastMode || input.identityEnvironmentKeys, ); if (!hasRuntimeConfig) return { value: null, invalidExistingConfig: false }; @@ -1524,6 +1525,11 @@ function buildCodexStartupConfig(input: { }, } : {}), + ...(input.identityEnvironmentKeys ? { + features: { ...parseObject(existing.features), ...(input.fastMode ? { fast_mode: true } : {}), shell_snapshot: false }, + shell_environment_policy: { ...parseObject(existing.shell_environment_policy), + inherit: "all", ignore_default_excludes: true, include_only: input.identityEnvironmentKeys }, + } : {}), }), invalidExistingConfig, }; @@ -1959,7 +1965,7 @@ async function buildRuntime(input: { await fs.mkdir(stateDir, { recursive: true }); const envConfig = parseObject(config.env); - const env: Record = { ...buildPaperclipEnv(agent), PAPERCLIP_RUN_ID: runId }; + const env: Record = { ...buildPaperclipEnv(agent, input.ctx.agentIdentity), PAPERCLIP_RUN_ID: runId }; const wakeTaskId = (typeof context.taskId === "string" && context.taskId.trim()) || (typeof context.issueId === "string" && context.issueId.trim()) || @@ -2042,6 +2048,11 @@ async function buildRuntime(input: { if (acpxAgent === "codex") { const codexStartupConfig = buildCodexStartupConfig({ existingConfig: env.CODEX_CONFIG, + ...(input.ctx.agentIdentity ? { identityEnvironmentKeys: [...new Set([ + "PATH", "HOME", "LANG", "TMPDIR", "CODEX_HOME", + "PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", + ...Object.keys(env).filter(key => !/key|secret|token/i.test(key)), + ])] } : {}), requestedModel, requestedThinkingEffort, fastMode, @@ -2203,6 +2214,7 @@ async function buildRuntime(input: { // identifiers are NOT here; they scope the outer session key only (see // `keyIdentity`). The fingerprint builder accepts only this identity. const fingerprintIdentity: SessionFingerprintIdentity = { + ...(input.ctx.agentIdentity ? { agentIdentityKeyId: input.ctx.agentIdentity.keyId } : {}), acpxAgent, agentCommand: agentCommand ?? acpxAgent, cwd: path.resolve(sessionCwd), diff --git a/packages/adapter-utils/src/acpx-engine/run-contracts.ts b/packages/adapter-utils/src/acpx-engine/run-contracts.ts index 173cb5db9aa..1610ed7d911 100644 --- a/packages/adapter-utils/src/acpx-engine/run-contracts.ts +++ b/packages/adapter-utils/src/acpx-engine/run-contracts.ts @@ -439,6 +439,7 @@ export interface PaperclipClaudeSettingsIdentity { * or a resumable session and forces a fresh launch. */ export interface SessionFingerprintIdentity { + readonly agentIdentityKeyId?: string; readonly acpxAgent: string; readonly agentCommand: string; readonly cwd: string; diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts index c1599a25d8b..51ab309890f 100644 --- a/packages/adapter-utils/src/execution-target-sandbox.test.ts +++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts @@ -1,3 +1,5 @@ +import { generateKeyPairSync, randomBytes, verify } from "node:crypto"; +import { buildAgentIdentityEnv } from "./server-utils.js"; import { createServer } from "node:http"; import http2 from "node:http2"; import net from "node:net"; @@ -308,6 +310,29 @@ describe("sandbox adapter execution targets", () => { return events.filter((event) => event.stream === stream).map((event) => event.chunk).join(""); } + it("delivers cryptographic identity through a remote sandbox process transport", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "identity-remote-process-")); + cleanupDirs.push(rootDir); + const keys = generateKeyPairSync("ed25519"); + const identity = { + keyId: "sha256:remote-identity", + publicKeyPem: keys.publicKey.export({ type: "spki", format: "pem" }).toString(), + privateKeyPem: keys.privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + }; + const target: AdapterSandboxExecutionTarget = { + kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir, + timeoutMs: 30_000, runner: createLocalSandboxRunner(), + }; + const challenge = randomBytes(32); + const result = await runAdapterExecutionTargetProcess("identity-remote", target, process.execPath, + ["-e", `const {sign}=require('node:crypto');process.stdout.write(sign(null,Buffer.from(process.argv[1],'hex'),process.env.PAPERCLIP_AGENT_PRIVATE_KEY).toString('base64'));`, challenge.toString("hex")], { + cwd: rootDir, env: buildAgentIdentityEnv(identity), timeoutSec: 5, graceSec: 1, onLog: async () => {}, + }); + expect(result.exitCode).toBe(0); + expect(verify(null, challenge, keys.publicKey, Buffer.from(result.stdout, "base64"))).toBe(true); + expect((await readRuntimeTextFiles(rootDir)).join("\n")).not.toContain(identity.privateKeyPem.split("\n")[1]); + }); + it("executes through the provider-neutral runner without a remote spec", async () => { const runner = { execute: vi.fn(async () => ({ diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index eb02fb4efc4..1fb3046e1ed 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -1,5 +1,6 @@ export type { AdapterAgent, + AgentRuntimeIdentity, AdapterRuntime, UsageSummary, AdapterBillingType, diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index c35e4e2a8f6..520414d3b72 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -31,6 +31,8 @@ import { runningProcesses, runChildProcess, sanitizeSshRemoteEnv, + sanitizeInheritedPaperclipEnv, + isForbiddenConfigEnvKey, signalRunningProcess, shapePaperclipWorkspaceEnvForExecution, rewriteWorkspaceCwdEnvVarsForExecution, @@ -40,6 +42,13 @@ import { WATCHDOG_DEFAULT_MANDATE, } from "./server-utils.js"; +it("reserves identity credentials even for mixed-case inherited or configured keys", () => { + const keys = ["PAPERCLIP_AGENT_KEY_ID", "Paperclip_Agent_Public_Key", "paperclip_agent_private_key"]; + const inherited = Object.fromEntries(keys.map(key => [key, "host-override"])); + expect(sanitizeInheritedPaperclipEnv({ ...inherited, PATH: "/usr/bin" })).toEqual({ PATH: "/usr/bin" }); + for (const key of keys) expect(isForbiddenConfigEnvKey(key)).toBe(true); +}); + describe("runtime connection tool delivery", () => { const access = { version: 1 as const, diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index d07a634e825..e14d77d0d24 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -20,6 +20,7 @@ import { } from "./paperclip-runner-permissions.js"; import type { AdapterExecutionContext, + AgentRuntimeIdentity, AdapterRuntimeToolAccess, AdapterSkillEntry, AdapterSkillSnapshot, @@ -168,7 +169,8 @@ export function isPaperclipRuntimeEnvKey(key: string): boolean { // Other PAPERCLIP_*-named config keys are allowed as long as Paperclip has // not assigned the same key for the run (runtime vars always win). export function isForbiddenConfigEnvKey(key: string): boolean { - return key === "PAPERCLIP_API_KEY" || key === "PAPERCLIP_WAKE_PAYLOAD_JSON"; + return key === "PAPERCLIP_API_KEY" || key === "PAPERCLIP_WAKE_PAYLOAD_JSON" || + AGENT_IDENTITY_ENV_KEYS.includes(key.toUpperCase()); } const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [ "../../skills", @@ -3193,10 +3195,22 @@ export function buildInvocationEnvForLogs( return redactEnvForLogs(merged); } +export const AGENT_IDENTITY_ENV_KEYS = [ + "PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", +]; + +export function buildAgentIdentityEnv(identity?: AgentRuntimeIdentity): Record { + return identity ? { + PAPERCLIP_AGENT_KEY_ID: identity.keyId, + PAPERCLIP_AGENT_PUBLIC_KEY: identity.publicKeyPem, + PAPERCLIP_AGENT_PRIVATE_KEY: identity.privateKeyPem, + } : {}; +} + export function buildPaperclipEnv(agent: { id: string; companyId: string; -}): Record { +}, identity?: AgentRuntimeIdentity): Record { const resolveHostForUrl = (rawHost: string): string => { const host = rawHost.trim(); if (!host || host === "0.0.0.0" || host === "::") return "localhost"; @@ -3207,6 +3221,7 @@ export function buildPaperclipEnv(agent: { const vars: Record = { PAPERCLIP_AGENT_ID: agent.id, PAPERCLIP_COMPANY_ID: agent.companyId, + ...buildAgentIdentityEnv(identity), }; const runtimeHost = resolveHostForUrl( process.env.PAPERCLIP_LISTEN_HOST ?? process.env.HOST ?? "localhost", @@ -3495,6 +3510,10 @@ export function sanitizeInheritedPaperclipEnv( const env: NodeJS.ProcessEnv = { ...baseEnv }; delete env.PAPERCLIPAI_CMD; for (const key of Object.keys(env)) { + if (AGENT_IDENTITY_ENV_KEYS.includes(key.toUpperCase())) { + delete env[key]; + continue; + } if (!key.startsWith("PAPERCLIP_")) continue; if (key === "PAPERCLIP_RUNTIME_API_URL") continue; if (key === "PAPERCLIP_LISTEN_HOST") continue; diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index c11343cae4e..6b068adcd3b 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -196,7 +196,15 @@ export interface AdapterRuntimeEvent { payload?: Record; } +export interface AgentRuntimeIdentity { + keyId: string; + publicKeyPem: string; + privateKeyPem: string; +} + export interface AdapterExecutionContext { + /** Server-owned credentials. Never persist this object or merge it into config. */ + agentIdentity?: AgentRuntimeIdentity; /** Synchronous, content-free diagnostic scope; never stop or collection authority. */ onExecutionPhase?: AdapterExecutionPhaseSink; /** Run-scoped operator cancellation; adapters must settle before returning. */ diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 114cad4fa43..988c2479f5a 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -104,6 +104,7 @@ const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); const executeClaudeAcp = createClaudeAcpExecutor(); interface ClaudeExecutionInput { + agentIdentity?: AdapterExecutionContext["agentIdentity"]; runId: string; agent: AdapterExecutionContext["agent"]; config: Record; @@ -216,7 +217,7 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise = { ...buildPaperclipEnv(agent) }; + const env: Record = { ...buildPaperclipEnv(agent, input.agentIdentity) }; env.PAPERCLIP_RUN_ID = runId; const wakeTaskId = @@ -456,6 +457,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise ({ name: server.name, endpointPath: server.url, @@ -1228,6 +1228,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise identityNames.includes(key) || !/key|secret|token/i.test(key)); + args.unshift("-c", "features.shell_snapshot=false", "-c", 'shell_environment_policy.inherit="all"', "-c", "shell_environment_policy.ignore_default_excludes=true", + "-c", `shell_environment_policy.include_only=${JSON.stringify(shellKeys)}`); + } const commandNotesWithFastMode = execArgs.fastModeIgnoredReason == null ? commandNotes diff --git a/packages/adapters/cursor-cloud/src/server/execute.ts b/packages/adapters/cursor-cloud/src/server/execute.ts index 48e62b10eea..c4335fb71b8 100644 --- a/packages/adapters/cursor-cloud/src/server/execute.ts +++ b/packages/adapters/cursor-cloud/src/server/execute.ts @@ -108,7 +108,7 @@ function buildWakeEnv(ctx: AdapterExecutionContext, configEnv: Record = { ...configEnv, - ...buildPaperclipEnv(agent), + ...buildPaperclipEnv(agent, ctx.agentIdentity), ...buildRuntimeToolsEnv(ctx.runtimeTools), PAPERCLIP_RUN_ID: runId, }; diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index cfaa9949a9e..f0cbb4f4212 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -240,7 +240,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise = { - ...buildPaperclipEnv(agent), + ...buildPaperclipEnv(agent, ctx.agentIdentity), ...buildRuntimeToolsEnv(ctx.runtimeTools), }; env.PAPERCLIP_RUN_ID = runId; diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index 720c384d433..7f0e915709d 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -273,7 +273,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise = { - ...buildPaperclipEnv(agent), + ...buildPaperclipEnv(agent, ctx.agentIdentity), ...buildRuntimeToolsEnv(ctx.runtimeTools), }; env.PAPERCLIP_RUN_ID = runId; diff --git a/packages/adapters/grok-local/src/server/execute.ts b/packages/adapters/grok-local/src/server/execute.ts index 1170900e5fc..f2009f46e8f 100644 --- a/packages/adapters/grok-local/src/server/execute.ts +++ b/packages/adapters/grok-local/src/server/execute.ts @@ -311,7 +311,7 @@ async function executeTurn(ctx: AdapterExecutionContext): Promise => { const envConfig = parseObject(config.env); const env: Record = { - ...buildPaperclipEnv(agent), + ...buildPaperclipEnv(agent, ctx.agentIdentity), ...buildRuntimeToolsEnv(ctx.runtimeTools), }; env.PAPERCLIP_RUN_ID = runId; diff --git a/packages/adapters/hermes/src/server/execute.ts b/packages/adapters/hermes/src/server/execute.ts index ca70427b8d1..d835b064bbe 100644 --- a/packages/adapters/hermes/src/server/execute.ts +++ b/packages/adapters/hermes/src/server/execute.ts @@ -487,7 +487,7 @@ export async function execute( const env: Record = { ...(process.env as Record), ...(userEnv && typeof userEnv === "object" ? userEnv : {}), - ...buildPaperclipEnv(ctx.agent), + ...buildPaperclipEnv(ctx.agent, ctx.agentIdentity), ...buildRuntimeToolsEnv(ctx.runtimeTools), }; diff --git a/packages/adapters/kimi-local/src/server/execute.ts b/packages/adapters/kimi-local/src/server/execute.ts index b02ca0c02da..96a28c127b9 100644 --- a/packages/adapters/kimi-local/src/server/execute.ts +++ b/packages/adapters/kimi-local/src/server/execute.ts @@ -246,7 +246,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const env: Record = { - ...buildPaperclipEnv(agent), + ...buildPaperclipEnv(agent, ctx.agentIdentity), ...buildRuntimeToolsEnv(ctx.runtimeTools), }; env.PAPERCLIP_RUN_ID = runId; diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 6a1f0dc1c0a..2c6a599e07b 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -272,7 +272,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise = { - ...buildPaperclipEnv(agent), + ...buildPaperclipEnv(agent, ctx.agentIdentity), ...buildRuntimeToolsEnv(ctx.runtimeTools), }; env.PAPERCLIP_RUN_ID = runId; diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index 3149642b323..dc00a9946f1 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -277,7 +277,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise = { - ...buildPaperclipEnv(agent), + ...buildPaperclipEnv(agent, ctx.agentIdentity), ...buildRuntimeToolsEnv(ctx.runtimeTools), }; env.PAPERCLIP_RUN_ID = runId; diff --git a/packages/db/src/migrations/0306_windy_leo.sql b/packages/db/src/migrations/0306_windy_leo.sql new file mode 100644 index 00000000000..81c5ef748ae --- /dev/null +++ b/packages/db/src/migrations/0306_windy_leo.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS "agent_identity_keys" ( + "agent_id" uuid PRIMARY KEY NOT NULL, + "company_id" uuid NOT NULL, + "algorithm" text NOT NULL, + "key_id" text NOT NULL, + "public_key_pem" text NOT NULL, + "private_key_material" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "agent_identity_keys_algorithm_check" CHECK ("agent_identity_keys"."algorithm" = 'Ed25519') +); +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'public.agent_identity_keys'::regclass + AND conname = 'agent_identity_keys_owner_fk' + ) THEN + ALTER TABLE "agent_identity_keys" ADD CONSTRAINT "agent_identity_keys_owner_fk" FOREIGN KEY ("company_id","agent_id") REFERENCES "public"."agents"("company_id","id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$; diff --git a/packages/db/src/migrations/meta/0301_snapshot.json b/packages/db/src/migrations/meta/0306_snapshot.json similarity index 99% rename from packages/db/src/migrations/meta/0301_snapshot.json rename to packages/db/src/migrations/meta/0306_snapshot.json index 6a4bf1f5c1e..7078d40338b 100644 --- a/packages/db/src/migrations/meta/0301_snapshot.json +++ b/packages/db/src/migrations/meta/0306_snapshot.json @@ -1,6 +1,6 @@ { - "id": "357aa875-870e-4232-9b3f-4194a2c74844", - "prevId": "dfa5064e-7b35-4d20-8a3d-aa808c4d92e0", + "id": "597fc11f-c141-4357-b4f0-e500a4d84658", + "prevId": "392e3276-35d9-41b8-b703-9d592081bae8", "version": "7", "dialect": "postgresql", "tables": { @@ -829,6 +829,83 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.agent_identity_keys": { + "name": "agent_identity_keys", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key_pem": { + "name": "public_key_pem", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_material": { + "name": "private_key_material", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agent_identity_keys_owner_fk": { + "name": "agent_identity_keys_owner_fk", + "tableFrom": "agent_identity_keys", + "tableTo": "agents", + "columnsFrom": [ + "company_id", + "agent_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agent_identity_keys_algorithm_check": { + "name": "agent_identity_keys_algorithm_check", + "value": "\"agent_identity_keys\".\"algorithm\" = 'Ed25519'" + } + }, + "isRLSEnabled": false + }, "public.agent_instruction_heads": { "name": "agent_instruction_heads", "schema": "", @@ -51112,6 +51189,142 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.mcp_event_admissions": { + "name": "mcp_event_admissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reserves_subscription": { + "name": "reserves_subscription", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_event_admissions_expiry_idx": { + "name": "mcp_event_admissions_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_event_admissions_grant_idx": { + "name": "mcp_event_admissions_grant_idx", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_event_admissions_company_idx": { + "name": "mcp_event_admissions_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_event_admissions_company_id_companies_id_fk": { + "name": "mcp_event_admissions_company_id_companies_id_fk", + "tableFrom": "mcp_event_admissions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_event_admissions_grant_id_mcp_oauth_grants_id_fk": { + "name": "mcp_event_admissions_grant_id_mcp_oauth_grants_id_fk", + "tableFrom": "mcp_event_admissions", + "tableTo": "mcp_oauth_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.mcp_event_deliveries": { "name": "mcp_event_deliveries", "schema": "", @@ -51565,6 +51778,13 @@ "primaryKey": false, "notNull": true }, + "grant_types": { + "name": "grant_types", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"authorization_code\",\"refresh_token\"]'::jsonb" + }, "created_at": { "name": "created_at", "type": "timestamp with time zone", @@ -51581,6 +51801,181 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.mcp_oauth_device_requests": { + "name": "mcp_oauth_device_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_code_hash": { + "name": "device_code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code_hash": { + "name": "user_code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "requested_company_id": { + "name": "requested_company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "next_poll_at": { + "name": "next_poll_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_oauth_device_code_uq": { + "name": "mcp_oauth_device_code_uq", + "columns": [ + { + "expression": "device_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_user_code_uq": { + "name": "mcp_oauth_user_code_uq", + "columns": [ + { + "expression": "user_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_device_expiry_idx": { + "name": "mcp_oauth_device_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_oauth_device_requests_client_id_mcp_oauth_clients_id_fk": { + "name": "mcp_oauth_device_requests_client_id_mcp_oauth_clients_id_fk", + "tableFrom": "mcp_oauth_device_requests", + "tableTo": "mcp_oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_device_requests_grant_id_mcp_oauth_grants_id_fk": { + "name": "mcp_oauth_device_requests_grant_id_mcp_oauth_grants_id_fk", + "tableFrom": "mcp_oauth_device_requests", + "tableTo": "mcp_oauth_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.mcp_oauth_grants": { "name": "mcp_oauth_grants", "schema": "", @@ -51706,6 +52101,54 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.mcp_oauth_metadata_admissions": { + "name": "mcp_oauth_metadata_admissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mcp_oauth_metadata_admissions_expiry_idx": { + "name": "mcp_oauth_metadata_admissions_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.mcp_oauth_requests": { "name": "mcp_oauth_requests", "schema": "", @@ -51752,6 +52195,12 @@ "primaryKey": false, "notNull": true }, + "requested_company_id": { + "name": "requested_company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "grant_id": { "name": "grant_id", "type": "uuid", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index bb045d8e1df..ea487eed037 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2129,6 +2129,13 @@ "when": 1791300719193, "tag": "0305_chubby_vin_gonzales", "breakpoints": true + }, + { + "idx": 306, + "version": "7", + "when": 1791308373750, + "tag": "0306_windy_leo", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/agent_identity_keys.ts b/packages/db/src/schema/agent_identity_keys.ts new file mode 100644 index 00000000000..0cc9250a1b5 --- /dev/null +++ b/packages/db/src/schema/agent_identity_keys.ts @@ -0,0 +1,20 @@ +import { sql } from "drizzle-orm"; +import { pgTable, uuid, text, timestamp, jsonb, foreignKey, check } from "drizzle-orm/pg-core"; +import { agents } from "./agents.js"; + +export const agentIdentityKeys = pgTable("agent_identity_keys", { + agentId: uuid("agent_id").primaryKey(), + companyId: uuid("company_id").notNull(), + algorithm: text("algorithm").$type<"Ed25519">().notNull(), + keyId: text("key_id").notNull(), + publicKeyPem: text("public_key_pem").notNull(), + privateKeyMaterial: jsonb("private_key_material").$type>().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + owner: foreignKey({ + name: "agent_identity_keys_owner_fk", + columns: [table.companyId, table.agentId], + foreignColumns: [agents.companyId, agents.id], + }).onDelete("cascade"), + algorithmCheck: check("agent_identity_keys_algorithm_check", sql`${table.algorithm} = 'Ed25519'`), +})); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 65f43629ec4..0ef185057b9 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -225,3 +225,5 @@ export * from "./public_mcp.js"; export * from "./ai_connection_routing.js"; export { toolConnectionAppSnapshots } from "./tool_connection_app_snapshots.js"; export { toolConnectionAppSyncs } from "./tool_connection_app_syncs.js"; + +export { agentIdentityKeys } from "./agent_identity_keys.js"; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs index 9bb2c3b6c0b..98908dfdbdd 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs @@ -109,6 +109,9 @@ impl AcpxSidecarTransport { } }; let mut keys = vec![ + "PAPERCLIP_AGENT_KEY_ID", + "PAPERCLIP_AGENT_PUBLIC_KEY", + "PAPERCLIP_AGENT_PRIVATE_KEY", "LANGUAGE", "SSL_CERT_FILE", "SSL_CERT_DIR", diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs index bf6d79a874e..09e490c992f 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs @@ -32,6 +32,9 @@ const WARM_ATTACHMENT_TAIL_DRAIN_LIMIT: usize = 256; const WARM_ATTACHMENT_QUIET_WINDOW: Duration = Duration::from_millis(10); const WARM_ATTACHMENT_DRAIN_DEADLINE: Duration = Duration::from_millis(100); const OPENCODE_PROVIDER_ENVIRONMENT_KEYS: &[&str] = &[ + "PAPERCLIP_AGENT_KEY_ID", + "PAPERCLIP_AGENT_PUBLIC_KEY", + "PAPERCLIP_AGENT_PRIVATE_KEY", "OPENROUTER_API_KEY", "PAPERCLIP_NATIVE_MCP_NAME", "PAPERCLIP_NATIVE_MCP_URL", @@ -242,6 +245,8 @@ impl ProviderTraceSink { } fn frame(&mut self, direction: &str, raw: &[u8]) -> Option { + let redacted = crate::durable::redact_agent_identity_text(&String::from_utf8_lossy(raw)); + let raw = redacted.as_bytes(); if self.captured_bytes.saturating_add(raw.len()) > self.max_bytes { self.truncated = true; return None; @@ -792,6 +797,9 @@ const GITHUB_CREDENTIAL_ENVIRONMENT_KEYS: &[&str] = &[ ]; const CODEX_PROVIDER_ENVIRONMENT_KEYS: &[&str] = &[ + "PAPERCLIP_AGENT_KEY_ID", + "PAPERCLIP_AGENT_PUBLIC_KEY", + "PAPERCLIP_AGENT_PRIVATE_KEY", "CODEX_HOME", "OPENAI_API_KEY", "CODEX_API_KEY", diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/mod.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/mod.rs index 7cbe493ba05..1a47897bd0c 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/mod.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/mod.rs @@ -16,8 +16,9 @@ pub use runner::{ TerminalDeliveryReconciliation, }; pub(crate) use state::{ - create_private_temporary_file, open_private_regular_file, redact_sensitive_text_values, - redact_text, sanitize_value, validate_semantic_tool_input, verify_private_directory, + create_private_temporary_file, open_private_regular_file, redact_agent_identity_text, + redact_sensitive_text_values, redact_text, sanitize_value, validate_semantic_tool_input, + verify_private_directory, }; pub use state::{ Command, CommandDisposition, DurableState, DurableStateStore, EventPriority, diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs index 894a00176c3..8ae20ab8046 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs @@ -1842,7 +1842,30 @@ pub(crate) fn redact_text(input: &str) -> String { redacted } +pub(crate) fn redact_agent_identity_text(input: &str) -> String { + let Ok(key) = std::env::var("PAPERCLIP_AGENT_PRIVATE_KEY") else { + return input.to_owned(); + }; + if key.is_empty() { + return input.to_owned(); + } + let escaped = serde_json::to_string(&key).unwrap_or_default(); + let mut result = input.replace(&key, "[REDACTED]"); + if escaped.len() > 2 { + result = result.replace(&escaped[1..escaped.len() - 1], "[REDACTED]"); + } + for line in key + .lines() + .filter(|line| !line.is_empty() && !line.starts_with("-----")) + { + result = result.replace(line, "[REDACTED]"); + } + result +} + pub(crate) fn redact_sensitive_text_values(input: &str) -> String { + let identity_redacted = redact_agent_identity_text(input); + let input = identity_redacted.as_str(); let normalized = input.to_ascii_lowercase(); let bytes = normalized.as_bytes(); let mut ranges: Vec<(usize, usize)> = Vec::new(); diff --git a/packages/paperclip-runner/src/agent-identity-environment.test.ts b/packages/paperclip-runner/src/agent-identity-environment.test.ts new file mode 100644 index 00000000000..ea82e078ecf --- /dev/null +++ b/packages/paperclip-runner/src/agent-identity-environment.test.ts @@ -0,0 +1,34 @@ +import { execFileSync } from "node:child_process"; +import { generateKeyPairSync, randomBytes, verify } from "node:crypto"; +import { afterEach, expect, it, vi } from "vitest"; +import { createSanitizedCodexEnvironment } from "./drivers/codex/app-server-transport.js"; +import { createIsolatedCodexAppServerArgs } from "./drivers/codex/codex-security-config.js"; +import { createSanitizedAcpxSpawnInput } from "./drivers/acpx/environment.js"; + +afterEach(() => vi.unstubAllEnvs()); + +it("native Codex and ACPX provider processes receive explicit identity and can sign", () => { + const keys = generateKeyPairSync("ed25519"); + const environment = { + PAPERCLIP_AGENT_KEY_ID: "sha256:test-key", + PAPERCLIP_AGENT_PUBLIC_KEY: keys.publicKey.export({ type: "spki", format: "pem" }).toString(), + PAPERCLIP_AGENT_PRIVATE_KEY: keys.privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + }; + const challenge = randomBytes(32); + const source = `const {sign}=require('node:crypto'); process.stdout.write(sign(null,Buffer.from(process.argv[1],'hex'),process.env.PAPERCLIP_AGENT_PRIVATE_KEY).toString('base64'));`; + for (const env of [createSanitizedCodexEnvironment(environment), createSanitizedAcpxSpawnInput(environment, "codex").env]) { + expect(env).toMatchObject(environment); + const signature = execFileSync(process.execPath, ["-e", source, challenge.toString("hex")], { env }); + expect(verify(null, challenge, keys.publicKey, Buffer.from(signature.toString(), "base64"))).toBe(true); + } + const args = createIsolatedCodexAppServerArgs(environment).join("\n"); + expect(args).toContain('shell_environment_policy.inherit="all"'); + expect(args).toContain("PAPERCLIP_AGENT_PRIVATE_KEY"); + expect(args).not.toContain(environment.PAPERCLIP_AGENT_PRIVATE_KEY.split("\n")[1]); +}); + +it("native launchers do not adopt identity from an ambient host environment", () => { + vi.stubEnv("PAPERCLIP_AGENT_PRIVATE_KEY", "ambient-forged-key"); + expect(createSanitizedCodexEnvironment().PAPERCLIP_AGENT_PRIVATE_KEY).toBeUndefined(); + expect(createSanitizedAcpxSpawnInput(undefined, "codex").env.PAPERCLIP_AGENT_PRIVATE_KEY).toBeUndefined(); +}); diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index 9d089fde854..60085da3b68 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -151,6 +151,7 @@ export interface NativeExecutionInputV1 { runId: string; issueId: string; agentId: string; + agentKeyId?: string; executionWorkspaceId: string; }; task: { @@ -344,7 +345,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput } const binding = record(input.binding, "input.binding"); - exactKeys(binding, ["companyId", "runId", "issueId", "agentId", "executionWorkspaceId"], "input.binding"); + exactKeys(binding, ["companyId", "runId", "issueId", "agentId", "agentKeyId", "executionWorkspaceId"], "input.binding"); const task = record(input.task, "input.task"); exactKeys(task, ["identifier", "title", "description", "prompt", "workMode"], "input.task"); const workspace = record(input.workspace, "input.workspace"); @@ -709,6 +710,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput runId: text(binding.runId, "input.binding.runId"), issueId: text(binding.issueId, "input.binding.issueId"), agentId: text(binding.agentId, "input.binding.agentId"), + ...(binding.agentKeyId === undefined ? {} : { agentKeyId: text(binding.agentKeyId, "input.binding.agentKeyId") }), executionWorkspaceId: text(binding.executionWorkspaceId, "input.binding.executionWorkspaceId"), }, task: { diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts index 03ba01a956c..dfc3a831262 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts @@ -390,6 +390,9 @@ it("preserves an explicit OpenCode permission mode at the runner spawn boundary" runnerDigest: expectedRunnerDigest, environment: { PATH: "/bin", + PAPERCLIP_AGENT_KEY_ID: "sha256:identity", + PAPERCLIP_AGENT_PUBLIC_KEY: "public-pem", + PAPERCLIP_AGENT_PRIVATE_KEY: "private-pem", OPENROUTER_API_KEY: "provider-key", PAPERCLIP_OPENCODE_PERMISSION_MODE: "deny", PAPERCLIP_OPENCODE_RUNTIME_DIR: "/runner/opencode", @@ -419,6 +422,9 @@ it("preserves an explicit OpenCode permission mode at the runner spawn boundary" expect(launches).toHaveLength(1); expect(launches[0]!.environment).toMatchObject({ PATH: "/bin", + PAPERCLIP_AGENT_KEY_ID: "sha256:identity", + PAPERCLIP_AGENT_PUBLIC_KEY: "public-pem", + PAPERCLIP_AGENT_PRIVATE_KEY: "private-pem", OPENROUTER_API_KEY: "provider-key", PAPERCLIP_OPENCODE_PERMISSION_MODE: "deny", PAPERCLIP_OPENCODE_RUNTIME_DIR: "/runner/opencode", diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts index c7f8cc9e876..a61fee55257 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts @@ -3371,6 +3371,9 @@ const runnerPlatformEnvironmentKeys = [ ] as const; const runnerExplicitProviderEnvironmentKeys = [ + "PAPERCLIP_AGENT_KEY_ID", + "PAPERCLIP_AGENT_PUBLIC_KEY", + "PAPERCLIP_AGENT_PRIVATE_KEY", ...ACPX_CREDENTIAL_NAMES.pi, ...ACPX_CREDENTIAL_NAMES.cursor, ...ACPX_CREDENTIAL_NAMES.copilot, diff --git a/packages/paperclip-runner/src/drivers/acpx/environment.ts b/packages/paperclip-runner/src/drivers/acpx/environment.ts index a78fdcbe268..df705f2d9a6 100644 --- a/packages/paperclip-runner/src/drivers/acpx/environment.ts +++ b/packages/paperclip-runner/src/drivers/acpx/environment.ts @@ -110,6 +110,7 @@ export function createSanitizedAcpxSpawnInput( "RUST_BACKTRACE", "PAPERCLIP_NATIVE_MCP_NAME", "PAPERCLIP_NATIVE_MCP_URL", + ...(environment === undefined ? [] : ["PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY"]), ...credentialNames, ]); let retainedBytes = 0; diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index 8ecdbc88ede..b75c21ecf6a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -453,6 +453,14 @@ export async function prepareAcpxRuntimeSandbox(input: { // also affect provider startup and belongs at the launch boundary. "[features]", "shell_snapshot = false", + ...(input.environment?.PAPERCLIP_AGENT_KEY_ID ? [ + "[shell_environment_policy]", 'inherit = "all"', "ignore_default_excludes = true", + `include_only = ${JSON.stringify([...new Set([ + "PATH", "HOME", "LANG", "LANGUAGE", "TZ", "TMPDIR", "TEMP", "TMP", "CODEX_HOME", + "PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", + ...Object.keys(input.environment).filter(key => !/key|secret|token/i.test(key)), + ])])}`, + ] : []), "", ].join("\n"), ); diff --git a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts index 7c86f2bb35c..f6aafd72c08 100644 --- a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts +++ b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts @@ -227,8 +227,10 @@ const SAFE_ENVIRONMENT_KEYS = [ * separate empty-by-default environment and filesystem permission profile. */ export function createSanitizedCodexEnvironment( - source: NodeJS.ProcessEnv = process.env, + source?: NodeJS.ProcessEnv, ): NodeJS.ProcessEnv { + const explicit = source; + source ??= process.env; const environment: NodeJS.ProcessEnv = {}; for (const key of SAFE_ENVIRONMENT_KEYS) { const value = source[key]; @@ -236,6 +238,9 @@ export function createSanitizedCodexEnvironment( if (key.includes("PROXY") && proxyContainsCredentials(value)) continue; environment[key] = value; } + for (const key of ["PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY"]) { + if (explicit?.[key] !== undefined) environment[key] = explicit[key]; + } Object.assign(environment, githubCredentialEnvironment(source)); return environment; } diff --git a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts index f6480c12c03..3cc702004c2 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts @@ -176,7 +176,10 @@ export function createIsolatedCodexAppServerArgs( readOnlyRoots = [...new Set([...readOnlyRoots, ...codexNetworkReadOnlyRoots(source)])]; const networkAccess = codexNetworkAccess(source); const externalRunnerSandbox = usesExternalRunnerSandbox(source); - const inheritedGitHubKeys = githubCredentialEnvironmentKeys(source); + const inheritedGitHubKeys = [ + ...githubCredentialEnvironmentKeys(source), + ...["PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY"].filter(key => source[key] !== undefined), + ]; const hasProjectedEnvironment = inheritedGitHubKeys.length > 0; // Codex filters the configured `set` values through include_only as well. // Retain the explicit command PATH/HOME/locale settings, not ambient secrets. diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts index ab72e2cc4a1..cce75cc2f27 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -2020,7 +2020,11 @@ async function startRuntime(input: { const assignedMcp = nativeMcpLaunchBinding( input.options.environment ?? process.env, ); + const identityKey = input.options.environment?.PAPERCLIP_AGENT_PRIVATE_KEY; + const identityValues = identityKey ? [identityKey, JSON.stringify(identityKey).slice(1, -1), + ...identityKey.split(/\r?\n/).filter(line => line && !line.startsWith("-----"))] : []; input.trace?.addSensitiveValues([ + ...identityValues, password, authHeader, bridge.secret, @@ -2197,6 +2201,7 @@ async function startRuntime(input: { bridge, trace: input.trace, sensitiveValues: [ + ...identityValues, password, input.options.environment?.OPENROUTER_API_KEY, ].filter((value): value is string => Boolean(value)), @@ -2599,6 +2604,7 @@ function sanitizedEnvironment( overrides: Record, ): NodeJS.ProcessEnv { const allowed = [ + "PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", "PATH", "LANG", "LC_ALL", diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 68a7c84cc8f..c3ba73df559 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -3211,6 +3211,7 @@ export function resolveRunnerdAcpxPermissionMode( } const OPEN_CODE_RUNNER_ENVIRONMENT_KEYS = new Set([ + "PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", "PATH", "LANG", "LANGUAGE", @@ -3245,6 +3246,10 @@ function createSanitizedOpenCodeRunnerEnvironment( source: NodeJS.ProcessEnv | undefined, ): NodeJS.ProcessEnv { const candidate = { ...process.env, ...source }; + for (const key of ["PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY"]) { + delete candidate[key]; + if (source?.[key] !== undefined) candidate[key] = source[key]; + } return Object.fromEntries( Object.entries(candidate).filter( ([key, value]) => diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 511861df3bd..e20f8785a2e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -933,6 +933,7 @@ export type { AgentDetail, ClearAgentErrorResponse, AgentPermissions, + AgentPublicIdentity, AgentInstructionsBundleMode, AgentInstructionsFileSummary, AgentInstructionsFileDetail, diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index 2cbde14d9c6..2908981ccd8 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -415,3 +415,11 @@ export interface AgentInstructionCandidate { createdAt: string; updatedAt: string; } + +/** Public cryptographic identity; private material is runtime-only. */ +export interface AgentPublicIdentity { + algorithm: "Ed25519"; + keyId: string; + publicKeyPem: string; + createdAt: string; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 6d899e083ee..449dbc58097 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -280,6 +280,7 @@ export type { AgentDetail, ClearAgentErrorResponse, AgentPermissions, + AgentPublicIdentity, AgentRuntimeConfig, AgentInstructionsBundleMode, AgentInstructionsFileSummary, diff --git a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts index 9571e69b133..c7c1af49314 100644 --- a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts +++ b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts @@ -357,6 +357,12 @@ describe("agent cross-tenant route authorization", () => { isInstanceAdmin: false, }; const deniedCases = [ + { + label: "public cryptographic identity", + request: (app: express.Express) => + requestApp(app, (baseUrl) => request(baseUrl).get(`/api/agents/${agentId}/identity`)), + untouched: [], + }, { label: "pause", request: (app: express.Express) => diff --git a/server/src/__tests__/agent-identity-master-key.test.ts b/server/src/__tests__/agent-identity-master-key.test.ts new file mode 100644 index 00000000000..98029e89968 --- /dev/null +++ b/server/src/__tests__/agent-identity-master-key.test.ts @@ -0,0 +1,36 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; +import { expect, it, vi } from "vitest"; +import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js"; + +it("atomically publishes one complete master key across competing processes", async () => { + const directory = await mkdtemp(join(tmpdir(), "identity-master-race-")); + const keyFile = join(directory, "master.key"); + const providerPath = fileURLToPath(new URL("../secrets/local-encrypted-provider.ts", import.meta.url)); + const tsx = createRequire(import.meta.url).resolve("tsx/esm"); + try { + const script = `import {localEncryptedProvider as p} from ${JSON.stringify(providerPath)}; console.log(JSON.stringify(await p.createSecret({value:'race-challenge'})));`; + const children = await Promise.all(Array.from({ length: 6 }, () => promisify(execFile)(process.execPath, + ["--import", tsx, "--input-type=module", "-e", script], { + env: { ...process.env, PAPERCLIP_SECRETS_MASTER_KEY: "", PAPERCLIP_SECRETS_MASTER_KEY_FILE: keyFile }, + }))); + expect(Buffer.from(await readFile(keyFile, "utf8"), "base64")).toHaveLength(32); + expect((await stat(keyFile)).mode & 0o777).toBe(0o600); + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", ""); + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", keyFile); + for (const child of children) { + expect(await localEncryptedProvider.resolveVersion(JSON.parse(child.stdout))).toBe("race-challenge"); + } + await rm(keyFile); + await expect(localEncryptedProvider.resolveVersion(JSON.parse(children[0].stdout))).rejects.toThrow("master key file is missing"); + await expect(stat(keyFile)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + vi.unstubAllEnvs(); + await rm(directory, { recursive: true, force: true }); + } +}, 30_000); diff --git a/server/src/__tests__/agent-identity.test.ts b/server/src/__tests__/agent-identity.test.ts new file mode 100644 index 00000000000..1c52eb545d8 --- /dev/null +++ b/server/src/__tests__/agent-identity.test.ts @@ -0,0 +1,223 @@ +import { randomBytes, randomUUID, verify, generateKeyPairSync } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import express from "express"; +import request from "supertest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { eq } from "drizzle-orm"; +import { agentIdentityKeys, agents, companies, createDb, closeRegisteredClients, ensurePostgresDatabase, runDatabaseBackup, runDatabaseRestore, type Db } from "@paperclipai/db"; +import { resolveWorktreeSeedPlan } from "../../../cli/src/commands/worktree-lib.js"; +import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { agentIdentityService, supportsManagedAgentIdentity } from "../services/agent-identity.js"; +import { agentService } from "../services/agents.js"; +import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js"; +import { execute } from "../adapters/process/execute.js"; +import { createAgentIdentityRedactor } from "../services/agent-identity-redaction.js"; +import { heartbeatService } from "../services/heartbeat.js"; +import { agentRoutes } from "../routes/agents.js"; +import { actorMiddleware } from "../middleware/auth.js"; +import { errorHandler } from "../middleware/error-handler.js"; +import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.js"; + +describe("agent cryptographic identity", () => { + let db: Db; + let database: Awaited>; + let directory: string; + let companyId: string; + + beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), "paperclip-identity-")); + vi.stubEnv("PAPERCLIP_HOME", directory); + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", ""); + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", join(directory, "master.key")); + database = await startEmbeddedPostgresTestDatabase("paperclip-identity-"); + db = createDb(database.connectionString); + companyId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Identity test", issuePrefix: "KEY", defaultResponsibleUserId: "identity-test-owner" }); + }); + afterAll(async () => { + await database?.cleanup(); + vi.unstubAllEnvs(); + await rm(directory, { recursive: true, force: true }); + }); + + async function oldAgent() { + const [agent] = await db.insert(agents).values({ companyId, name: randomUUID(), role: "engineer" }).returning(); + return agent; + } + + it("migration and public reads leave existing agents unprovisioned", async () => { + const agent = await oldAgent(); + expect(await agentIdentityService(db).getPublicIdentity(companyId, agent.id)).toBeNull(); + expect(await db.select().from(agentIdentityKeys)).toHaveLength(0); + for (const type of ["http", "openclaw_gateway", "hermes_gateway"]) expect(supportsManagedAgentIdentity(type)).toBe(false); + for (const provider of ["claude_managed", "aws_agentcore"]) expect(supportsManagedAgentIdentity("paperclip_runner", { provider })).toBe(false); + expect(supportsManagedAgentIdentity("paperclip_runner", { provider: "codex" }, "claude_managed_agents_api")).toBe(false); + expect(supportsManagedAgentIdentity("paperclip_runner", { provider: "claude_managed" }, "codex_app_server")).toBe(true); + }); + + it("concurrent first runs converge, restart/model/name changes preserve keys, and ownership is enforced", async () => { + const agent = await oldAgent(); + const pairs = await Promise.all(Array.from({ length: 8 }, () => + agentIdentityService(db).ensureAgentIdentity(companyId, agent.id))); + expect(new Set(pairs.map(pair => pair.privateKeyPem)).size).toBe(1); + await db.update(agents).set({ name: "Renamed", adapterConfig: { model: "another-model" }, status: "paused" }).where(eq(agents.id, agent.id)); + const freshDb = createDb(database.connectionString); + expect(await agentIdentityService(freshDb).ensureAgentIdentity(companyId, agent.id)).toEqual(pairs[0]); + expect(await agentIdentityService(db).getPublicIdentity(randomUUID(), agent.id)).toBeNull(); + await expect(agentIdentityService(db).ensureAgentIdentity(randomUUID(), agent.id)).rejects.toThrow("Agent not found"); + const second = await oldAgent(); + expect((await agentIdentityService(db).ensureAgentIdentity(companyId, second.id)).keyId).not.toBe(pairs[0].keyId); + await db.delete(agents).where(eq(agents.id, second.id)); + expect(await agentIdentityService(db).getPublicIdentity(companyId, second.id)).toBeNull(); + }); + + it("creates identities in the agent creation transaction and rolls both writes back on failure", async () => { + const created = await agentService(db).create(companyId, { name: "New identity", role: "engineer", status: "pending_approval" }); + expect(await agentIdentityService(db).getPublicIdentity(companyId, created.id)).toMatchObject({ algorithm: "Ed25519" }); + const id = randomUUID(); + await expect(db.transaction(async tx => { + await tx.insert(agents).values({ id, companyId, name: "Rollback", role: "engineer" }); + await agentIdentityService(tx as unknown as Db).ensureAgentIdentity(companyId, id); + throw new Error("rollback"); + })).rejects.toThrow("rollback"); + expect(await db.select().from(agents).where(eq(agents.id, id))).toHaveLength(0); + expect(await agentIdentityService(db).getPublicIdentity(companyId, id)).toBeNull(); + const fail = vi.spyOn(localEncryptedProvider, "createSecret").mockRejectedValueOnce(new Error("encryption unavailable")); + await expect(agentService(db).create(companyId, { name: "Must roll back", role: "engineer", status: "pending_approval" })).rejects.toThrow("encryption unavailable"); + fail.mockRestore(); + expect(await db.select().from(agents).where(eq(agents.name, "Must roll back"))).toHaveLength(0); + }); + + it("fails closed for wrong master keys, corrupted material and mismatched public keys", async () => { + const agent = await oldAgent(); + const identity = await agentIdentityService(db).ensureAgentIdentity(companyId, agent.id); + const [stored] = await db.select().from(agentIdentityKeys).where(eq(agentIdentityKeys.agentId, agent.id)); + expect(JSON.stringify(stored)).not.toContain(identity.privateKeyPem.split("\n")[1]); + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", randomBytes(32).toString("base64")); + await expect(agentIdentityService(db).ensureAgentIdentity(companyId, agent.id)).rejects.toThrow("decryption failed"); + expect(await agentIdentityService(db).getPublicIdentity(companyId, agent.id)).toMatchObject({ keyId: identity.keyId }); + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", ""); + await db.update(agentIdentityKeys).set({ privateKeyMaterial: { ...stored.privateKeyMaterial, ciphertext: "AAAA" } }).where(eq(agentIdentityKeys.agentId, agent.id)); + await expect(agentIdentityService(db).ensureAgentIdentity(companyId, agent.id)).rejects.toThrow(); + const other = generateKeyPairSync("ed25519").publicKey.export({ type: "spki", format: "pem" }).toString(); + await db.update(agentIdentityKeys).set({ privateKeyMaterial: stored.privateKeyMaterial, publicKeyPem: other }).where(eq(agentIdentityKeys.agentId, agent.id)); + await expect(agentIdentityService(db).ensureAgentIdentity(companyId, agent.id)).rejects.toThrow("do not match"); + expect((await db.select().from(agentIdentityKeys).where(eq(agentIdentityKeys.agentId, agent.id)))[0].keyId).toBe(identity.keyId); + }); + + it("a real managed process signs a random challenge before and after reconnect, ignoring hostile env overrides", async () => { + const agent = await oldAgent(); + const challenge = randomBytes(32).toString("base64"); + let publicPem = ""; + for (let restart = 0; restart < 2; restart++) { + const restarted = agentIdentityService(createDb(database.connectionString)); + const identity = await restarted.ensureAgentIdentity(companyId, agent.id); + const publicIdentity = await restarted.getPublicIdentity(companyId, agent.id); + if (restart) expect(publicIdentity!.publicKeyPem).toBe(publicPem); + publicPem = publicIdentity!.publicKeyPem; + vi.stubEnv("PAPERCLIP_AGENT_PRIVATE_KEY", "inherited override"); + const logs: string[] = []; + const metas: unknown[] = []; + await execute({ + runId: randomUUID(), agent, agentIdentity: identity, + config: { command: process.execPath, cwd: directory, args: ["-e", `const {sign}=require('node:crypto'); process.stdout.write(sign(null,Buffer.from(process.env.CHALLENGE,'base64'),process.env.PAPERCLIP_AGENT_PRIVATE_KEY).toString('base64'));`], env: { CHALLENGE: challenge, PAPERCLIP_AGENT_PRIVATE_KEY: "configured override" } }, + context: {}, runtime: { sessionId: null, sessionParams: null, taskKey: null }, + onLog: async (_stream, chunk) => { logs.push(chunk); }, onMeta: async meta => { metas.push(meta); }, + }); + expect(verify(null, Buffer.from(challenge, "base64"), publicPem, Buffer.from(logs.join(""), "base64"))).toBe(true); + expect(JSON.stringify(metas)).not.toContain(identity.privateKeyPem.split("\n")[1]); + } + }); + + it("lazily provisions through managed run preparation, exposes only the public API key, and redacts emitted keys", async () => { + const agent = await oldAgent(); + const app = express(); + app.use(actorMiddleware(db, { deploymentMode: "local_trusted" })); + app.use("/api", agentRoutes(db)); + app.use(errorHandler); + const firstRead = await request(app).get(`/api/agents/${agent.id}/identity`); + expect(firstRead.status).toBe(200); + expect(firstRead.body).toBeNull(); + const challenge = randomBytes(32).toString("base64"); + await db.update(agents).set({ adapterType: "process", adapterConfig: { + command: process.execPath, cwd: directory, + args: ["-e", `const {sign}=require('node:crypto'); const k=process.env.PAPERCLIP_AGENT_PRIVATE_KEY; console.log('SIGNATURE:'+sign(null,Buffer.from(process.env.CHALLENGE,'base64'),k).toString('base64')); process.stdout.write(k.slice(0,40)); setTimeout(()=>process.stdout.write(k.slice(40)),30);`], + env: { CHALLENGE: challenge, PAPERCLIP_AGENT_PRIVATE_KEY: "ignored override" }, + } }).where(eq(agents.id, agent.id)); + let previousKeyId = ""; + for (let restart = 0; restart < 2; restart++) { + const heartbeat = heartbeatService(createDb(database.connectionString)); + try { + const run = await heartbeat.invoke(agent.id, "on_demand", {}, "manual"); + expect(run).toBeTruthy(); + await drainHeartbeatRunsToQuiescence(db, heartbeat); + const finished = await heartbeat.getRun(run!.id); + expect(finished?.status, JSON.stringify(finished)).toBe("succeeded"); + const publicResponse = await request(app).get(`/api/agents/${agent.id}/identity`); + expect(publicResponse.status).toBe(200); + expect(Object.keys(publicResponse.body).sort()).toEqual(["algorithm", "createdAt", "keyId", "publicKeyPem"]); + if (restart) expect(publicResponse.body.keyId).toBe(previousKeyId); + previousKeyId = publicResponse.body.keyId; + const signature = /SIGNATURE:([A-Za-z0-9+/=]+)/.exec(finished!.stdoutExcerpt ?? "")?.[1]; + expect(signature).toBeTruthy(); + expect(verify(null, Buffer.from(challenge, "base64"), publicResponse.body.publicKeyPem, Buffer.from(signature!, "base64"))).toBe(true); + const privateBody = (await agentIdentityService(db).ensureAgentIdentity(companyId, agent.id)).privateKeyPem.split("\n")[1]; + expect(JSON.stringify(finished)).not.toContain(privateBody); + expect(JSON.stringify(await heartbeat.readLog(run!.id))).not.toContain(privateBody); + } finally { + await drainHeartbeatRunsToQuiescence(db, heartbeat); + } + } + }, 60_000); + + it("disaster recovery retains identity; both development seed modes omit it and mint fresh keys", async () => { + const agent = await oldAgent(); + const original = await agentIdentityService(db).ensureAgentIdentity(companyId, agent.id); + for (const mode of ["backup", "minimal", "full"] as const) { + const seed = mode === "backup" ? undefined : resolveWorktreeSeedPlan(mode); + const backup = await runDatabaseBackup({ + connectionString: database.connectionString, backupDir: join(directory, mode), + backupEngine: "javascript", retention: { dailyDays: 1, weeklyWeeks: 0, monthlyMonths: 0 }, + excludeTables: seed?.excludedTables, nullifyColumns: seed?.nullifyColumns, + }); + const target = new URL(database.connectionString); + const admin = new URL(database.connectionString); + admin.pathname = "/postgres"; + await ensurePostgresDatabase(admin.toString(), `identity_${mode}`); + target.pathname = `/identity_${mode}`; + await runDatabaseRestore({ connectionString: target.toString(), backupFile: backup.backupFile }); + const restored = agentIdentityService(createDb(target.toString())); + if (mode === "backup") expect(await restored.ensureAgentIdentity(companyId, agent.id)).toEqual(original); + else { + expect(await restored.getPublicIdentity(companyId, agent.id)).toBeNull(); + expect((await restored.ensureAgentIdentity(companyId, agent.id)).keyId).not.toBe(original.keyId); + } + await closeRegisteredClients(target.toString()); + } + }, 60_000); +}); + +describe("identity output redaction", () => { + const pem = generateKeyPairSync("ed25519").privateKey.export({ format: "pem", type: "pkcs8" }).toString(); + it("redacts PEM, JSON-escaped PEM and raw private material", () => { + const redactor = createAgentIdentityRedactor(pem); + for (const text of [pem, JSON.stringify(pem), pem.split("\n")[1]]) { + expect(redactor.redact({ output: text }).output).not.toContain(pem.split("\n")[1]); + } + }); + it("redacts every split boundary and interleaved streams, including interrupted prefixes", () => { + for (let split = 1; split < pem.length; split++) { + const redactor = createAgentIdentityRedactor(pem); + let output = redactor.chunk("stdout", pem.slice(0, split)); + expect(redactor.chunk("stderr", "ordinary output\n")).toBe("ordinary output\n"); + output += redactor.chunk("stdout", pem.slice(split)); + output += redactor.finish("stdout"); + expect(output).not.toContain(pem.split("\n")[1]); + } + const redactor = createAgentIdentityRedactor(pem); + expect(redactor.chunk("stdout", pem.slice(0, 42))).toBe(""); + expect(redactor.finish("stdout")).toBe("***REDACTED***"); + }); +}); diff --git a/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts b/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts index 211c9e662cd..781937c13ce 100644 --- a/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts +++ b/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts @@ -1008,7 +1008,7 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => { expect(adapterInput.context.paperclipTaskMarkdown).not.toContain("Create child issues from the approved plan only"); }, 20_000); - it("preserves accepted-plan continuation resume state when the wake issue owns the in-flight claim", async () => { + it("preserves accepted-plan instructions but replaces a pre-identity session when the wake issue owns the in-flight claim", async () => { const companyId = randomUUID(); const projectId = randomUUID(); const projectWorkspaceId = randomUUID(); @@ -1138,7 +1138,8 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => { runtime: { sessionId: string | null; sessionParams: Record | null }; context: Record; }; - expect(adapterInput.runtime.sessionId).toBe("accepted-plan-retry-session"); + // The old process predates identity injection and must be replaced. + expect(adapterInput.runtime.sessionId).toBeNull(); expect(adapterInput.context.acceptedPlanWakeRouting).toBeUndefined(); expect(adapterInput.context.paperclipTaskMarkdown).toContain( "Implement the accepted plan on this issue when the work is small and cohesive.", diff --git a/server/src/adapters/process/execute.ts b/server/src/adapters/process/execute.ts index 0f08381574f..672a4827ab8 100644 --- a/server/src/adapters/process/execute.ts +++ b/server/src/adapters/process/execute.ts @@ -26,7 +26,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise = { - ...buildPaperclipEnv(agent), + ...buildPaperclipEnv(agent, ctx.agentIdentity), ...buildRuntimeToolsEnv(ctx.runtimeTools), }; for (const [k, v] of Object.entries(envConfig)) { diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 0f7e74bbee2..8cb235af1d1 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,3 +1,4 @@ +import { agentIdentityService } from "../services/agent-identity.js"; import { aiConnectionRouterService, poolMemberRuntimeConfig } from "../services/ai-connection-router.js"; import { connectionIntentService } from "../services/connection-intents.js"; import { completeConnectionIntentSchema } from "@paperclipai/shared"; @@ -4385,6 +4386,12 @@ export function agentRoutes( res.json(rows); }); + router.get("/agents/:id/identity", async (req, res) => { + const agent = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found"); + if (!agent || !(await assertAgentReadAllowed(req, res, agent))) return; + res.json(await agentIdentityService(db).getPublicIdentity(agent.companyId, agent.id)); + }); + router.get("/agents/:id", async (req, res) => { const id = req.params.id as string; const agent = await getAccessibleResource(req, res, svc.getById(id), "Agent not found"); diff --git a/server/src/secrets/local-encrypted-provider.ts b/server/src/secrets/local-encrypted-provider.ts index 33d3c0c17c4..ba7daa5bc18 100644 --- a/server/src/secrets/local-encrypted-provider.ts +++ b/server/src/secrets/local-encrypted-provider.ts @@ -1,5 +1,5 @@ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; -import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, linkSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import path from "node:path"; import { resolveDefaultSecretsKeyFilePath } from "../home-paths.js"; import type { @@ -45,7 +45,7 @@ function decodeMasterKey(raw: string): Buffer | null { return null; } -function loadOrCreateMasterKey(): Buffer { +function loadOrCreateMasterKey(allowCreate = true): Buffer { const envKeyRaw = process.env.PAPERCLIP_SECRETS_MASTER_KEY; if (envKeyRaw && envKeyRaw.trim().length > 0) { const fromEnv = decodeMasterKey(envKeyRaw); @@ -68,16 +68,24 @@ function loadOrCreateMasterKey(): Buffer { return decoded; } + if (!allowCreate) throw new Error(`Secrets master key file is missing: ${keyPath}`); const dir = path.dirname(keyPath); mkdirSync(dir, { recursive: true }); const generated = randomBytes(32); - writeFileSync(keyPath, generated.toString("base64"), { encoding: "utf8", mode: 0o600 }); + const temporaryPath = `${keyPath}.${process.pid}.${randomBytes(12).toString("hex")}.tmp`; + writeFileSync(temporaryPath, generated.toString("base64"), { encoding: "utf8", mode: 0o600, flag: "wx" }); try { - chmodSync(keyPath, 0o600); - } catch { - // best effort + // A hard link publishes a complete file without overwriting a competitor's + // key. Opening the destination with wx would expose an empty/partial file. + try { + linkSync(temporaryPath, keyPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + } + } finally { + unlinkSync(temporaryPath); } - return generated; + return loadOrCreateMasterKey(false); } function enforceKeyFilePermissionsBestEffort(keyPath: string) { @@ -272,7 +280,7 @@ export const localEncryptedProvider: SecretProviderModule = { throw badRequest("local_encrypted does not support external reference secrets"); }, async resolveVersion(input) { - const masterKey = loadOrCreateMasterKey(); + const masterKey = loadOrCreateMasterKey(false); return decryptValue(masterKey, asLocalEncryptedMaterial(input.material)); }, async deleteOrArchive() { diff --git a/server/src/services/agent-identity-redaction.ts b/server/src/services/agent-identity-redaction.ts new file mode 100644 index 00000000000..35b581dc4c5 --- /dev/null +++ b/server/src/services/agent-identity-redaction.ts @@ -0,0 +1,37 @@ +import { REDACTED_EVENT_VALUE } from "../redaction.js"; +import { redactRegisteredSecretValues } from "./run-secret-redaction.js"; + +/** Keep private material in memory only; do not copy it into run snapshots. */ +export function createAgentIdentityRedactor(privateKeyPem?: string) { + const values = privateKeyPem ? [...new Set([ + privateKeyPem, + privateKeyPem.trim(), + JSON.stringify(privateKeyPem).slice(1, -1), + ...privateKeyPem.split(/\r?\n/).filter(line => line && !line.startsWith("-----")), + ])].sort((a, b) => b.length - a.length) : []; + const pending = new Map(); + return { + values, + redact(value: T): T { return redactRegisteredSecretValues(value, values); }, + // Hold only a suffix that could begin a secret. Separate buffers prevent + // interleaved stdout/stderr from defeating matching across output chunks. + chunk(stream: string, chunk: string): string { + let text = (pending.get(stream) ?? "") + chunk; + for (const value of values) text = text.split(value).join(REDACTED_EVENT_VALUE); + let held = 0; + for (const value of values) { + for (let size = Math.min(value.length - 1, text.length); size > held; size--) { + if (text.endsWith(value.slice(0, size))) { held = size; break; } + } + } + pending.set(stream, held ? text.slice(-held) : ""); + return held ? text.slice(0, -held) : text; + }, + // An interrupted partial secret must not be flushed as plaintext. + finish(stream: string): string { + const held = pending.get(stream); + pending.delete(stream); + return held ? REDACTED_EVENT_VALUE : ""; + }, + }; +} diff --git a/server/src/services/agent-identity.ts b/server/src/services/agent-identity.ts new file mode 100644 index 00000000000..462c0393931 --- /dev/null +++ b/server/src/services/agent-identity.ts @@ -0,0 +1,88 @@ +import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { agentIdentityKeys, agents, type Db } from "@paperclipai/db"; +import type { AgentPublicIdentity } from "@paperclipai/shared"; +import { notFound } from "../errors.js"; +import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js"; +import { persistActivity } from "./activity-log.js"; + +const publicColumns = { + algorithm: agentIdentityKeys.algorithm, + keyId: agentIdentityKeys.keyId, + publicKeyPem: agentIdentityKeys.publicKeyPem, + createdAt: agentIdentityKeys.createdAt, +}; + +export function agentIdentityService(db: Db) { + const ownership = (companyId: string, agentId: string) => and( + eq(agentIdentityKeys.companyId, companyId), eq(agentIdentityKeys.agentId, agentId), + ); + + return { + // Reading an identity must never provision it or decrypt private material. + async getPublicIdentity(companyId: string, agentId: string): Promise { + const [row] = await db.select(publicColumns).from(agentIdentityKeys).where(ownership(companyId, agentId)); + return row ? { ...row, createdAt: row.createdAt.toISOString() } : null; + }, + + async ensureAgentIdentity(companyId: string, agentId: string) { + return db.transaction(async (tx) => { + // Lock the parent even when there is no identity row yet. This also + // serializes deletion and works inside the agent-creation transaction. + const [owner] = await tx.select({ id: agents.id }).from(agents) + .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))).for("update"); + if (!owner) throw notFound("Agent not found"); + let [row] = await tx.select().from(agentIdentityKeys).where(ownership(companyId, agentId)); + if (!row) { + const keys = generateKeyPairSync("ed25519"); + const publicKeyPem = keys.publicKey.export({ type: "spki", format: "pem" }).toString(); + const keyId = fingerprint(keys.publicKey.export({ type: "spki", format: "der" })); + const privateKeyPem = keys.privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const encrypted = await localEncryptedProvider.createSecret({ value: privateKeyPem }); + [row] = await tx.insert(agentIdentityKeys).values({ + companyId, agentId, algorithm: "Ed25519", keyId, publicKeyPem, + privateKeyMaterial: encrypted.material, + }).returning(); + await persistActivity(tx as unknown as Db, { + companyId, actorType: "system", actorId: "system", + action: "agent.identity.created", entityType: "agent", entityId: agentId, + details: { algorithm: "Ed25519", keyId }, + }); + } + const privateKeyPem = await localEncryptedProvider.resolveVersion({ + material: row.privateKeyMaterial, externalRef: null, + }); + // Fail closed on corruption or a wrong master key; never replace a key. + const privateKey = createPrivateKey(privateKeyPem); + const derived = createPublicKey(privateKey).export({ type: "spki", format: "der" }); + const stored = createPublicKey(row.publicKeyPem).export({ type: "spki", format: "der" }); + if (row.algorithm !== "Ed25519" || privateKey.asymmetricKeyType !== "ed25519" || + !derived.equals(stored) || fingerprint(stored) !== row.keyId) { + throw new Error("Agent identity public and private keys do not match"); + } + return { keyId: row.keyId, publicKeyPem: row.publicKeyPem, privateKeyPem }; + }); + }, + }; +} + +function fingerprint(spki: Buffer): string { + return `sha256:${createHash("sha256").update(spki).digest("base64url")}`; +} + +export function supportsManagedAgentIdentity( + adapterType: string, + config?: { provider?: unknown }, + persistedDriver?: string | null, +): boolean { + if (adapterType === "paperclip_runner") { + // API-hosted providers have no process environment under our control. + return persistedDriver + ? ["codex", "codex_app_server", "opencode_server", "acpx_runtime"].includes(persistedDriver) + : [undefined, "codex", "opencode", "acpx"].includes(config?.provider as string | undefined); + } + return new Set([ + "process", "claude_local", "codex_local", "cursor_cloud", + "gemini_local", "grok_local", "hermes_local", "kimi_local", "opencode_local", "pi_local", "cursor", + ]).has(adapterType); +} diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 13450be91f0..eb1bdacefcd 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -58,6 +58,8 @@ import { } from "./built-in-agent-metadata.js"; import { issueThreadInteractionService } from "./issue-thread-interactions.js"; +import { agentIdentityService } from "./agent-identity.js"; + function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); } @@ -945,6 +947,7 @@ export function agentService(db: Db) { }) .returning() .then((rows) => rows[0]); + await agentIdentityService(txDb).ensureAgentIdentity(companyId, created.id); // New standard agents receive the standard direct grants at activation. // Low-trust and bundled agents keep their explicit, narrower grants. if (created.status !== "pending_approval" && !permissionsImplyLowTrust(normalizedPermissions) && diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 8d512560003..43c7295da77 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1,3 +1,6 @@ +import { createAgentIdentityRedactor } from "./agent-identity-redaction.js"; +import { agentIdentityService, supportsManagedAgentIdentity } from "./agent-identity.js"; +import { buildAgentIdentityEnv } from "@paperclipai/adapter-utils/server-utils"; import { retryIdempotentDatabaseOperation } from "../database-retry.js"; import { prepareConnectionInstructionDelivery } from "./connection-instructions.js"; import { resolveAssignedConnectionInstructionsForRun } from "./native-runtime/assigned-mcp-tools.js"; @@ -1487,6 +1490,7 @@ const LOW_TRUST_SENSITIVE_ENV_KEY_RE = // 3. Any other PAPERCLIP_*-named binding is user data and flows through to // the run env like any non-prefixed binding. const FORBIDDEN_ENV_BINDING_KEYS = new Set([ + "PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", "PAPERCLIP_RUNNER_NETWORK_ACCESS", "PAPERCLIP_RUNNER_NETWORK_ROOTS", "PAPERCLIP_API_KEY", @@ -1514,7 +1518,7 @@ function stripForbiddenEnvBindings( const filtered = Object.fromEntries( Object.entries(record).filter( ([key]) => - !FORBIDDEN_ENV_BINDING_KEYS.has(key) && + !FORBIDDEN_ENV_BINDING_KEYS.has(key.toUpperCase()) && !(managedGitHubCredentials && MANAGED_GITHUB_TOKEN_KEYS.has(key)), ), ); @@ -6579,6 +6583,7 @@ function buildSessionConfigCategoryValues(input: { secretManifest: readonly EffectiveRunConfigSecretManifestEntry[]; runtimeSkills: unknown; agentConfigRevision: unknown; + agentIdentityKeyId?: string; }) { const sanitizedSecretManifest = sanitizeSecretManifestForConfigFingerprint( input.secretManifest, @@ -6600,6 +6605,7 @@ function buildSessionConfigCategoryValues(input: { return { adapter: { adapterType: input.adapterType, + ...(input.agentIdentityKeyId ? { agentIdentityKeyId: input.agentIdentityKeyId } : {}), agentConfigRevision: input.agentConfigRevision, }, adapterConfig: input.effectiveAdapterConfig, @@ -6632,6 +6638,7 @@ export async function buildEffectiveRunSessionConfigMetadata(input: { secretManifest?: readonly EffectiveRunConfigSecretManifestEntry[]; runtimeSkills: unknown; agentConfigRevision?: unknown; + agentIdentityKeyId?: string; }): Promise { const secretManifest = input.secretManifest ?? []; const instructions = await resolveInstructionsConfigFingerprintMetadata( @@ -6651,6 +6658,7 @@ export async function buildEffectiveRunSessionConfigMetadata(input: { secretManifest, runtimeSkills: input.runtimeSkills, agentConfigRevision: input.agentConfigRevision ?? null, + agentIdentityKeyId: input.agentIdentityKeyId, }); const fingerprints = createEffectiveRunConfigFingerprints({ session: categoryValues, @@ -20532,6 +20540,7 @@ export function heartbeatService( > | null = null; let providerTraceFinalized = false; let readFailureReportSecrets: () => string[] = () => []; + let identityRedactor = createAgentIdentityRedactor(); try { let agent = await getAgent(run.agentId); @@ -21870,9 +21879,15 @@ export function heartbeatService( resolve: () => resolveAssignedConnectionInstructionsForRun(db, { companyId: agent.companyId, agentId: agent.id, runId: run.id }), context, config: connectorDelivery.config, native: agent.adapterType === "paperclip_runner", }); + const agentIdentity = supportsManagedAgentIdentity(agent.adapterType, agent.adapterConfig, run.runtimeMode === "native" ? run.driverKind : undefined) + ? await agentIdentityService(db).ensureAgentIdentity(agent.companyId, agent.id) + : undefined; + identityRedactor = createAgentIdentityRedactor(agentIdentity?.privateKeyPem); + if (agentIdentity) secretKeys.add("PAPERCLIP_AGENT_PRIVATE_KEY"); const resolvedFailureSecrets = readFailureReportSecrets(); readFailureReportSecrets = () => [ ...resolvedFailureSecrets, + ...identityRedactor.values, ...collectRunFailureSecretValues(runtimeConfig.env, secretKeys), ]; const latestAgentConfigRevision = await getLatestAgentConfigRevision( @@ -21880,6 +21895,7 @@ export function heartbeatService( agent.id, ); const sessionConfigMetadataInput = { + agentIdentityKeyId: agentIdentity?.keyId, adapterType: agent.adapterType, effectiveAdapterConfig: runtimeConfig, managedAiHome: managedAiRuntime?.home, @@ -21978,7 +21994,7 @@ export function heartbeatService( compatibleConfigMetadata, wakeResetReason: wakeSessionResetReason, preserveLegacySessionWithoutConfigMetadata: - acceptedPlanContinuationWake && !acceptedPlanWakeRoutingDecision, + acceptedPlanContinuationWake && !acceptedPlanWakeRoutingDecision && !agentIdentity, }); const resetTaskSession = shouldResetTaskSessionForWake(context) || sessionConfigFreshness.reset; @@ -23432,7 +23448,7 @@ export function heartbeatService( await getCurrentUserRedactionOptions(); const onLog = async (stream: "stdout" | "stderr", chunk: string) => { const sanitizedChunk = compactRunLogChunk( - redactCurrentUserText(chunk, currentUserRedactionOptions), + redactCurrentUserText(identityRedactor.chunk(stream, chunk), currentUserRedactionOptions), ); if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk); @@ -23593,6 +23609,7 @@ export function heartbeatService( } } const onAdapterMeta = async (meta: AdapterInvocationMeta) => { + meta = identityRedactor.redact(meta); if (meta.env && secretKeys.size > 0) { for (const key of secretKeys) { if (key in meta.env) meta.env[key] = "***REDACTED***"; @@ -23608,6 +23625,7 @@ export function heartbeatService( }; const onAdapterEvent = async (event: AdapterRuntimeEvent) => { + event = identityRedactor.redact(event); const eventType = event.eventType.trim(); if (!eventType) return; await appendRunEvent(currentRun, { @@ -24176,6 +24194,7 @@ export function heartbeatService( getNativeFreshSessionHandoff = nativeReviewRequest ? undefined : getFreshSessionHandoff; const buildExecution = ({ normalizedSessionId, resumedSession }: { normalizedSessionId: string; resumedSession: boolean }) => buildNativeExecutionInput({ + agentKeyId: agentIdentity?.keyId, companyId: agent.companyId, runId: run.id, issue: nativeReviewRequest ? { ...issueRef, title: `Review: ${issueRef.title}`, description: nativeReviewRequest } : issueRef, @@ -24909,6 +24928,7 @@ export function heartbeatService( process.env, executionWorkspace.cwd, ), + ...buildAgentIdentityEnv(agentIdentity), ...(instructionCopy && isAgentDirectoryCopy(instructionCopy) ? { AGENT_HOME: instructionCopy.executionRoot } : {}), ...(nativeMcpServer ? { @@ -25065,6 +25085,7 @@ export function heartbeatService( legacyAdapterEntered = true; return withAdapterExecutionPhase(executionPhaseContext, "adapter_execution", () => adapter.execute({ getFreshSessionHandoff, + agentIdentity, runId: run.id, agent, runtime: runtimeForAdapter, @@ -25145,6 +25166,11 @@ export function heartbeatService( if (!guardedDispatch.dispatched) return; adapterResult = await guardedDispatch.resultPromise; } + adapterResult = identityRedactor.redact(adapterResult); + for (const stream of ["stdout", "stderr"] as const) { + const tail = identityRedactor.finish(stream); + if (tail) await onLog(stream, tail); + } if (instructionSave) adapterResult.resultJson = { ...adapterResult.resultJson, instructionSave }; adapterResult = applyWorkspaceRestoreFailure(adapterResult); // A returned result can include a failed restore. Keep the workspace @@ -26223,7 +26249,7 @@ export function heartbeatService( // child. Let the cancellation write settle before attempting failure. await processRunCancellationSettlements.get(run.id)?.settled; const message = redactCurrentUserText( - err instanceof Error ? err.message : "Unknown adapter failure", + identityRedactor.redact(err instanceof Error ? err.message : "Unknown adapter failure"), await getCurrentUserRedactionOptions(), ); const workspaceValidationFailure = isWorkspaceValidationFailure(err) @@ -26266,7 +26292,7 @@ export function heartbeatService( recordedResponsibleUserDenialCode ?? nativeTerminalFailureCode ?? "adapter_failed"; - logger.error({ err, runId }, "heartbeat execution failed"); + logger.error({ err: identityRedactor.redact({ message: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined }), runId }, "heartbeat execution failed"); let logSummary: { bytes: number | null; @@ -26480,9 +26506,9 @@ export function heartbeatService( // Setup code before adapter.execute threw (e.g. ensureRuntimeState, resolveWorkspaceForRun). // The inner catch did not fire, so we must record the failure here. const message = redactCurrentUserText( - outerErr instanceof Error + identityRedactor.redact(outerErr instanceof Error ? outerErr.message - : "Unknown setup failure", + : "Unknown setup failure"), await getCurrentUserRedactionOptions(), ); // A missing secret/env binding is a known pre-dispatch configuration gap, @@ -26526,7 +26552,7 @@ export function heartbeatService( nonRetryablePreflightCode ?? "setup_failed"; logger.error( - { err: outerErr, runId }, + { err: identityRedactor.redact({ message: outerErr instanceof Error ? outerErr.message : String(outerErr), stack: outerErr instanceof Error ? outerErr.stack : undefined }), runId }, "heartbeat execution setup failed", ); const setupFailureAgent = await getAgent(run.agentId).catch(() => null); diff --git a/server/src/services/native-runtime/native-execution-input.ts b/server/src/services/native-runtime/native-execution-input.ts index 839b52ccc4b..2626701c11a 100644 --- a/server/src/services/native-runtime/native-execution-input.ts +++ b/server/src/services/native-runtime/native-execution-input.ts @@ -57,6 +57,7 @@ export function buildNativeExecutionInput(input: { previousTurn?: { runId: string; task: { title: string; description: string | null } } | null; conversationMode?: boolean; agentId: string; + agentKeyId?: string; workspace: { id: string; cwd: string; @@ -207,6 +208,7 @@ export function buildNativeExecutionInput(input: { runId: input.runId, issueId: input.issue.id, agentId: input.agentId, + ...(input.agentKeyId ? { agentKeyId: input.agentKeyId } : {}), executionWorkspaceId: input.workspace.id, }, task: { diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index f1ef9b31cf8..cb5d0ef8bb1 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -6271,6 +6271,23 @@ describe("native warm session supervision", () => { return { base, next, run }; } + it("replaces an older warm process before the first turn with cryptographic identity", async () => { + const { base } = fixture("identity-key-upgrade"); + const next = { ...base, binding: { ...base.binding, runId: "identity-key-next" } }; + const close = vi.fn(async () => undefined); + state.execute.mockReset().mockImplementationOnce(async options => { + options.onSession?.({ close }); return result; + }).mockImplementationOnce(async options => { + expect(close).toHaveBeenCalledWith({ reason: "warm native session configuration changed" }); + expect(options.existingSession).toBeUndefined(); + return result; + }); + await executePaperclipNativeSession({ db: leaseDb(base), execution: base, runnerInstanceId: "runner" }); + await executePaperclipNativeSession({ db: leaseDb(next), execution: next, runnerInstanceId: "runner", + runnerEnvironment: { PAPERCLIP_AGENT_KEY_ID: "sha256:identity-key" } }); + expect(state.execute).toHaveBeenCalledTimes(2); + }); + it("awaits prior idle ownership retirement before launching the accepted-plan session", async () => { const { base, next, run } = fixture("identity-handoff"); let finishClose!: () => void; diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 981169652ec..6a2bfd0a708 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -3,6 +3,7 @@ import { readLocalAiCredentialFile } from "../local-ai-credential-file.js"; import { prepareGrokRunnerCredentials } from "./grok-runner-credentials.js"; import { copyBackGrokAuth } from "@paperclipai/adapter-grok-local/server"; +import { createAgentIdentityRedactor } from "../agent-identity-redaction.js"; import { isSupportedRemoteCodexVersion, parseCodexCliVersion, @@ -5256,6 +5257,7 @@ function nativeSessionConfigDigest( .update( JSON.stringify({ companyId: execution.binding.companyId, + ...(execution.binding.agentKeyId ? { agentKeyId: execution.binding.agentKeyId } : {}), normalizedSessionId: nativeSessionKey(execution), executionLocation, provider: execution.provider, @@ -5308,6 +5310,7 @@ function nativeHarnessEnvironmentFingerprint( .update( canonicalJson({ companyId: execution.binding.companyId, + ...(execution.binding.agentKeyId ? { agentKeyId: execution.binding.agentKeyId } : {}), agentId: execution.binding.agentId, issueId: execution.binding.issueId, normalizedSessionId: nativeSessionKey(execution), @@ -7294,6 +7297,14 @@ export async function executePaperclipNativeSession(input: { }, ) => Promise; }): Promise { + const agentKeyId = input.runnerEnvironment?.PAPERCLIP_AGENT_KEY_ID; + if (agentKeyId) { + // Recovered pre-upgrade inputs also need the current public identity marker + // before deciding whether an already-running provider can be reused. + input = { ...input, execution: { ...input.execution, + binding: { ...input.execution.binding, agentKeyId }, + } }; + } const runId = input.execution.binding.runId; if (nativeSessionStartups.has(runId)) { throw new Error("native_session_supervisor_busy"); @@ -7853,6 +7864,7 @@ async function executePaperclipNativeSessionWithinScope( controlPlaneSourceInstanceId: controlPlaneInstanceId, }, { + privateKeyPem: input.runnerEnvironment?.PAPERCLIP_AGENT_PRIVATE_KEY, onCommittedEvent: async (event) => { await toolTrace.observe(event); if (event.eventType === "item.completed" && @@ -8412,6 +8424,7 @@ async function executePaperclipNativeSessionWithinScope( requireSessionCloseBeforeReturn: runnerdBackend !== null || input.instructionWorkingCopy !== undefined, onSessionClosed: input.instructionWorkingCopy?.collectStopped, onCheckpoint: async (snapshot) => { + snapshot = createAgentIdentityRedactor(input.runnerEnvironment?.PAPERCLIP_AGENT_PRIVATE_KEY).redact(snapshot); if (warmSessionId !== null && warmConfigDigest !== null) { await persistWarmNativeCheckpoint( input.execution, diff --git a/server/src/services/native-runtime/paperclip-control-plane-port.test.ts b/server/src/services/native-runtime/paperclip-control-plane-port.test.ts index e203a551754..892e1f363b1 100644 --- a/server/src/services/native-runtime/paperclip-control-plane-port.test.ts +++ b/server/src/services/native-runtime/paperclip-control-plane-port.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { asc, eq, sql } from "drizzle-orm"; -import { randomUUID } from "node:crypto"; +import { generateKeyPairSync, randomUUID } from "node:crypto"; import { renderPaperclipWakePrompt } from "@paperclipai/adapter-utils/server-utils"; import { readCompletedAssistantMessageCandidate, resolveHeartbeatRunResponse, selectHeartbeatRunFinalAgentMessage } from "../heartbeat-run-summary.js"; import { @@ -299,6 +299,47 @@ describe("PaperclipControlPlanePort conformance", () => { } }); + it("redacts identity private material before persisting native events and checkpoints", async () => { + const identity = { ...CONTROL_PLANE_CONFORMANCE_OPEN.identity, runId: randomUUID(), sessionId: randomUUID() }; + const runnerId = randomUUID(); + const privateKeyPem = generateKeyPairSync("ed25519").privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const keyBody = privateKeyPem.split("\n")[1]; + await db.insert(heartbeatRuns).values({ + id: identity.runId, companyId: identity.companyId, agentId: identity.agentId, + status: "running", runtimeMode: "native", nativeIssueId: identity.issueId, + nativeSessionId: identity.sessionId, runnerInstanceId: runnerId, + completionContractId: contractId, completionContractSha256: contractSha, + contextSnapshot: { issueId: identity.issueId }, + }); + const observed: PrpEvent[] = []; + const port = new PaperclipControlPlanePort(db, { + ...identity, completionContractId: contractId, completionContractSha256: contractSha, + sourceInstanceId: runnerId, controlPlaneSourceInstanceId: `identity-${identity.runId}`, + }, { privateKeyPem, onCommittedEvent: async event => { observed.push(event); } }); + await port.openRun({ identity, backendKind: "mock", sourceInstanceId: runnerId }); + await port.appendEvent({ + schema: "paperclip.prp.event.v1", sourceEventId: `${runnerId}:1`, sourceSeq: 1, + sourceInstanceId: runnerId, sourceKind: "runner", runId: identity.runId, + normalizedSessionId: identity.sessionId, eventType: "tool.execution.started", schemaVersion: 1, + priority: 0, emittedAt: new Date().toISOString(), payload: { + schema: "paperclip.tool.execution.v1", executionId: "identity-output", transport: "builtin", + operation: "execute", name: "diagnostic", target: null, namespace: null, readOnly: true, + status: "running", durationMs: null, exitCode: null, progress: null, + output: privateKeyPem, outputBytes: Buffer.byteLength(privateKeyPem), outputTruncated: false, + outputDigest: `sha256:${"a".repeat(64)}`, + }, + }); + await port.checkpointSession({ backendKind: "mock", sessionId: identity.sessionId, identity, + semanticResult: { ...CONTROL_PLANE_CONFORMANCE_RESULT, summary: privateKeyPem }, + }); + const persistedEvents = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, identity.runId)); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, identity.runId)); + for (const value of [persistedEvents, observed, run.runnerProfileJson]) { + expect(JSON.stringify(value)).not.toContain(keyBody); + expect(JSON.stringify(value)).toContain("***REDACTED***"); + } + }); + it("runs the unchanged package conformance suite against Paperclip persistence", async () => { const identity = CONTROL_PLANE_CONFORMANCE_OPEN.identity; const committedEventIds: string[] = []; diff --git a/server/src/services/native-runtime/paperclip-control-plane-port.ts b/server/src/services/native-runtime/paperclip-control-plane-port.ts index 328d6098e88..0afdea0261d 100644 --- a/server/src/services/native-runtime/paperclip-control-plane-port.ts +++ b/server/src/services/native-runtime/paperclip-control-plane-port.ts @@ -1,3 +1,4 @@ +import { createAgentIdentityRedactor } from "../agent-identity-redaction.js"; import { and, asc, eq, gt, or } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { @@ -71,6 +72,7 @@ function assertTerminal(value: unknown): asserts value is PrpTerminalState { /** Production implementation of the runner package's deliberately narrow persistence port. */ export class PaperclipControlPlanePort implements ControlPlanePort { readonly #db: Db; + readonly #identityRedactor: ReturnType; readonly #binding: PaperclipControlPlaneBinding; #sessionId: string | null = null; readonly #onCommittedEvent?: (event: PrpEvent) => Promise; @@ -80,11 +82,14 @@ export class PaperclipControlPlanePort implements ControlPlanePort { db: Db, binding: PaperclipControlPlaneBinding, options: { + /** Runtime-only secret; never part of the persisted binding. */ + privateKeyPem?: string; onCommittedEvent?: (event: PrpEvent) => Promise; onDuplicateEvent?: (event: PrpEvent) => Promise; } = {}, ) { this.#db = db; + this.#identityRedactor = createAgentIdentityRedactor(options.privateKeyPem); this.#binding = structuredClone(binding); this.#onCommittedEvent = options.onCommittedEvent; this.#onDuplicateEvent = options.onDuplicateEvent; @@ -151,6 +156,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort { } async checkpointSession(snapshot: PersistedNativeSession): Promise { + snapshot = this.#identityRedactor.redact(snapshot); const identity = snapshot.identity; if ( identity.companyId !== this.#binding.companyId @@ -183,7 +189,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort { if (!isPrpEvent(value)) throw new Error("native_legacy_event_not_supported"); const validated = validatePrpEvent(value); if (!validated.ok) throw new Error(`native_event_schema_invalid:${validated.issues[0]?.message ?? "unknown"}`); - const event = validated.event; + const event = this.#identityRedactor.redact(validated.event); if (event.runId !== this.#binding.runId || (this.#sessionId && event.normalizedSessionId !== this.#sessionId)) { throw new Error("native_event_binding_mismatch"); } @@ -264,6 +270,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort { } async completeRun(value: NativeRunResult | CompleteControlPlaneRunInput): Promise { + value = this.#identityRedactor.redact(value); if (!isCompleteInput(value)) throw new Error("native_structured_result_required"); // Capture compatibility diagnostics before canonical validation removes // legacy/non-actionable attention payloads. They are operator evidence, diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 7bd9cce9855..f3b6fef3e57 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -1,3 +1,4 @@ +import type { AgentPublicIdentity } from "@paperclipai/shared"; import type { Agent, ConnectionIntentInteraction, @@ -109,6 +110,8 @@ function agentPath(id: string, companyId?: string, suffix = "") { } export const agentsApi = { + getIdentity: (id: string, companyId?: string) => + api.get(agentPath(id, companyId, "/identity")), adoptAiConnection: (agentId: string, interactionId: string, connectionId: string, companyId: string) => api.post(withCompanyScope(`/agents/${agentId}/connection-intents/${interactionId}/adopt`, companyId), { connectionId }), diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 6a961016db2..8e0c8ff2479 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -217,6 +217,7 @@ export const queryKeys = { ["team-catalog", "installed", companyId] as const, }, agents: { + identity: (id: string) => ["agents", "identity", id] as const, list: (companyId: string) => ["agents", companyId] as const, detail: (id: string) => ["agents", "detail", id] as const, runtimeState: (id: string) => ["agents", "runtime-state", id] as const, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 84a616a77be..308116e7b34 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1728,7 +1728,10 @@ export function AgentOverview({ ?? asNonEmptyString(agent.runtimeConfig?.model) ?? "Adapter default"; const lastRun = runs[0] ?? null; - + const identity = useQuery({ + queryKey: [...queryKeys.agents.identity(agent.id), lastRun?.id, lastRun?.status], + queryFn: () => agentsApi.getIdentity(agent.id, agent.companyId), + }); return (
@@ -1750,6 +1753,20 @@ export function AgentOverview({ ) : Board} {directReportCount} + + {identity.isPending ? Loading… + : identity.isError ? Could not load public key + : identity.data ? ( + + {identity.data.keyId.slice(0, 19)}… + + ) : ( +
+
Not created yet
+
Created automatically on the next managed run
+
+ )} +