From a590ab769d32be6e8912de1ac169eb7bdc398fb7 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:57:03 -0500 Subject: [PATCH] Give managed agents persistent cryptographic identities (#15352) Give agents persistent Ed25519 identities encrypted with the existing instance master key. Create keys transactionally for new agents and lazily before supported managed runs, expose public identities in the API and agent UI, and protect private material during runtime delivery and output persistence. Preserve identities in recovery backups while giving imported and development-cloned agents fresh keys. Co-Authored-By: Paperclip --- cli/src/__tests__/worktree.test.ts | 7 +- cli/src/commands/worktree-lib.ts | 3 +- doc/AGENT-IDENTITY.md | 100 +++++ doc/DATABASE.md | 9 + doc/SPEC-implementation.md | 12 + doc/SPEC.md | 9 + .../src/acpx-engine/execute.test.ts | 16 + .../adapter-utils/src/acpx-engine/execute.ts | 18 +- .../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.test.ts | 28 +- .../codex-local/src/server/execute.ts | 14 +- .../cursor-cloud/src/server/execute.test.ts | 34 ++ .../cursor-cloud/src/server/execute.ts | 6 +- .../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 +- .../migrations/0308_whole_steel_serpent.sql | 20 + ...{0303_snapshot.json => 0308_snapshot.json} | 317 +++++++++++++++- 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 | 15 + .../crates/runner-core/src/durable/state.rs | 350 +++++++++++++++++- .../src/agent-identity-environment.test.ts | 34 ++ .../src/contracts/native-execution.ts | 4 +- .../provider-trace-file-sink.test.ts | 18 + .../src/contracts/provider-trace-file-sink.ts | 5 + .../durable-prp-control-plane.test.ts | 6 + .../durable-prp-control-plane.ts | 3 + .../src/drivers/acpx/environment.ts | 1 + .../src/drivers/acpx/runtime-sandbox.test.ts | 20 + .../src/drivers/acpx/runtime-sandbox.ts | 10 + .../src/drivers/codex/app-server-transport.ts | 7 +- .../drivers/codex/codex-security-config.ts | 5 +- .../opencode/opencode-server-driver.ts | 5 + .../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-default-configure-grants.test.ts | 6 +- .../agent-identity-master-key.test.ts | 36 ++ server/src/__tests__/agent-identity.test.ts | 256 +++++++++++++ ...at-accepted-plan-workspace-refresh.test.ts | 5 +- server/src/adapters/process/execute.ts | 2 +- server/src/routes/agents.ts | 7 + server/src/routes/openapi.ts | 19 + .../src/secrets/local-encrypted-provider.ts | 24 +- .../src/services/agent-identity-redaction.ts | 95 +++++ server/src/services/agent-identity.ts | 88 +++++ server/src/services/agents.ts | 3 + server/src/services/heartbeat.ts | 44 ++- .../native-runtime/native-execution-input.ts | 2 + .../native-session-executor.test.ts | 48 ++- .../native-runtime/native-session-executor.ts | 46 ++- .../paperclip-control-plane-port.test.ts | 86 ++++- .../paperclip-control-plane-port.ts | 45 ++- ui/src/api/agents.ts | 3 + .../transcript/native-run-events.test.ts | 10 + .../transcript/native-run-events.ts | 18 + ui/src/lib/queryKeys.ts | 1 + ui/src/pages/AgentDetail.tsx | 19 +- 73 files changed, 2014 insertions(+), 63 deletions(-) create mode 100644 doc/AGENT-IDENTITY.md create mode 100644 packages/db/src/migrations/0308_whole_steel_serpent.sql rename packages/db/src/migrations/meta/{0303_snapshot.json => 0308_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..f8dabb3c54a --- /dev/null +++ b/doc/AGENT-IDENTITY.md @@ -0,0 +1,100 @@ +# 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. +Native output deltas also buffer potential key fragments before persistence. +Raw provider trace contents are suppressed for identity-enabled runs; trace +routing, timing, and interpretation metadata remain available. + +Managed remote providers, including Cursor Cloud, receive the same private key +as managed local processes. Selecting a runtime therefore trusts its host with +the agent's persistent identity: the agent and its host can retain the key and +sign outside Paperclip. Encryption at rest protects database storage, not an +executing runtime. Move an agent only between runtime hosts trusted with that +identity. + +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. + +Native failure records and reports redact the assigned key before truncating diagnostics. Streaming output buffers settle at item or turn completion: short structural prefixes (such as a trailing dash) are preserved, longer interrupted key fragments become redaction markers, and pending buffers are cleared. Terminal events carry these settled `outputTails`; transcript projection displays them as deltas without changing the source event receipt. + +Codex shell delivery preserves its default `KEY`/`SECRET`/`TOKEN` name exclusions for other configured credentials. The exceptions are the three identity variables and `PAPERCLIP_API_KEY`: the latter is the short-lived, scoped run/bridge credential required by the Paperclip agent skill’s Bash/curl API calls. Disabling Codex’s automatic exclusions is paired with this explicit filtered allowlist; it does not admit arbitrary host environment variables. Provider authentication secrets can still reach the provider process without being newly exposed to shell commands by this feature. diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 6bb4307f58a..c05b9d6d3aa 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -570,3 +570,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.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 4296a110e79..faa2b6e7a0d 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -172,6 +172,7 @@ async function runExecutor( runtime?: Record; executionTransport?: Record; authToken?: string; + agentIdentity?: AdapterExecutionContext["agentIdentity"]; executionTarget?: Record; runtimeMcp?: AdapterRuntimeMcpAccess; prepareRemoteManagedHome?: AcpxEngineExecutorOptions["prepareRemoteManagedHome"]; @@ -210,6 +211,7 @@ async function runExecutor( context: options.context ?? {}, executionTransport: options.executionTransport, authToken: options.authToken, + agentIdentity: options.agentIdentity, executionTarget: options.executionTarget, runtimeMcp: options.runtimeMcp, startupTraceContext: options.startupTraceContext, @@ -548,6 +550,20 @@ describe("shared ACPX engine runtime behavior", () => { ); }); + it("keeps identity and scoped API access without exposing configured service tokens to Codex shells", async () => { + const { meta } = await runExecutor({ agent: "codex", env: { MY_SERVICE_TOKEN: "assigned-tool-token" } }, { + authToken: "assigned-run-token", + agentIdentity: { keyId: "sha256:test", publicKeyPem: "public", privateKeyPem: "private" }, + }); + const config = JSON.parse(String((meta[0]?.env as Record).CODEX_CONFIG)); + expect(config.shell_environment_policy.include_only).toEqual(expect.arrayContaining([ + "PAPERCLIP_API_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", + ])); + expect(config.shell_environment_policy.include_only).not.toContain("MY_SERVICE_TOKEN"); + expect(JSON.stringify(config)).not.toContain("assigned-run-token"); + expect(JSON.stringify(config)).not.toContain("assigned-tool-token"); + }); + it("forwards arbitrary Codex model IDs verbatim without picker-dependent session config", async () => { const arbitraryModel = "gpt-999-test-does-not-exist"; const { configOptions, meta } = await runExecutor({ diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 28e5b620259..725d733ab6c 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,13 @@ 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", + // Preserve Codex's default secret-name exclusions. The short-lived + // Paperclip API token is required by the agent skill's Bash/curl calls. + ...Object.keys(env).filter(key => key === "PAPERCLIP_API_KEY" || !/key|secret|token/i.test(key)), + ])] } : {}), requestedModel, requestedThinkingEffort, fastMode, @@ -2203,6 +2216,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 afterEach(async () => { vi.clearAllMocks(); + vi.unstubAllEnvs(); if (savedCodexHomeEnv === undefined) { delete process.env.CODEX_HOME; } else { @@ -117,7 +118,7 @@ describe("codex execute — outbound auth copy-back restore contribution", () => ); } - async function runTeardown(input: { sandboxAuth: string; hostAuth: string; onProviderStopped?: () => Promise }) { + async function runTeardown(input: { sandboxAuth: string; hostAuth: string; onProviderStopped?: () => Promise; withIdentity?: boolean }) { const rootDir = await mkdtemp( path.join(os.tmpdir(), "paperclip-codex-copyback-e2e-"), ); @@ -136,8 +137,14 @@ describe("codex execute — outbound auth copy-back restore contribution", () => process.env.CODEX_HOME = sharedHostHome; sandboxAuthFixture.bytes = Buffer.from(input.sandboxAuth, "utf8"); + const commandArgs: string[] = []; const executionResult = await execute({ runId: "run-copyback-e2e", + ...(input.withIdentity ? { + authToken: "assigned-run-token", + agentIdentity: { keyId: "sha256:test", publicKeyPem: "public", privateKeyPem: "private" }, + } : {}), + onMeta: async meta => { commandArgs.push(...(meta.commandArgs ?? [])); }, onProviderStopped: input.onProviderStopped, agent: { id: "agent-1", @@ -152,7 +159,7 @@ describe("codex execute — outbound auth copy-back restore contribution", () => engine: "cli", // External CODEX_HOME (outside the managed company tree) so no managed // seeding rewrites auth.json before teardown; equals the shared host home. - env: { CODEX_HOME: sharedHostHome }, + env: { CODEX_HOME: sharedHostHome, ...(input.withIdentity ? { MY_SERVICE_TOKEN: "assigned-tool-token" } : {}) }, }, context: { paperclipWorkspace: { @@ -176,12 +183,29 @@ describe("codex execute — outbound auth copy-back restore contribution", () => }); return { + commandArgs, finalHostAuth: await readFile(hostAuthPath, "utf8"), finalHostMode: (await lstat(hostAuthPath)).mode & 0o777, executionResult, }; } + it("keeps identity and scoped API access without exposing configured service tokens to CLI shells", async () => { + vi.stubEnv("DATABASE_URL", "postgres://operator:host-password@host/private"); + vi.stubEnv("HOST_DATABASE_PASSWORD", "host-password"); + const { commandArgs } = await runTeardown({ sandboxAuth: "{}", hostAuth: "{}", withIdentity: true }); + const policy = commandArgs.find(arg => arg.startsWith("shell_environment_policy.include_only=")); + const keys = JSON.parse(policy!.slice(policy!.indexOf("=") + 1)); + expect(keys).toEqual(expect.arrayContaining([ + "PAPERCLIP_API_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", + ])); + expect(keys).not.toContain("MY_SERVICE_TOKEN"); + expect(keys).not.toContain("DATABASE_URL"); + expect(keys).not.toContain("HOST_DATABASE_PASSWORD"); + expect(commandArgs.join(" ")).not.toContain("assigned-run-token"); + expect(commandArgs.join(" ")).not.toContain("assigned-tool-token"); + }); + it("collects stopped-provider instruction edits before a throwing remote restore", async () => { const order: string[] = []; prepareAdapterExecutionTargetRuntime.mockImplementationOnce(async () => ({ diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index fe2fa38b588..78e6594c519 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -754,7 +754,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise ({ name: server.name, endpointPath: server.url, @@ -1228,6 +1228,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise key === "PAPERCLIP_API_KEY" || !/key|secret|token/i.test(key)), + ...identityNames, + ])]; + 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.test.ts b/packages/adapters/cursor-cloud/src/server/execute.test.ts index b57f8207814..156ed5d9607 100644 --- a/packages/adapters/cursor-cloud/src/server/execute.test.ts +++ b/packages/adapters/cursor-cloud/src/server/execute.test.ts @@ -1,3 +1,4 @@ +import { generateKeyPairSync, sign, verify } from "node:crypto"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; import { createPromptContextFixture } from "@paperclipai/adapter-utils/test-fixtures/prompt-context"; @@ -266,6 +267,39 @@ describe("cursor_cloud execute", () => { ); }); + it("delivers the assigned identity to the managed cloud environment without exposing it in prompts or metadata", async () => { + const keys = generateKeyPairSync("ed25519"); + const identity = { + keyId: "sha256:assigned-identity", + publicKeyPem: keys.publicKey.export({ type: "spki", format: "pem" }).toString(), + privateKeyPem: keys.privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + }; + const sdkAgent = createMockSdkAgent(); + createMock.mockResolvedValue(sdkAgent); + const ctx = createContext({ agentIdentity: identity }); + ctx.config.env = { + CURSOR_API_KEY: "cursor-secret", + PAPERCLIP_AGENT_KEY_ID: "configured-override", + PAPERCLIP_AGENT_PRIVATE_KEY: "configured-override", + paperclip_agent_private_key: "configured-override", + }; + + await execute(ctx); + + const env = createMock.mock.calls[0]?.[0]?.cloud?.envVars; + expect(env).toMatchObject({ + PAPERCLIP_AGENT_KEY_ID: identity.keyId, + PAPERCLIP_AGENT_PUBLIC_KEY: identity.publicKeyPem, + PAPERCLIP_AGENT_PRIVATE_KEY: identity.privateKeyPem, + }); + expect(env).not.toHaveProperty("paperclip_agent_private_key"); + const challenge = Buffer.from("managed cloud identity"); + expect(verify(null, challenge, identity.publicKeyPem, + sign(null, challenge, env.PAPERCLIP_AGENT_PRIVATE_KEY))).toBe(true); + const privateBody = identity.privateKeyPem.split("\n")[1]; + expect(JSON.stringify([ctx.meta, ctx.logs, sdkAgent.send.mock.calls])).not.toContain(privateBody); + }); + it("omits empty environment values while preserving nonempty values exactly", async () => { createMock.mockResolvedValue(createMockSdkAgent()); const ctx = createContext(); diff --git a/packages/adapters/cursor-cloud/src/server/execute.ts b/packages/adapters/cursor-cloud/src/server/execute.ts index 48e62b10eea..3a14ba1a20b 100644 --- a/packages/adapters/cursor-cloud/src/server/execute.ts +++ b/packages/adapters/cursor-cloud/src/server/execute.ts @@ -17,6 +17,7 @@ import { asString, buildPaperclipEnv, buildRuntimeToolsEnv, + isForbiddenConfigEnvKey, joinPromptSections, parseObject, readPaperclipIssueWorkModeFromContext, @@ -59,6 +60,7 @@ function asStringEnvMap(value: unknown): Record { const parsed = parseObject(value); const env: Record = {}; for (const [key, entry] of Object.entries(parsed)) { + if (isForbiddenConfigEnvKey(key)) continue; if (typeof entry === "string") { env[key] = entry; } else if (typeof entry === "object" && entry !== null && !Array.isArray(entry)) { @@ -108,7 +110,9 @@ function buildWakeEnv(ctx: AdapterExecutionContext, configEnv: Record = { ...configEnv, - ...buildPaperclipEnv(agent), + // The selected managed cloud host is trusted with this persistent identity, + // just like a managed local host (see doc/AGENT-IDENTITY.md). + ...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/0308_whole_steel_serpent.sql b/packages/db/src/migrations/0308_whole_steel_serpent.sql new file mode 100644 index 00000000000..81c5ef748ae --- /dev/null +++ b/packages/db/src/migrations/0308_whole_steel_serpent.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/0303_snapshot.json b/packages/db/src/migrations/meta/0308_snapshot.json similarity index 99% rename from packages/db/src/migrations/meta/0303_snapshot.json rename to packages/db/src/migrations/meta/0308_snapshot.json index b3a8140db55..e294651897e 100644 --- a/packages/db/src/migrations/meta/0303_snapshot.json +++ b/packages/db/src/migrations/meta/0308_snapshot.json @@ -1,6 +1,6 @@ { - "id": "300dc8d4-29f3-4eb5-8498-3ecc69e4ea0a", - "prevId": "befbd58f-acb8-4fd4-bb64-4c1c0cfd0f3e", + "id": "00d98f2c-c054-4ca3-91eb-08211bdf418d", + "prevId": "ff1eeb0a-fea3-4cd1-b45c-ce712c4667ff", "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": "", @@ -2894,7 +2971,7 @@ "checkConstraints": { "ai_connection_defaults_provider_check": { "name": "ai_connection_defaults_provider_check", - "value": "\"ai_connection_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai')" + "value": "\"ai_connection_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google')" }, "ai_connection_defaults_method_check": { "name": "ai_connection_defaults_method_check", @@ -3367,7 +3444,7 @@ "checkConstraints": { "ai_provider_defaults_provider_check": { "name": "ai_provider_defaults_provider_check", - "value": "\"ai_provider_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai')" + "value": "\"ai_provider_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google')" } }, "isRLSEnabled": false @@ -39410,7 +39487,7 @@ }, "resource_lifecycle_events_action_check": { "name": "resource_lifecycle_events_action_check", - "value": "\"resource_lifecycle_events\".\"action\" = 'create' OR (\"resource_lifecycle_events\".\"resource_type\" = 'project' AND \"resource_lifecycle_events\".\"action\" = 'update') OR (\"resource_lifecycle_events\".\"resource_type\" = 'agent' AND \"resource_lifecycle_events\".\"action\" IN ('pause', 'resume', 'terminate'))" + "value": "\"resource_lifecycle_events\".\"action\" = 'create' OR (\"resource_lifecycle_events\".\"resource_type\" = 'project' AND \"resource_lifecycle_events\".\"action\" IN ('update', 'archive')) OR (\"resource_lifecycle_events\".\"resource_type\" = 'agent' AND \"resource_lifecycle_events\".\"action\" IN ('pause', 'resume', 'terminate'))" } }, "isRLSEnabled": false @@ -51701,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", @@ -51717,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": "", @@ -51842,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": "", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index ef06669ccd9..5daf7757de5 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2143,6 +2143,13 @@ "when": 1791311441769, "tag": "0307_cool_naoko", "breakpoints": true + }, + { + "idx": 308, + "version": "7", + "when": 1791312251106, + "tag": "0308_whole_steel_serpent", + "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 61d66307dcf..e672dd33f97 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 @@ -118,6 +118,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 e5a93bf12bb..29494a4e21a 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 @@ -34,6 +34,9 @@ const WARM_ATTACHMENT_DRAIN_DEADLINE: Duration = Duration::from_millis(100); const OPENCODE_PROVIDER_ENVIRONMENT_KEYS: &[&str] = &[ "PAPERCLIP_AI_PROVIDER_KEY", "PAPERCLIP_AI_PROVIDER_URL", + "PAPERCLIP_AGENT_KEY_ID", + "PAPERCLIP_AGENT_PUBLIC_KEY", + "PAPERCLIP_AGENT_PRIVATE_KEY", "OPENROUTER_API_KEY", "PAPERCLIP_NATIVE_MCP_NAME", "PAPERCLIP_NATIVE_MCP_URL", @@ -244,6 +247,15 @@ impl ProviderTraceSink { } fn frame(&mut self, direction: &str, raw: &[u8]) -> Option { + // Raw protocol frames can contain arbitrary private-key fragments. + // Preserve trace metadata without retaining raw content for identity runs. + let redacted = + if std::env::var("PAPERCLIP_AGENT_PRIVATE_KEY").is_ok_and(|key| !key.is_empty()) { + "[REDACTED: agent identity runtime]".to_owned() + } else { + String::from_utf8_lossy(raw).into_owned() + }; + let raw = redacted.as_bytes(); if self.captured_bytes.saturating_add(raw.len()) > self.max_bytes { self.truncated = true; return None; @@ -795,6 +807,9 @@ const GITHUB_CREDENTIAL_ENVIRONMENT_KEYS: &[&str] = &[ const CODEX_PROVIDER_ENVIRONMENT_KEYS: &[&str] = &[ "PAPERCLIP_AI_PROVIDER_KEY", + "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/state.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs index 894a00176c3..92f35e6db21 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 @@ -312,6 +312,8 @@ pub enum CommandDisposition { #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct DurableState { + #[serde(skip)] + identity_output: IdentityOutputBuffer, pub schema: String, pub runner_instance_id: String, pub environment_lease_id: String, @@ -352,6 +354,7 @@ pub struct DurableState { impl DurableState { pub(crate) fn new(config: &DurableRunnerConfig) -> Self { Self { + identity_output: IdentityOutputBuffer::default(), schema: STATE_SCHEMA.to_owned(), runner_instance_id: config.runner_instance_id.clone(), environment_lease_id: config.environment_lease_id.clone(), @@ -527,6 +530,37 @@ impl DurableState { } validate_semantic_tool_input_digest(event_type.as_str(), &payload)?; + // Clone until enqueue succeeds: rejected writes must not consume output. + // Pending secret fragments are memory-only and never enter durable state. + let mut identity_output = self.identity_output.clone(); + let payload = if event_type == "item.delta" { + identity_output.redact( + &format!("{}:{}", self.turn_id, self.item_id), + &payload, + &self.item_id, + ) + } else if matches!( + event_type.as_str(), + "item.completed" | "item.failed" | "item.cancelled" + ) { + identity_output.settle( + &format!("{}:{}", self.turn_id, self.item_id), + &payload, + false, + ) + } else if matches!( + event_type.as_str(), + "turn.completed" + | "turn.failed" + | "turn.cancelled" + | "turn.interrupted" + | "session.closed" + ) { + identity_output.settle(&format!("{}:", self.turn_id), &payload, true) + } else { + payload + }; + let sanitized_payload = preserve_bounded_display_content( event_type.as_str(), &payload, @@ -634,6 +668,7 @@ impl DurableState { ); } self.peak_outbox_bytes = self.peak_outbox_bytes.max(projected); + self.identity_output = identity_output; Ok(source_seq) } @@ -1626,6 +1661,199 @@ fn durable_semantics_changed_by_sanitization(original: &Value, sanitized: &Value } } +#[derive(Clone, Default, PartialEq)] +struct IdentityOutputBuffer { + pending: BTreeMap, + streams: BTreeMap, +} + +#[derive(Clone, PartialEq)] +struct IdentityOutputStream { + scope: String, + item_id: Option, + payload: Value, + paths: Vec, +} + +impl std::fmt::Debug for IdentityOutputBuffer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("IdentityOutputBuffer { [REDACTED] }") + } +} + +impl IdentityOutputBuffer { + fn redact(&mut self, stream: &str, payload: &Value, item_id: &str) -> Value { + let Ok(key) = std::env::var("PAPERCLIP_AGENT_PRIVATE_KEY") else { + return payload.clone(); + }; + if key.is_empty() { + return payload.clone(); + } + let result = self.redact_with_key(stream, payload, &key); + if let Some(descriptor) = self.streams.get_mut(&Self::stream_key(stream, payload)) { + descriptor.item_id = Some(json!(item_id)); + } + result + } + + fn stream_key(stream: &str, payload: &Value) -> String { + json!([ + stream, + payload.get("itemId"), + payload.get("providerItemId"), + payload.get("channel"), + payload.get("stream") + ]) + .to_string() + } + + fn redact_with_key(&mut self, stream: &str, payload: &Value, key: &str) -> Value { + let scope = stream.to_owned(); + let stream = Self::stream_key(stream, payload); + let mut values = vec![key.to_owned(), key.trim().to_owned()]; + values.extend( + key.lines() + .filter(|line| !line.is_empty() && !line.starts_with("-----")) + .map(str::to_owned), + ); + values.sort_by_key(|value| std::cmp::Reverse(value.len())); + let result = self.visit(&stream, "", payload, &values); + let prefix = format!("{stream}."); + let paths: Vec = self + .pending + .keys() + .filter(|path| path.starts_with(&prefix)) + .cloned() + .collect(); + if paths.is_empty() { + self.streams.remove(&stream); + } else { + let metadata = ["itemId", "providerItemId", "kind", "channel", "stream"] + .iter() + .filter_map(|key| { + payload + .get(*key) + .filter(|value| value.is_string()) + .map(|value| ((*key).to_owned(), sanitize_value(value))) + }) + .collect(); + self.streams.insert( + stream, + IdentityOutputStream { + scope, + item_id: None, + payload: Value::Object(metadata), + paths, + }, + ); + } + result + } + + fn settle(&mut self, scope: &str, payload: &Value, whole_turn: bool) -> Value { + let streams: Vec = self + .streams + .iter() + .filter(|(_, descriptor)| { + (if whole_turn { + descriptor.scope.starts_with(scope) + } else { + descriptor.scope == scope + }) && (whole_turn + || payload.get("itemId").is_none() + || descriptor.payload.get("itemId").is_none() + || payload.get("itemId") == descriptor.payload.get("itemId")) + }) + .map(|(stream, _)| stream.clone()) + .collect(); + let mut tails = Vec::new(); + for stream in streams { + let descriptor = self.streams.remove(&stream).unwrap(); + let primary = descriptor + .paths + .iter() + .find(|path| **path == format!("{stream}.text")) + .or_else(|| descriptor.paths.first()) + .cloned(); + let mut text = String::new(); + for path in descriptor.paths { + if let Some(held) = self.pending.remove(&path) { + if Some(&path) == primary.as_ref() { + text = if held.len() < 8 { + held + } else { + "[REDACTED]".to_owned() + }; + } + } + } + if !text.is_empty() { + let mut tail_payload = descriptor.payload; + tail_payload["text"] = json!(text); + tails.push(json!({"itemId": descriptor.item_id, "payload": tail_payload})); + } + } + let mut result = payload.clone(); + if !tails.is_empty() { + let mut existing = result + .get("outputTails") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + existing.extend(tails); + result["outputTails"] = json!(existing); + } + result + } + + fn visit(&mut self, path: &str, field: &str, value: &Value, secrets: &[String]) -> Value { + match value { + Value::Object(object) => Value::Object( + object + .iter() + .map(|(key, child)| { + ( + key.clone(), + self.visit(&format!("{path}.{key}"), key, child, secrets), + ) + }) + .collect(), + ), + Value::Array(array) => Value::Array( + array + .iter() + .enumerate() + .map(|(index, child)| { + self.visit(&format!("{path}.{index}"), field, child, secrets) + }) + .collect(), + ), + Value::String(chunk) if matches!(field, "text" | "delta" | "output" | "patch") => { + let mut text = self.pending.remove(path).unwrap_or_default(); + text.push_str(chunk); + for secret in secrets { + text = text.replace(secret, "[REDACTED]"); + } + let mut held = 0; + for secret in secrets { + for size in (held + 1..secret.len().min(text.len() + 1)).rev() { + if secret.is_char_boundary(size) && text.ends_with(&secret[..size]) { + held = size; + break; + } + } + } + if held > 0 { + let tail = text.split_off(text.len() - held); + self.pending.insert(path.to_owned(), tail); + } + Value::String(text) + } + other => other.clone(), + } + } +} + pub(crate) fn sanitize_value(value: &Value) -> Value { match value { Value::Object(object) => Value::Object( @@ -1824,6 +2052,9 @@ fn validate_semantic_tool_input_digest( } pub(crate) fn redact_text(input: &str) -> String { + // Cutting a secret first leaves an unmatchable plaintext fragment. + let redacted = redact_sensitive_text_values(input); + let input = redacted.as_str(); let (bounded, truncated) = if input.len() > 4096 { let boundary = input .char_indices() @@ -1835,14 +2066,37 @@ pub(crate) fn redact_text(input: &str) -> String { } else { (input, false) }; - let mut redacted = redact_sensitive_text_values(bounded); + let mut redacted = bounded.to_owned(); if truncated { redacted.push_str("…[truncated]"); } 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(); @@ -2360,6 +2614,64 @@ fn current_timestamp() -> Result { #[cfg(test)] mod tests { + #[test] + fn identity_delta_redaction_buffers_every_split_and_never_serializes_pending_material() { + use super::*; + let key = "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIExamplePrivateMaterial\n-----END PRIVATE KEY-----\n"; + let body = key.lines().nth(1).unwrap(); + for split in 1..body.len() { + let mut buffer = IdentityOutputBuffer::default(); + let first = buffer.redact_with_key( + "envelope-item", + &json!({"itemId": "item-1", "text": &body[..split], "update": {"delta": &body[..split]}}), + key, + ); + let other = buffer.redact_with_key( + "envelope-item", + &json!({"itemId": "item-2", "text": "ordinary output\n"}), + key, + ); + assert_eq!(other["text"], "ordinary output\n"); + let second = buffer.redact_with_key( + "envelope-item", + &json!({"itemId": "item-1", "text": &body[split..], "update": {"delta": &body[split..]}}), + key, + ); + assert_eq!(first["text"], ""); + assert_eq!(second["text"], "[REDACTED]"); + assert_eq!(second["update"]["delta"], "[REDACTED]"); + } + let mut state = DurableState::new(&config(PathBuf::from("/unused"))); + state + .identity_output + .redact_with_key("item", &json!({"text": &body[..20]}), key); + assert!(!serde_json::to_string(&state).unwrap().contains(&body[..20])); + assert!(!format!("{state:?}").contains(&body[..20])); + } + + #[test] + fn identity_output_settles_item_and_turn_tails_without_retaining_fragments() { + let key = "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEITestSecretMaterialForAnAgentIdentity\n-----END PRIVATE KEY-----\n"; + let mut output = IdentityOutputBuffer::default(); + assert_eq!( + output.redact_with_key( + "turn:item", + &json!({"itemId": "one", "kind": "reasoning", "text": "Thinking-"}), + key + )["text"], + "Thinking" + ); + let settled = output.settle("turn:item", &json!({"itemId": "one"}), false); + assert_eq!(settled["outputTails"][0]["payload"]["text"], "-"); + assert!(output.pending.is_empty()); + assert!(output.streams.is_empty()); + output.redact_with_key("turn:other", &json!({"text": &key[..42]}), key); + let settled = output.settle("turn:", &json!({"status": "completed"}), true); + assert_eq!(settled["outputTails"][0]["payload"]["text"], "[REDACTED]"); + assert!(output.pending.is_empty()); + assert!(output.streams.is_empty()); + } + use std::time::Duration; use super::*; @@ -3954,6 +4266,42 @@ mod tests { ); } + #[test] + fn identity_redaction_precedes_multibyte_truncation() { + let prefix = "MC4CAQAwBQYDK2VwBCIEIA"; + let body = format!("{prefix}{}", "A".repeat(64 - prefix.len())); + let key = format!("-----BEGIN PRIVATE KEY-----\n{body}\n-----END PRIVATE KEY-----\n"); + // Give only this subprocess the key; do not race other tests' environment. + if std::env::var_os("PAPERCLIP_IDENTITY_TRUNCATION_TEST").is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "durable::state::tests::identity_redaction_precedes_multibyte_truncation", + "--nocapture", + ]) + .env("PAPERCLIP_IDENTITY_TRUNCATION_TEST", "1") + .env("PAPERCLIP_AGENT_PRIVATE_KEY", &key) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + for material in [&body, &key] { + let input = format!("{}{material}", "é".repeat(2025)); + let output = redact_text(&input); + assert!(!output.contains(prefix)); + assert!(output.contains("[REDACTED]")); + assert!(output.len() <= 4096); + let persisted = sanitize_value(&json!({"text": input})); + assert!(!persisted.to_string().contains(prefix)); + } + } + #[test] fn diagnostic_redaction_still_bounds_retained_text() { let output = redact_text(&format!("token=secret-value {}", "x".repeat(4_096))); 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/contracts/provider-trace-file-sink.test.ts b/packages/paperclip-runner/src/contracts/provider-trace-file-sink.test.ts index 405a28edd77..fc30e120364 100644 --- a/packages/paperclip-runner/src/contracts/provider-trace-file-sink.test.ts +++ b/packages/paperclip-runner/src/contracts/provider-trace-file-sink.test.ts @@ -14,6 +14,24 @@ afterEach(async () => { }); describe("ProviderTraceFileSink", () => { + it("omits raw identity-run frames even when a key is split into single-byte deltas", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-provider-trace-identity-")); + roots.push(root); + const tracePath = join(root, "provider.ndjson"); + const sink = await createProviderTraceFileSink({ path: tracePath, provider: "opencode", channel: "test" }); + const key = "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIExamplePrivateMaterial\n-----END PRIVATE KEY-----\n"; + sink!.addSensitiveValues([key]); + for (const delta of key) sink!.frame({ + direction: "provider_to_client", raw: JSON.stringify({ delta }), transport: "sse", + }); + await sink!.finish(); + const records = (await readFile(tracePath, "utf8")).trim().split("\n").map(line => JSON.parse(line)); + const frames = records.filter(record => record.kind === "frame"); + expect(frames).toHaveLength(key.length); + for (const frame of frames) expect(Buffer.from(frame.rawBase64, "base64").toString()) + .toBe("[REDACTED: agent identity runtime]"); + }); + it("hardens an existing file before it appends provider frames", async () => { const root = await mkdtemp(join(tmpdir(), "paperclip-provider-trace-mode-")); roots.push(root); diff --git a/packages/paperclip-runner/src/contracts/provider-trace-file-sink.ts b/packages/paperclip-runner/src/contracts/provider-trace-file-sink.ts index aff5932900b..666ad36f036 100644 --- a/packages/paperclip-runner/src/contracts/provider-trace-file-sink.ts +++ b/packages/paperclip-runner/src/contracts/provider-trace-file-sink.ts @@ -33,6 +33,7 @@ export class ProviderTraceFileSink { readonly #channel: string; readonly #maxBytes: number; readonly #sensitiveValues = new Set(); + #hasPrivateIdentity = false; readonly #queue: string[] = []; #nextFrameId: number; #nextDebugSequence: number; @@ -62,6 +63,7 @@ export class ProviderTraceFileSink { addSensitiveValues(values: readonly (string | undefined)[]): void { for (const value of values) { + if (value?.includes("-----BEGIN PRIVATE KEY-----")) this.#hasPrivateIdentity = true; if (value) this.#sensitiveValues.add(value); } } @@ -181,6 +183,9 @@ export class ProviderTraceFileSink { } #redact(raw: Buffer): Buffer { + // Raw frames may split a private key at any byte boundary. Keep trace + // routing/timing metadata, but never spool raw content for identity runs. + if (this.#hasPrivateIdentity) return Buffer.from("[REDACTED: agent identity runtime]"); if (this.#sensitiveValues.size === 0) return raw; let value = raw.toString("utf8"); for (const sensitive of [...this.#sensitiveValues].sort( 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 9492b21b60b..64ee0e368ba 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_AI_PROVIDER_KEY: "managed-provider-key", ANTHROPIC_AUTH_TOKEN: "managed-claude-key", @@ -422,6 +425,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_AI_PROVIDER_KEY: "managed-provider-key", ANTHROPIC_AUTH_TOKEN: "managed-claude-key", 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 bbf604d4dce..4c4e20c73f3 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 @@ -3375,6 +3375,9 @@ const runnerExplicitProviderEnvironmentKeys = [ ...CLAUDE_ROUTING_ENV_KEYS, "PAPERCLIP_AI_PROVIDER_KEY", "PAPERCLIP_AI_PROVIDER_URL", + "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 3ce0790542a..2ecbf367474 100644 --- a/packages/paperclip-runner/src/drivers/acpx/environment.ts +++ b/packages/paperclip-runner/src/drivers/acpx/environment.ts @@ -111,6 +111,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, ...(agent === "claude" ? CLAUDE_ROUTING_ENV_KEYS : []), ]); diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts index fb80619a55d..53423467c92 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts @@ -52,6 +52,26 @@ describe("ACPX runtime sandbox", () => { expect((await stat(requirementsPath)).mode & 0o777).toBe(0o600); }); + it("keeps provider credentials outside identity-enabled Codex shell commands", async () => { + const fixture = await sandboxFixture("codex"); + const sandbox = await prepareAcpxRuntimeSandbox({ + binding: fixture.binding, agent: "codex", environment: { + PATH: process.env.PATH, + OPENAI_API_KEY: "provider-secret", MY_SERVICE_TOKEN: "configured-secret", + PAPERCLIP_API_KEY: "scoped-run-token", + PAPERCLIP_AGENT_KEY_ID: "sha256:test", PAPERCLIP_AGENT_PUBLIC_KEY: "public-identity", + PAPERCLIP_AGENT_PRIVATE_KEY: "private-identity", + }, + }); + const config = await readFile(join(sandbox.agentHomeDirectory, "config.toml"), "utf8"); + const keys = JSON.parse(config.split("\n").find(line => line.startsWith("include_only = "))!.slice("include_only = ".length)); + expect(keys).toEqual(expect.arrayContaining(["PAPERCLIP_API_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY"])); + expect(keys).not.toContain("OPENAI_API_KEY"); + expect(keys).not.toContain("MY_SERVICE_TOKEN"); + expect(sandbox.launchEnvironment.OPENAI_API_KEY).toBe("provider-secret"); + for (const value of ["provider-secret", "configured-secret", "scoped-run-token", "private-identity"]) expect(config).not.toContain(value); + }); + it.each(["claude-sonnet-5", "sonnet", "custom-deployment-id"])( "preserves the requested Claude model %s in its isolated settings", async (model) => { diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index 8ecdbc88ede..11df4137364 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -453,6 +453,16 @@ 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", + // Provider/config secrets keep Codex's default shell exclusions; + // only Paperclip's scoped API token is required by Bash/curl skills. + ...Object.keys(input.environment).filter(key => key === "PAPERCLIP_API_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 154b7df2fcc..53ce851d113 100644 --- a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts +++ b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts @@ -229,8 +229,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]; @@ -238,6 +240,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 e82b068527a..fa95ea19b4d 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -2130,7 +2130,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("-----"))] : []; const sensitiveValues = [ + ...identityValues, password, authHeader, bridge.secret, @@ -2736,6 +2740,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 b73efc381bc..7c16e9fc024 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -3213,6 +3213,7 @@ export function resolveRunnerdAcpxPermissionMode( const OPEN_CODE_RUNNER_ENVIRONMENT_KEYS = new Set([ "PAPERCLIP_AI_PROVIDER_KEY", "PAPERCLIP_AI_PROVIDER_URL", + "PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", "PATH", "LANG", "LANGUAGE", @@ -3247,6 +3248,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 ee050d555e3..8910f5f93c5 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-default-configure-grants.test.ts b/server/src/__tests__/agent-default-configure-grants.test.ts index e67f68a5453..3094e12f700 100644 --- a/server/src/__tests__/agent-default-configure-grants.test.ts +++ b/server/src/__tests__/agent-default-configure-grants.test.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { eq } from "drizzle-orm"; -import { agents, companies, companyMemberships, createDb, principalPermissionGrants } from "@paperclipai/db"; +import { activityLog, agents, companies, companyMemberships, createDb, principalPermissionGrants } from "@paperclipai/db"; import { LOW_TRUST_REVIEW_PRESET, type PermissionKey } from "@paperclipai/shared"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; import { agentService } from "../services/agents.js"; @@ -137,6 +137,7 @@ describeDatabase("new agent configuration defaults", () => { await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId)); await db.delete(companyMemberships).where(eq(companyMemberships.companyId, companyId)); await db.delete(agents).where(eq(agents.companyId, companyId)); + await db.delete(activityLog).where(eq(activityLog.companyId, companyId)); await db.delete(companies).where(eq(companies.id, companyId)); }); @@ -178,6 +179,7 @@ describeDatabase("new agent configuration defaults", () => { await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId)); await db.delete(agents).where(eq(agents.companyId, companyId)); + await db.delete(activityLog).where(eq(activityLog.companyId, companyId)); await db.delete(companies).where(eq(companies.id, companyId)); }); @@ -207,6 +209,7 @@ describeDatabase("new agent configuration defaults", () => { await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId)); await db.delete(agents).where(eq(agents.companyId, companyId)); + await db.delete(activityLog).where(eq(activityLog.companyId, companyId)); await db.delete(companies).where(eq(companies.id, companyId)); }); @@ -235,6 +238,7 @@ describeDatabase("new agent configuration defaults", () => { await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId)); await db.delete(companyMemberships).where(eq(companyMemberships.companyId, companyId)); await db.delete(agents).where(eq(agents.companyId, companyId)); + await db.delete(activityLog).where(eq(activityLog.companyId, companyId)); await db.delete(companies).where(eq(companies.id, companyId)); }); }); 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..dcdbf1189b2 --- /dev/null +++ b/server/src/__tests__/agent-identity.test.ts @@ -0,0 +1,256 @@ +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).where(eq(agentIdentityKeys.agentId, agent.id))).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("persists ordinary stdout and stderr endings after settling the identity buffer", async () => { + const agent = await oldAgent(); + await db.update(agents).set({ adapterType: "process", adapterConfig: { + command: process.execPath, cwd: directory, + args: ["-e", "process.stdout.write('safe-'); process.stderr.write('safeM');"], + } }).where(eq(agents.id, agent.id)); + const heartbeat = heartbeatService(db); + try { + const run = await heartbeat.invoke(agent.id, "on_demand", {}, "manual"); + await drainHeartbeatRunsToQuiescence(db, heartbeat); + const finished = await heartbeat.getRun(run!.id); + expect(finished?.status).toBe("succeeded"); + expect(finished?.stdoutExcerpt).toMatch(/safe-$/); + expect(finished?.stderrExcerpt).toMatch(/safeM$/); + const lines = (await heartbeat.readLog(run!.id)).content.trim().split("\n").map(line => JSON.parse(line)); + expect(lines.filter(line => line.stream === "stdout").map(line => line.chunk).join("")).toContain("safe-"); + expect(lines.filter(line => line.stream === "stderr").map(line => line.chunk).join("")).toContain("safeM"); + } 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("settles ordinary endings and interrupted keys on item and turn completion", () => { + const redactor = createAgentIdentityRedactor(pem); + expect(redactor.delta("run:turn:item", { kind: "reasoning", text: "Thinking-" }, "item").text).toBe("Thinking"); + const completed = redactor.settleDeltas("run:turn:item", {}, false); + expect(completed).toMatchObject({ outputTails: [{ itemId: "item", payload: { kind: "reasoning", text: "-" } }] }); + expect(redactor.settleDeltas("run:turn:", {}, true)).toEqual({}); + redactor.delta("run:turn:other", { text: pem.slice(0, 42) }, "other"); + expect(redactor.settleDeltas("run:turn:", {}, true)).toMatchObject({ outputTails: [{ payload: { text: "***REDACTED***" } }] }); + expect(redactor.delta("run:turn:other", { text: "hello" }).text).toBe("hello"); + }); + + 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 418bf322378..5373891b489 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,4 +1,5 @@ import { aiRoutingHarness } from "@paperclipai/shared"; +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"; @@ -4386,6 +4387,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/routes/openapi.ts b/server/src/routes/openapi.ts index 88e59062bd6..754c13e5b33 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -3456,6 +3456,25 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, }); +registry.registerPath({ + method: "get", + path: "/api/agents/{id}/identity", + tags: ["agents"], + summary: "Get an agent's public cryptographic identity, or null before provisioning", + request: { params: z.object({ id: z.string() }) }, + responses: { + 200: r.ok(z.object({ + algorithm: z.literal("Ed25519"), + keyId: z.string(), + publicKeyPem: z.string(), + createdAt: z.string().datetime(), + }).nullable()), + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + }, +}); + registry.registerPath({ method: "patch", path: "/api/agents/{id}", 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..6bcd1e7d32a --- /dev/null +++ b/server/src/services/agent-identity-redaction.ts @@ -0,0 +1,95 @@ +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(); + const deltaStreams = new Map; paths: string[]; + }>(); + const redactor = { + 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; } + } + } + if (held) pending.set(stream, text.slice(-held)); + else pending.delete(stream); + 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); + // Ed25519 PEM/PKCS#8 values begin with fixed structural bytes. Preserve + // ordinary endings such as a dash; longer interrupted fragments stay hidden. + return held ? (held.length < 8 ? held : REDACTED_EVENT_VALUE) : ""; + }, + /** Delta payloads can repeat output under text and provider-specific fields. */ + delta(stream: string, value: T, itemId?: string): T { + const scope = stream; + const paths: string[] = []; + const payload = value as Record; + // Durable transports can share one envelope item ID across provider items. + stream = JSON.stringify([stream, payload.itemId, payload.providerItemId, payload.channel, payload.stream]); + const visit = (entry: unknown, path: string, field: string): unknown => { + if (typeof entry === "string") { + if (!/^(text|delta|output|patch)$/.test(field)) return redactor.redact(entry); + const key = `${stream}:${path}`; + const output = redactor.chunk(key, entry); + if (pending.has(key)) paths.push(key); + return output; + } + if (Array.isArray(entry)) return entry.map((child, index) => visit(child, `${path}.${index}`, field)); + if (entry && typeof entry === "object") return Object.fromEntries( + Object.entries(entry).map(([key, child]) => [key, visit(child, `${path}.${key}`, key)]), + ); + return entry; + }; + const result = visit(value, "", "") as T; + for (const previous of deltaStreams.get(stream)?.paths ?? []) { + if (pending.has(previous) && !paths.includes(previous)) paths.push(previous); + } + if (paths.length) deltaStreams.set(stream, { scope, itemId, paths, + payload: Object.fromEntries(["itemId", "providerItemId", "kind", "channel", "stream"] + .filter(key => typeof payload[key] === "string").map(key => [key, redactor.redact(payload[key])])), + }); + else deltaStreams.delete(stream); + return result; + }, + /** Settle output in the terminal event, preserving its source receipt identity. */ + settleDeltas(scope: string, payload: Record, wholeTurn: boolean) { + const tails: Array<{ itemId?: string; payload: Record }> = []; + for (const [stream, descriptor] of deltaStreams) { + if (wholeTurn ? !descriptor.scope.startsWith(scope) : descriptor.scope !== scope) continue; + if (!wholeTurn && payload.itemId && descriptor.payload.itemId && payload.itemId !== descriptor.payload.itemId) continue; + const primary = descriptor.paths.find(path => path === `${stream}:.text`) ?? descriptor.paths[0]; + let text = ""; + for (const path of descriptor.paths) { + const tail = redactor.finish(path); + if (path === primary) text = tail; + } + deltaStreams.delete(stream); + if (text) tails.push({ itemId: descriptor.itemId, payload: { ...descriptor.payload, text } }); + } + return tails.length ? { ...payload, outputTails: [ + ...(Array.isArray(payload.outputTails) ? payload.outputTails : []), ...tails, + ] } : payload; + }, + }; + return redactor; +} 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 27007d2b774..681c9332115 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; @@ -23430,7 +23446,7 @@ export function heartbeatService( const currentUserRedactionOptions = await getCurrentUserRedactionOptions(); - const onLog = async (stream: "stdout" | "stderr", chunk: string) => { + const appendIdentityRedactedLog = async (stream: "stdout" | "stderr", chunk: string) => { const sanitizedChunk = compactRunLogChunk( redactCurrentUserText(chunk, currentUserRedactionOptions), ); @@ -23505,6 +23521,8 @@ export function heartbeatService( }, }); }; + const onLog = (stream: "stdout" | "stderr", chunk: string) => + appendIdentityRedactedLog(stream, identityRedactor.chunk(stream, chunk)); if (runScopedMentionedSkillKeys.length > 0) { await onLog( "stdout", @@ -23593,6 +23611,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 +23627,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 +24196,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, @@ -24910,6 +24931,7 @@ export function heartbeatService( process.env, executionWorkspace.cwd, ), + ...buildAgentIdentityEnv(agentIdentity), ...(instructionCopy && isAgentDirectoryCopy(instructionCopy) ? { AGENT_HOME: instructionCopy.executionRoot } : {}), ...(nativeMcpServer ? { @@ -25066,6 +25088,7 @@ export function heartbeatService( legacyAdapterEntered = true; return withAdapterExecutionPhase(executionPhaseContext, "adapter_execution", () => adapter.execute({ getFreshSessionHandoff, + agentIdentity, runId: run.id, agent, runtime: runtimeForAdapter, @@ -25146,6 +25169,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 appendIdentityRedactedLog(stream, tail); + } if (instructionSave) adapterResult.resultJson = { ...adapterResult.resultJson, instructionSave }; adapterResult = applyWorkspaceRestoreFailure(adapterResult); // A returned result can include a failed restore. Keep the workspace @@ -26224,7 +26252,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) @@ -26267,7 +26295,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; @@ -26481,9 +26509,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, @@ -26527,7 +26555,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..88a0da0c2a7 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -18,6 +18,7 @@ import { } from "node:fs/promises"; import { execFileSync } from "node:child_process"; import { tmpdir } from "node:os"; +import { inspect } from "node:util"; import { join } from "node:path"; import { heartbeatRuns, @@ -39,7 +40,7 @@ import { type NativeExecutionInput, type PrpEvent, } from "@paperclipai/paperclip-runner"; -import { createHash } from "node:crypto"; +import { createHash, generateKeyPairSync } from "node:crypto"; import { DatabaseSync } from "node:sqlite"; import { nativeSha256 } from "./canonical.js"; import * as noLaunchProofModule from "./native-maintenance-no-launch.js"; @@ -6271,6 +6272,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; @@ -7594,6 +7612,34 @@ describe("native session bounded recovery", () => { }); }); + it.each(["full", "truncated"])("redacts %s identity from native failure writes, logs, and rethrown errors", async (mode) => { + const privateKeyPem = generateKeyPairSync("ed25519").privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const keyBody = privateKeyPem.split("\n")[1]; + const failure = new Error(`native provider failed: ${mode === "full" ? privateKeyPem : privateKeyPem.slice(0, 64)}`); + void failure.stack; // Also cover stacks materialized before the catch boundary. + const updates: Array<{ table: unknown; values: Record }> = []; + const logs: string[] = []; + state.execute.mockReset().mockRejectedValueOnce(failure); + state.upsertRecoveryAction.mockReset().mockResolvedValue({}); + const service = vi.spyOn(issueServiceModule, "issueService").mockReturnValue({ + update: vi.fn(async () => ({ status: "blocked", statusVersion: 1 })), + } as unknown as ReturnType); + try { + await expect(executePaperclipNativeSession({ + db: leaseDb(execution, {}, {}, updates), execution, runnerInstanceId: "runner", + runnerEnvironment: { PAPERCLIP_AGENT_PRIVATE_KEY: privateKeyPem }, + onLog: async (_stream, text) => { logs.push(text); }, + })).rejects.toBe(failure); + expect(updates.some(entry => entry.table === nativeRunFinalizations && entry.values.failureDetail)).toBe(true); + for (const output of [inspect(updates.map(entry => entry.values), { depth: null }), logs.join(""), failure.message, failure.stack!]) { + expect(output).not.toContain(keyBody.slice(0, 24)); + expect(output).toContain("***REDACTED***"); + } + } finally { + service.mockRestore(); + } + }); + it("persists actionable operator recovery without an automatic cleanup wake", async () => { const updates: Array<{ table: unknown; values: Record }> = []; diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 981169652ec..0fb5a5ab50b 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"); @@ -7435,6 +7446,10 @@ export async function executePaperclipNativeSession(input: { async function executePaperclipNativeSessionWithinScope( input: Parameters[0], ): Promise { + const identityRedactor = createAgentIdentityRedactor(input.runnerEnvironment?.PAPERCLIP_AGENT_PRIVATE_KEY); + const redactIdentityText = (text: string) => redactSensitiveText( + identityRedactor.chunk("diagnostic", text) + identityRedactor.finish("diagnostic"), + ); if ( input.execution.provider.kind !== "codex" && input.execution.provider.kind !== "opencode" && @@ -7853,6 +7868,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 +8428,7 @@ async function executePaperclipNativeSessionWithinScope( requireSessionCloseBeforeReturn: runnerdBackend !== null || input.instructionWorkingCopy !== undefined, onSessionClosed: input.instructionWorkingCopy?.collectStopped, onCheckpoint: async (snapshot) => { + snapshot = identityRedactor.redact(snapshot); if (warmSessionId !== null && warmConfigDigest !== null) { await persistWarmNativeCheckpoint( input.execution, @@ -8428,7 +8445,7 @@ async function executePaperclipNativeSessionWithinScope( } }, onPostCompletionEnrichmentFailure: async ({ stage, error }) => { - const detail = redactSensitiveText( + const detail = redactIdentityText( error instanceof Error ? error.message : String(error), ).slice(-4_096); await input.onLog?.( @@ -8439,7 +8456,7 @@ async function executePaperclipNativeSessionWithinScope( onSessionQuarantined: async (reason) => { await input.onLog?.( "stderr", - `[paperclip-runner] warm native session quarantined: ${redactSensitiveText(reason).slice(-1_000)}\n`, + `[paperclip-runner] warm native session quarantined: ${redactIdentityText(reason).slice(-1_000)}\n`, ); }, onContinuityBreak: async (continuity) => { @@ -8636,6 +8653,16 @@ async function executePaperclipNativeSessionWithinScope( clearSteeringDeliveries(input.execution.binding.runId); clearNativeRuntimeRequestResolutions(input.execution.binding.runId); } catch (error) { + // Preserve typed failure semantics while removing credentials before any + // persistence, reporting, logging, or rethrow at this boundary. + if (error instanceof Error) { + const originalMessage = error.message; + error.message = redactIdentityText(originalMessage); + if (error.stack) error.stack = redactIdentityText(originalMessage + ? error.stack.split(originalMessage).join(error.message) : error.stack); + } else { + error = identityRedactor.redact(error); + } if (nativeRunsDetachingForRestart.has(input.execution.binding.runId)) { await leaseRenewal.stop().catch(() => undefined); liveQuestions.close(); @@ -8674,7 +8701,7 @@ async function executePaperclipNativeSessionWithinScope( ); const failedAtMs = Date.now(); - const executionFailureMessage = redactSensitiveText( + const executionFailureMessage = redactIdentityText( error instanceof Error ? error.message : String(error), ).slice(-4_096); if (!taskSettleScope) { @@ -8785,11 +8812,10 @@ async function executePaperclipNativeSessionWithinScope( const { exhausted } = recoveryProjection; const integrityFailure = sourceFailureCode === "native_event_replay_conflict"; - const message = - error instanceof Error - ? error.message.slice(0, 2_000) - : String(error).slice(0, 2_000); - const sanitizedStderrTail = redactSensitiveText(message).slice(-4_096); + const message = redactIdentityText( + error instanceof Error ? error.message : String(error), + ).slice(0, 2_000); + const sanitizedStderrTail = redactIdentityText(message).slice(-4_096); // Set inside the transaction only when the write below genuinely // transitions the run into "failed". Read after the transaction // commits, so a rolled-back write never reports a false failure. @@ -8820,7 +8846,7 @@ async function executePaperclipNativeSessionWithinScope( recoveryState: phase === "retryable_failure" ? "resuming_session" : "blocked", failureCode, - failureDetail: { + failureDetail: identityRedactor.redact({ message, originalFailureCode: error instanceof NativeProviderTerminalFailure @@ -8857,7 +8883,7 @@ async function executePaperclipNativeSessionWithinScope( "bootstrap_retry" ? "Retry provider bootstrap on this same run; durable evidence proves no provider session or provider event was created." : "Resume this same run from its exact persisted native provider checkpoint after the retry delay.", - }, + }), nextAttemptAt, recoveryHistory: sql`( select coalesce(jsonb_agg(item order by ordinal), '[]'::jsonb) 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..81bcbfdad86 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,90 @@ 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 }, + }); + let sourceSeq = 2; + for (const [itemId, chunks] of [ + ["halves", [keyBody.slice(0, 31), keyBody.slice(31)]], + ["bytes", [...keyBody]], + ] as const) { + for (const [index, chunk] of chunks.entries()) { + const event: PrpEvent = { + schema: "paperclip.prp.event.v1", sourceEventId: `${runnerId}:${sourceSeq}`, sourceSeq: sourceSeq++, + sourceInstanceId: runnerId, sourceKind: "runner", runId: identity.runId, + normalizedSessionId: identity.sessionId, turnId: "identity-turn", itemId, + eventType: "item.delta", schemaVersion: 1, priority: 1, emittedAt: new Date().toISOString(), + payload: { itemId, kind: "commandExecution", text: chunk, update: { delta: chunk } }, + }; + await port.appendEvent(event); + expect((await port.appendEvent(event)).disposition).toBe("duplicate"); + if (index === 0) await port.appendEvent({ + ...event, sourceEventId: `${runnerId}:${sourceSeq}`, sourceSeq: sourceSeq++, + payload: { itemId: "another-provider-item", kind: "commandExecution", text: "Unrelated output\n", update: { delta: "Unrelated output\n" } }, + }); + } + } + await port.appendEvent({ + schema: "paperclip.prp.event.v1", sourceEventId: `${runnerId}:${sourceSeq}`, sourceSeq: sourceSeq++, + sourceInstanceId: runnerId, sourceKind: "runner", runId: identity.runId, + normalizedSessionId: identity.sessionId, turnId: "tail-turn", itemId: "reasoning-item", + eventType: "item.delta", schemaVersion: 1, priority: 1, emittedAt: new Date().toISOString(), + payload: { kind: "reasoning", text: "Thinking-" }, + }); + const terminal: PrpEvent = { + schema: "paperclip.prp.event.v1", sourceEventId: `${runnerId}:${sourceSeq}`, sourceSeq: sourceSeq++, + sourceInstanceId: runnerId, sourceKind: "runner", runId: identity.runId, + normalizedSessionId: identity.sessionId, turnId: "tail-turn", + eventType: "turn.completed", schemaVersion: 1, priority: 0, emittedAt: new Date().toISOString(), + payload: { status: "completed" }, + }; + await port.appendEvent(terminal); + expect((await port.appendEvent(terminal)).disposition).toBe("duplicate"); + expect(observed.at(-1)?.payload.outputTails).toEqual([{ itemId: "reasoning-item", payload: { kind: "reasoning", text: "-" } }]); + 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***"); + } + for (const itemId of ["halves", "bytes"]) { + const deltas = observed.filter(event => event.payload.itemId === itemId).map(event => event.payload); + expect(deltas.map(delta => delta.text).join("")).toBe("***REDACTED***"); + expect(deltas.map(delta => (delta.update as { delta: string }).delta).join("")).toBe("***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..e41fd522c8e 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 { @@ -39,6 +40,9 @@ export interface PaperclipControlPlaneBinding { controlPlaneSourceInstanceId: string; } +const IDENTITY_DELTA_RETRY_WINDOW = 1_024; +const IDENTITY_DELTA_RETRY_BYTES = 4 * 1024 * 1024; + function isPrpEvent(value: NativeRunEvent | PrpEvent): value is PrpEvent { return "schema" in value && [ "paperclip.prp.event.v1", @@ -71,6 +75,9 @@ 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 #redactedDeltas = new Map(); + #redactedDeltaBytes = 0; readonly #binding: PaperclipControlPlaneBinding; #sessionId: string | null = null; readonly #onCommittedEvent?: (event: PrpEvent) => Promise; @@ -80,11 +87,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 +161,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 +194,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; + let 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"); } @@ -193,6 +204,37 @@ export class PaperclipControlPlanePort implements ControlPlanePort { if (event.sourceInstanceId !== expectedSourceInstanceId) { throw new Error("native_event_source_binding_mismatch"); } + const terminalItem = /^item\.(completed|failed|cancelled)$/.test(event.eventType); + const terminalTurn = /^(turn|session)\.(completed|failed|cancelled|interrupted|closed)$/.test(event.eventType); + if ((event.eventType === "item.delta" || terminalItem || terminalTurn) && this.#identityRedactor.values.length > 0) { + const key = `${event.sourceInstanceId}:${event.sourceEventId}`; + const cached = this.#redactedDeltas.get(key); + const originalSha = nativeSha256(validated.event); + if (cached) { + if (cached.originalSha !== originalSha) throw new Error("native_event_source_payload_conflict"); + event = cached.event; + } else { + const stream = `${event.sourceInstanceId}:${event.turnId}:${event.itemId}`; + event = { ...event, payload: event.eventType === "item.delta" + ? this.#identityRedactor.delta(stream, validated.event.payload, event.itemId) + : this.#identityRedactor.settleDeltas( + terminalTurn ? (event.eventType.startsWith("session.") ? `${event.sourceInstanceId}:` : `${event.sourceInstanceId}:${event.turnId}:`) : stream, + event.payload, terminalTurn, + ), + }; + // Retries must use the same redacted payload without consuming a delta twice. + const bytes = Buffer.byteLength(JSON.stringify(event)); + this.#redactedDeltas.set(key, { event, originalSha, bytes }); + this.#redactedDeltaBytes += bytes; + // Match the bounded runner transcript retry window; large tool output + // also has a byte cap so long runs cannot retain another full transcript. + while (this.#redactedDeltas.size > IDENTITY_DELTA_RETRY_WINDOW || this.#redactedDeltaBytes > IDENTITY_DELTA_RETRY_BYTES) { + const oldest = this.#redactedDeltas.keys().next().value!; + this.#redactedDeltaBytes -= this.#redactedDeltas.get(oldest)!.bytes; + this.#redactedDeltas.delete(oldest); + } + } + } const persisted = await appendHeartbeatRunEvent(this.#db, { companyId: this.#binding.companyId, runId: this.#binding.runId, @@ -264,6 +306,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/components/transcript/native-run-events.test.ts b/ui/src/components/transcript/native-run-events.test.ts index 7fba1ff24ca..7fb01b5b371 100644 --- a/ui/src/components/transcript/native-run-events.test.ts +++ b/ui/src/components/transcript/native-run-events.test.ts @@ -307,6 +307,16 @@ describe("nativeRunEventsToTranscript", () => { expect(transcript.filter((entry) => entry.kind === "run_terminal")).toHaveLength(1); }); + it("preserves buffered output tails when a native turn ends", () => { + const transcript = nativeRunEventsToTranscript([ + event(1, "item.delta", { itemId: "reasoning-1", kind: "reasoning", text: "Thinking" }), + event(2, "turn.completed", { status: "completed", outputTails: [ + { itemId: "reasoning-1", payload: { kind: "reasoning", text: "-" } }, + ] }), + ]); + expect(transcript.filter(entry => entry.kind === "thinking").map(entry => "text" in entry ? entry.text : "").join("")).toBe("Thinking-"); + }); + it("projects provider-neutral messages, tools, usage, and the final reply", () => { const transcript = nativeRunEventsToTranscript([ event(6, "run.result.proposed", runResult("Done safely.")), diff --git a/ui/src/components/transcript/native-run-events.ts b/ui/src/components/transcript/native-run-events.ts index 954a3ce7856..85f5426a16a 100644 --- a/ui/src/components/transcript/native-run-events.ts +++ b/ui/src/components/transcript/native-run-events.ts @@ -612,6 +612,24 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[] if (seenSourceEventIds.has(sourceEventId)) return false; seenSourceEventIds.add(sourceEventId); return true; + }).flatMap((event) => { + const envelope = record(event.payload?.prpEvent); + const payload = record(envelope?.payload); + if (!envelope || envelope.eventType !== event.eventType + || !/^(item|turn|session)\.(completed|failed|cancelled|interrupted|closed)$/.test(event.eventType) + || !Array.isArray(payload?.outputTails)) return [event]; + // Redaction can hold a possible key prefix until the item/turn ends. + // Project its settled tail as display-only deltas before the terminal. + const tails = payload.outputTails.flatMap((value, index): HeartbeatRunEvent[] => { + const tail = record(value); + const tailPayload = record(tail?.payload); + if (!tailPayload || typeof tailPayload.text !== "string") return []; + return [{ ...event, eventType: "item.delta", payload: { prpEvent: { + ...envelope, eventType: "item.delta", itemId: tail?.itemId, + sourceEventId: `${envelope.sourceEventId}:output-tail:${index}`, payload: tailPayload, + } } }]; + }); + return [...tails, event]; }); const hasAcceptedResult = orderedEvents.some( (event) => event.eventType === "run.result.accepted", 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
+
+ )} +