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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-06 13:57:03 -05:00
1 parent d9f600043f
commit a590ab769d
73 files changed
+2014 -63

No files matched your search

+4 -3
View File
@@ -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");
});
+2 -1
View File
@@ -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: {},
};
}
+100
View File
@@ -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.
+9
View File
@@ -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).
+12
View File
@@ -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
+9
View File
@@ -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.
@@ -172,6 +172,7 @@ async function runExecutor(
runtime?: Record<string, unknown>;
executionTransport?: Record<string, unknown>;
authToken?: string;
agentIdentity?: AdapterExecutionContext["agentIdentity"];
executionTarget?: Record<string, unknown>;
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<string, string>).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({
@@ -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<string, string> = { ...buildPaperclipEnv(agent), PAPERCLIP_RUN_ID: runId };
const env: Record<string, string> = { ...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),
@@ -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;
@@ -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 () => ({
+1
View File
@@ -1,5 +1,6 @@
export type {
AdapterAgent,
AgentRuntimeIdentity,
AdapterRuntime,
UsageSummary,
AdapterBillingType,
@@ -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,
+21 -2
View File
@@ -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<string, string> {
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<string, string> {
}, identity?: AgentRuntimeIdentity): Record<string, string> {
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<string, string> = {
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;
+8
View File
@@ -196,7 +196,15 @@ export interface AdapterRuntimeEvent {
payload?: Record<string, unknown>;
}
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. */
@@ -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<string, unknown>;
@@ -216,7 +217,7 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const envConfig = parseObject(config.env);
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
const env: Record<string, string> = { ...buildPaperclipEnv(agent, input.agentIdentity) };
env.PAPERCLIP_RUN_ID = runId;
const wakeTaskId =
@@ -456,6 +457,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
const runtimeConfig = await buildClaudeRuntimeConfig({
agentIdentity: ctx.agentIdentity,
runId,
agent,
config,
@@ -89,6 +89,7 @@ describe("codex execute — outbound auth copy-back restore contribution", () =>
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<void> }) {
async function runTeardown(input: { sandboxAuth: string; hostAuth: string; onProviderStopped?: () => Promise<void>; 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 () => ({
@@ -754,7 +754,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
for (const note of preparedRuntimeConfig.notes) {
await onLog("stdout", `[paperclip] ${note}\n`);
}
const paperclipBaseEnv = buildPaperclipEnv(agent);
const paperclipBaseEnv = buildPaperclipEnv(agent, ctx.agentIdentity);
const runtimeMcpGateways = (ctx.runtimeMcp?.getServers() ?? []).map((server) => ({
name: server.name,
endpointPath: server.url,
@@ -1228,6 +1228,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
},
);
const args = execArgs.args;
if (ctx.agentIdentity) {
const identityNames = ["PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY"];
const shellKeys = [...new Set([
"PATH", "HOME", "LANG", "LANGUAGE", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR", "TEMP", "TMP", "CODEX_HOME",
// Keep Codex's default secret-name exclusions except for the scoped
// Paperclip API token used by the agent skill. Never include host env.
...Object.keys(env).filter(key => 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
@@ -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();
@@ -17,6 +17,7 @@ import {
asString,
buildPaperclipEnv,
buildRuntimeToolsEnv,
isForbiddenConfigEnvKey,
joinPromptSections,
parseObject,
readPaperclipIssueWorkModeFromContext,
@@ -59,6 +60,7 @@ function asStringEnvMap(value: unknown): Record<string, string> {
const parsed = parseObject(value);
const env: Record<string, string> = {};
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<string, st
const { runId, agent, context, authToken } = ctx;
const env: Record<string, string> = {
...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,
};
@@ -240,7 +240,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const envConfig = parseObject(config.env);
let env: Record<string, string> = {
...buildPaperclipEnv(agent),
...buildPaperclipEnv(agent, ctx.agentIdentity),
...buildRuntimeToolsEnv(ctx.runtimeTools),
};
env.PAPERCLIP_RUN_ID = runId;
@@ -273,7 +273,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const envConfig = parseObject(config.env);
const env: Record<string, string> = {
...buildPaperclipEnv(agent),
...buildPaperclipEnv(agent, ctx.agentIdentity),
...buildRuntimeToolsEnv(ctx.runtimeTools),
};
env.PAPERCLIP_RUN_ID = runId;
@@ -311,7 +311,7 @@ async function executeTurn(ctx: AdapterExecutionContext): Promise<AdapterExecuti
const executeTurn = async (): Promise<AdapterExecutionResult> => {
const envConfig = parseObject(config.env);
const env: Record<string, string> = {
...buildPaperclipEnv(agent),
...buildPaperclipEnv(agent, ctx.agentIdentity),
...buildRuntimeToolsEnv(ctx.runtimeTools),
};
env.PAPERCLIP_RUN_ID = runId;
@@ -487,7 +487,7 @@ export async function execute(
const env: Record<string, string> = {
...(process.env as Record<string, string>),
...(userEnv && typeof userEnv === "object" ? userEnv : {}),
...buildPaperclipEnv(ctx.agent),
...buildPaperclipEnv(ctx.agent, ctx.agentIdentity),
...buildRuntimeToolsEnv(ctx.runtimeTools),
};
@@ -246,7 +246,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const hasExplicitApiKey =
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
const env: Record<string, string> = {
...buildPaperclipEnv(agent),
...buildPaperclipEnv(agent, ctx.agentIdentity),
...buildRuntimeToolsEnv(ctx.runtimeTools),
};
env.PAPERCLIP_RUN_ID = runId;
@@ -272,7 +272,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const envConfig = parseObject(config.env);
const env: Record<string, string> = {
...buildPaperclipEnv(agent),
...buildPaperclipEnv(agent, ctx.agentIdentity),
...buildRuntimeToolsEnv(ctx.runtimeTools),
};
env.PAPERCLIP_RUN_ID = runId;
@@ -277,7 +277,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
// Build environment
const envConfig = parseObject(config.env);
const env: Record<string, string> = {
...buildPaperclipEnv(agent),
...buildPaperclipEnv(agent, ctx.agentIdentity),
...buildRuntimeToolsEnv(ctx.runtimeTools),
};
env.PAPERCLIP_RUN_ID = runId;
@@ -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 $$;
@@ -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": "",
+7
View File
@@ -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
}
]
}
@@ -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<Record<string, unknown>>().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'`),
}));
+2
View File
@@ -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";
@@ -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",
@@ -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<u64> {
// 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",
@@ -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<String, String>,
streams: BTreeMap<String, IdentityOutputStream>,
}
#[derive(Clone, PartialEq)]
struct IdentityOutputStream {
scope: String,
item_id: Option<Value>,
payload: Value,
paths: Vec<String>,
}
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<String> = 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<String> = 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<String, DurableRunnerError> {
#[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)));
@@ -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();
});
@@ -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: {
@@ -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);
@@ -33,6 +33,7 @@ export class ProviderTraceFileSink {
readonly #channel: string;
readonly #maxBytes: number;
readonly #sensitiveValues = new Set<string>();
#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(
@@ -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",
@@ -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,
@@ -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 : []),
]);
@@ -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) => {
@@ -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"),
);
@@ -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;
}
@@ -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.
@@ -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<string, string>,
): NodeJS.ProcessEnv {
const allowed = [
"PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY",
"PATH",
"LANG",
"LC_ALL",
@@ -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]) =>
+1
View File
@@ -933,6 +933,7 @@ export type {
AgentDetail,
ClearAgentErrorResponse,
AgentPermissions,
AgentPublicIdentity,
AgentInstructionsBundleMode,
AgentInstructionsFileSummary,
AgentInstructionsFileDetail,
+8
View File
@@ -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;
}
+1
View File
@@ -280,6 +280,7 @@ export type {
AgentDetail,
ClearAgentErrorResponse,
AgentPermissions,
AgentPublicIdentity,
AgentRuntimeConfig,
AgentInstructionsBundleMode,
AgentInstructionsFileSummary,
@@ -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) =>
@@ -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));
});
});
@@ -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);
+256
View File
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
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***");
});
});
@@ -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<string, unknown> | null };
context: Record<string, unknown>;
};
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.",
+1 -1
View File
@@ -26,7 +26,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const cwd = asString(config.cwd, process.cwd());
const envConfig = parseObject(config.env);
const env: Record<string, string> = {
...buildPaperclipEnv(agent),
...buildPaperclipEnv(agent, ctx.agentIdentity),
...buildRuntimeToolsEnv(ctx.runtimeTools),
};
for (const [k, v] of Object.entries(envConfig)) {
+7
View File
@@ -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");
+19
View File
@@ -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}",
+16 -8
View File
@@ -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() {
@@ -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<string, string>();
const deltaStreams = new Map<string, {
scope: string; itemId?: string; payload: Record<string, unknown>; paths: string[];
}>();
const redactor = {
values,
redact<T>(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<T>(stream: string, value: T, itemId?: string): T {
const scope = stream;
const paths: string[] = [];
const payload = value as Record<string, unknown>;
// 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<string, unknown>, wholeTurn: boolean) {
const tails: Array<{ itemId?: string; payload: Record<string, unknown> }> = [];
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;
}
+88
View File
@@ -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<AgentPublicIdentity | null> {
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);
}
+3
View File
@@ -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) &&
+36 -8
View File
@@ -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<EffectiveRunSessionConfigMetadata> {
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);
@@ -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: {
@@ -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<string, unknown> }> = [];
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<typeof issueServiceModule.issueService>);
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<string, unknown> }> =
[];
@@ -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<unknown>;
}): Promise<AdapterExecutionResult> {
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<typeof executePaperclipNativeSession>[0],
): Promise<AdapterExecutionResult> {
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)
@@ -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[] = [];
@@ -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<typeof createAgentIdentityRedactor>;
readonly #redactedDeltas = new Map<string, { event: PrpEvent; originalSha: string; bytes: number }>();
#redactedDeltaBytes = 0;
readonly #binding: PaperclipControlPlaneBinding;
#sessionId: string | null = null;
readonly #onCommittedEvent?: (event: PrpEvent) => Promise<void>;
@@ -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<void>;
onDuplicateEvent?: (event: PrpEvent) => Promise<void>;
} = {},
) {
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<void> {
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<void> {
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,
+3
View File
@@ -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<AgentPublicIdentity | null>(agentPath(id, companyId, "/identity")),
adoptAiConnection: (agentId: string, interactionId: string, connectionId: string, companyId: string) =>
api.post<ConnectionIntentInteraction>(withCompanyScope(`/agents/${agentId}/connection-intents/${interactionId}/adopt`, companyId), { connectionId }),
@@ -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.")),
@@ -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",
+1
View File
@@ -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,
+18 -1
View File
@@ -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 (
<div className="space-y-6">
<LatestRunCard runs={runs} agentId={agentRouteId} issuesById={issuesById} />
@@ -1750,6 +1753,20 @@ export function AgentOverview({
) : <span className="text-sm">Board</span>}
</SummaryRow>
<SummaryRow label="Direct reports"><span className="text-sm tabular-nums">{directReportCount}</span></SummaryRow>
<SummaryRow label="Public key">
{identity.isPending ? <span className="text-sm text-muted-foreground">Loading…</span>
: identity.isError ? <span className="text-sm text-destructive">Could not load public key</span>
: identity.data ? (
<CopyText text={identity.data.publicKeyPem} ariaLabel="Copy public key" title="Copy public key">
<span className="font-mono text-sm">{identity.data.keyId.slice(0, 19)}…</span>
</CopyText>
) : (
<div className="text-sm">
<div>Not created yet</div>
<div className="text-xs text-muted-foreground">Created automatically on the next managed run</div>
</div>
)}
</SummaryRow>
</div>
</section>