mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
feat: give managed agents persistent cryptographic identities
Create encrypted Ed25519 identities on agent creation or first managed run. Deliver runtime credentials, expose public identity, and preserve safe copy and recovery behavior. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
22a3ea3414
commit
b4cbba83f1
64 files changed
+1401
-47
No files matched your search
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Agent cryptographic identity
|
||||
|
||||
Each agent has one persistent Ed25519 keypair. New agents receive it in the
|
||||
agent-creation transaction. Existing agents receive it when Paperclip prepares
|
||||
their next managed run. Migration, startup, public API reads, and opening the
|
||||
agent page do not provision existing agents. There is no backfill command.
|
||||
|
||||
## Storage
|
||||
|
||||
`agent_identity_keys` lives in the agent's home instance database, including a
|
||||
hosted stack's database. Its agent primary key and composite company/agent
|
||||
foreign key enforce one identity and company-consistent ownership. Deleting an
|
||||
agent cascades to its identity. Creation locks the agent row inside a transaction,
|
||||
so simultaneous first runs use the same stored pair.
|
||||
|
||||
Public keys use SPKI PEM and private keys use PKCS#8 PEM. `keyId` is `sha256:`
|
||||
followed by the base64url SHA-256 digest of the public key's SPKI DER bytes.
|
||||
Private material is encrypted using the existing `local_encrypted` provider and
|
||||
stored separately from company secrets. The wrapping key is the stack's
|
||||
`PAPERCLIP_SECRETS_MASTER_KEY` or the local instance's secrets master-key file.
|
||||
The file is published atomically with mode `0600` on first use. No per-agent
|
||||
Cloud secret or Cloud registry record is needed.
|
||||
|
||||
Decryption and public/private key validation happen before launch. A wrong
|
||||
master key, corrupt ciphertext, or mismatched public key fails the run; none
|
||||
causes automatic key replacement. Renames, model changes, pauses, resumes, and
|
||||
restarts preserve the keypair.
|
||||
|
||||
## Managed runs
|
||||
|
||||
Paperclip supplies these server-owned environment variables:
|
||||
|
||||
```text
|
||||
PAPERCLIP_AGENT_KEY_ID
|
||||
PAPERCLIP_AGENT_PUBLIC_KEY
|
||||
PAPERCLIP_AGENT_PRIVATE_KEY
|
||||
```
|
||||
|
||||
The two key values are multiline PEM strings. Runtime-only adapter context and
|
||||
native runner environment fields deliver them to managed processes. Configured
|
||||
or inherited environment values cannot replace the assigned identity. Session
|
||||
compatibility includes the public key ID, so an older warm process is replaced
|
||||
before its next turn. Private values are excluded from invocation metadata and
|
||||
execution configuration; output and failure diagnostics redact known private
|
||||
material. The run-log redactor handles split stdout/stderr chunks independently.
|
||||
|
||||
Independently hosted HTTP and gateway agents, including Runner's API-hosted Claude Managed and AWS AgentCore providers, do not receive private keys in v1.
|
||||
Their new agent records still get stored identities; their existing records stay
|
||||
unprovisioned until a supported managed run.
|
||||
|
||||
An agent can sign using an ordinary cryptographic library, for example Node.js:
|
||||
|
||||
```js
|
||||
import { sign } from "node:crypto";
|
||||
const signature = sign(null, challengeBytes, process.env.PAPERCLIP_AGENT_PRIVATE_KEY);
|
||||
```
|
||||
|
||||
This identity does not change Paperclip bearer-token authentication. There is no
|
||||
signing API, private-key download endpoint, Git integration, rotation UI, or
|
||||
external-key import. Agents possess their private keys. External consumers must
|
||||
define their own authorization, expiry, and revocation rules.
|
||||
|
||||
## Public identity
|
||||
|
||||
`GET /api/agents/:id/identity` applies the existing agent-read access checks. It
|
||||
returns `null` before provisioning, otherwise:
|
||||
|
||||
```ts
|
||||
{ algorithm: "Ed25519", keyId: string, publicKeyPem: string, createdAt: string }
|
||||
```
|
||||
|
||||
The agent's Identity section shows a shortened fingerprint and copies the full
|
||||
public PEM. Before provisioning it shows “Not created yet” and explains that
|
||||
creation happens on the next managed run.
|
||||
|
||||
## Copies and recovery
|
||||
|
||||
Company/template imports and duplication use normal agent creation and get new
|
||||
identities. Minimal and full development seeds omit identity **rows**, while
|
||||
retaining the schema, even with live-work preservation. Those copied agents get
|
||||
fresh identities on their next managed runs.
|
||||
|
||||
Ordinary disaster-recovery backups include encrypted identities. Recovery needs
|
||||
both the database and the matching wrapping key; back up the key separately.
|
||||
Do not use a disaster-recovery backup as a development clone when agents should
|
||||
have distinct identities.
|
||||
@@ -566,3 +566,12 @@ reservation cannot silently disappear. Failed cleanup or an ambiguous storage
|
||||
write requires operator reconciliation before an unattached reservation is
|
||||
removed. The table stores no response bodies. See `doc/runner-api-tools.md` for
|
||||
limits and the operator override.
|
||||
|
||||
## Agent identity keys and backups
|
||||
|
||||
`agent_identity_keys` stores one encrypted Ed25519 identity per agent. Its migration
|
||||
creates schema only: existing agents provision on their next managed run. Public
|
||||
reads and server startup do not provision them. Normal backups preserve identity
|
||||
rows and need the matching secrets master key for recovery. Both development seed
|
||||
modes omit identity rows, including with live-work preservation, so copied agents
|
||||
get fresh identities. See [Agent cryptographic identity](AGENT-IDENTITY.md).
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,11 @@ async function buildRuntime(input: {
|
||||
if (acpxAgent === "codex") {
|
||||
const codexStartupConfig = buildCodexStartupConfig({
|
||||
existingConfig: env.CODEX_CONFIG,
|
||||
...(input.ctx.agentIdentity ? { identityEnvironmentKeys: [...new Set([
|
||||
"PATH", "HOME", "LANG", "TMPDIR", "CODEX_HOME",
|
||||
"PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY",
|
||||
...Object.keys(env).filter(key => !/key|secret|token/i.test(key)),
|
||||
])] } : {}),
|
||||
requestedModel,
|
||||
requestedThinkingEffort,
|
||||
fastMode,
|
||||
@@ -2203,6 +2214,7 @@ async function buildRuntime(input: {
|
||||
// identifiers are NOT here; they scope the outer session key only (see
|
||||
// `keyIdentity`). The fingerprint builder accepts only this identity.
|
||||
const fingerprintIdentity: SessionFingerprintIdentity = {
|
||||
...(input.ctx.agentIdentity ? { agentIdentityKeyId: input.ctx.agentIdentity.keyId } : {}),
|
||||
acpxAgent,
|
||||
agentCommand: agentCommand ?? acpxAgent,
|
||||
cwd: path.resolve(sessionCwd),
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,13 @@ 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([...Object.keys(process.env), ...Object.keys(env)])]
|
||||
.filter(key => identityNames.includes(key) || !/key|secret|token/i.test(key));
|
||||
args.unshift("-c", "features.shell_snapshot=false", "-c", 'shell_environment_policy.inherit="all"', "-c", "shell_environment_policy.ignore_default_excludes=true",
|
||||
"-c", `shell_environment_policy.include_only=${JSON.stringify(shellKeys)}`);
|
||||
}
|
||||
const commandNotesWithFastMode =
|
||||
execArgs.fastModeIgnoredReason == null
|
||||
? commandNotes
|
||||
|
||||
@@ -108,7 +108,7 @@ function buildWakeEnv(ctx: AdapterExecutionContext, configEnv: Record<string, st
|
||||
const { runId, agent, context, authToken } = ctx;
|
||||
const env: Record<string, string> = {
|
||||
...configEnv,
|
||||
...buildPaperclipEnv(agent),
|
||||
...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 $$;
|
||||
+451
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "357aa875-870e-4232-9b3f-4194a2c74844",
|
||||
"prevId": "dfa5064e-7b35-4d20-8a3d-aa808c4d92e0",
|
||||
"id": "597fc11f-c141-4357-b4f0-e500a4d84658",
|
||||
"prevId": "392e3276-35d9-41b8-b703-9d592081bae8",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
@@ -829,6 +829,83 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.agent_identity_keys": {
|
||||
"name": "agent_identity_keys",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"algorithm": {
|
||||
"name": "algorithm",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key_id": {
|
||||
"name": "key_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"public_key_pem": {
|
||||
"name": "public_key_pem",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"private_key_material": {
|
||||
"name": "private_key_material",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"agent_identity_keys_owner_fk": {
|
||||
"name": "agent_identity_keys_owner_fk",
|
||||
"tableFrom": "agent_identity_keys",
|
||||
"tableTo": "agents",
|
||||
"columnsFrom": [
|
||||
"company_id",
|
||||
"agent_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"company_id",
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"agent_identity_keys_algorithm_check": {
|
||||
"name": "agent_identity_keys_algorithm_check",
|
||||
"value": "\"agent_identity_keys\".\"algorithm\" = 'Ed25519'"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.agent_instruction_heads": {
|
||||
"name": "agent_instruction_heads",
|
||||
"schema": "",
|
||||
@@ -51112,6 +51189,142 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.mcp_event_admissions": {
|
||||
"name": "mcp_event_admissions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"subscription_id": {
|
||||
"name": "subscription_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"grant_id": {
|
||||
"name": "grant_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"reserves_subscription": {
|
||||
"name": "reserves_subscription",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"finished_at": {
|
||||
"name": "finished_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"mcp_event_admissions_expiry_idx": {
|
||||
"name": "mcp_event_admissions_expiry_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "expires_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"mcp_event_admissions_grant_idx": {
|
||||
"name": "mcp_event_admissions_grant_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "grant_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"mcp_event_admissions_company_idx": {
|
||||
"name": "mcp_event_admissions_company_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"mcp_event_admissions_company_id_companies_id_fk": {
|
||||
"name": "mcp_event_admissions_company_id_companies_id_fk",
|
||||
"tableFrom": "mcp_event_admissions",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"mcp_event_admissions_grant_id_mcp_oauth_grants_id_fk": {
|
||||
"name": "mcp_event_admissions_grant_id_mcp_oauth_grants_id_fk",
|
||||
"tableFrom": "mcp_event_admissions",
|
||||
"tableTo": "mcp_oauth_grants",
|
||||
"columnsFrom": [
|
||||
"grant_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.mcp_event_deliveries": {
|
||||
"name": "mcp_event_deliveries",
|
||||
"schema": "",
|
||||
@@ -51565,6 +51778,13 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"grant_types": {
|
||||
"name": "grant_types",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'[\"authorization_code\",\"refresh_token\"]'::jsonb"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
@@ -51581,6 +51801,181 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.mcp_oauth_device_requests": {
|
||||
"name": "mcp_oauth_device_requests",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"device_code_hash": {
|
||||
"name": "device_code_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_code_hash": {
|
||||
"name": "user_code_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"resource": {
|
||||
"name": "resource",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"scopes": {
|
||||
"name": "scopes",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"requested_company_id": {
|
||||
"name": "requested_company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"source_hash": {
|
||||
"name": "source_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"grant_id": {
|
||||
"name": "grant_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"interval_seconds": {
|
||||
"name": "interval_seconds",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 5
|
||||
},
|
||||
"next_poll_at": {
|
||||
"name": "next_poll_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"mcp_oauth_device_code_uq": {
|
||||
"name": "mcp_oauth_device_code_uq",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "device_code_hash",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"mcp_oauth_user_code_uq": {
|
||||
"name": "mcp_oauth_user_code_uq",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_code_hash",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"mcp_oauth_device_expiry_idx": {
|
||||
"name": "mcp_oauth_device_expiry_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "expires_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"mcp_oauth_device_requests_client_id_mcp_oauth_clients_id_fk": {
|
||||
"name": "mcp_oauth_device_requests_client_id_mcp_oauth_clients_id_fk",
|
||||
"tableFrom": "mcp_oauth_device_requests",
|
||||
"tableTo": "mcp_oauth_clients",
|
||||
"columnsFrom": [
|
||||
"client_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"mcp_oauth_device_requests_grant_id_mcp_oauth_grants_id_fk": {
|
||||
"name": "mcp_oauth_device_requests_grant_id_mcp_oauth_grants_id_fk",
|
||||
"tableFrom": "mcp_oauth_device_requests",
|
||||
"tableTo": "mcp_oauth_grants",
|
||||
"columnsFrom": [
|
||||
"grant_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.mcp_oauth_grants": {
|
||||
"name": "mcp_oauth_grants",
|
||||
"schema": "",
|
||||
@@ -51706,6 +52101,54 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.mcp_oauth_metadata_admissions": {
|
||||
"name": "mcp_oauth_metadata_admissions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"source_hash": {
|
||||
"name": "source_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"mcp_oauth_metadata_admissions_expiry_idx": {
|
||||
"name": "mcp_oauth_metadata_admissions_expiry_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "expires_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.mcp_oauth_requests": {
|
||||
"name": "mcp_oauth_requests",
|
||||
"schema": "",
|
||||
@@ -51752,6 +52195,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"requested_company_id": {
|
||||
"name": "requested_company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"grant_id": {
|
||||
"name": "grant_id",
|
||||
"type": "uuid",
|
||||
+7
@@ -2129,6 +2129,13 @@
|
||||
"when": 1791300719193,
|
||||
"tag": "0305_chubby_vin_gonzales",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 306,
|
||||
"version": "7",
|
||||
"when": 1791308373750,
|
||||
"tag": "0306_windy_leo",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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'`),
|
||||
}));
|
||||
@@ -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";
|
||||
@@ -109,6 +109,9 @@ impl AcpxSidecarTransport {
|
||||
}
|
||||
};
|
||||
let mut keys = vec![
|
||||
"PAPERCLIP_AGENT_KEY_ID",
|
||||
"PAPERCLIP_AGENT_PUBLIC_KEY",
|
||||
"PAPERCLIP_AGENT_PRIVATE_KEY",
|
||||
"LANGUAGE",
|
||||
"SSL_CERT_FILE",
|
||||
"SSL_CERT_DIR",
|
||||
|
||||
@@ -32,6 +32,9 @@ const WARM_ATTACHMENT_TAIL_DRAIN_LIMIT: usize = 256;
|
||||
const WARM_ATTACHMENT_QUIET_WINDOW: Duration = Duration::from_millis(10);
|
||||
const WARM_ATTACHMENT_DRAIN_DEADLINE: Duration = Duration::from_millis(100);
|
||||
const OPENCODE_PROVIDER_ENVIRONMENT_KEYS: &[&str] = &[
|
||||
"PAPERCLIP_AGENT_KEY_ID",
|
||||
"PAPERCLIP_AGENT_PUBLIC_KEY",
|
||||
"PAPERCLIP_AGENT_PRIVATE_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"PAPERCLIP_NATIVE_MCP_NAME",
|
||||
"PAPERCLIP_NATIVE_MCP_URL",
|
||||
@@ -242,6 +245,8 @@ impl ProviderTraceSink {
|
||||
}
|
||||
|
||||
fn frame(&mut self, direction: &str, raw: &[u8]) -> Option<u64> {
|
||||
let redacted = crate::durable::redact_agent_identity_text(&String::from_utf8_lossy(raw));
|
||||
let raw = redacted.as_bytes();
|
||||
if self.captured_bytes.saturating_add(raw.len()) > self.max_bytes {
|
||||
self.truncated = true;
|
||||
return None;
|
||||
@@ -792,6 +797,9 @@ const GITHUB_CREDENTIAL_ENVIRONMENT_KEYS: &[&str] = &[
|
||||
];
|
||||
|
||||
const CODEX_PROVIDER_ENVIRONMENT_KEYS: &[&str] = &[
|
||||
"PAPERCLIP_AGENT_KEY_ID",
|
||||
"PAPERCLIP_AGENT_PUBLIC_KEY",
|
||||
"PAPERCLIP_AGENT_PRIVATE_KEY",
|
||||
"CODEX_HOME",
|
||||
"OPENAI_API_KEY",
|
||||
"CODEX_API_KEY",
|
||||
|
||||
@@ -16,8 +16,9 @@ pub use runner::{
|
||||
TerminalDeliveryReconciliation,
|
||||
};
|
||||
pub(crate) use state::{
|
||||
create_private_temporary_file, open_private_regular_file, redact_sensitive_text_values,
|
||||
redact_text, sanitize_value, validate_semantic_tool_input, verify_private_directory,
|
||||
create_private_temporary_file, open_private_regular_file, redact_agent_identity_text,
|
||||
redact_sensitive_text_values, redact_text, sanitize_value, validate_semantic_tool_input,
|
||||
verify_private_directory,
|
||||
};
|
||||
pub use state::{
|
||||
Command, CommandDisposition, DurableState, DurableStateStore, EventPriority,
|
||||
|
||||
@@ -1842,7 +1842,30 @@ pub(crate) fn redact_text(input: &str) -> String {
|
||||
redacted
|
||||
}
|
||||
|
||||
pub(crate) fn redact_agent_identity_text(input: &str) -> String {
|
||||
let Ok(key) = std::env::var("PAPERCLIP_AGENT_PRIVATE_KEY") else {
|
||||
return input.to_owned();
|
||||
};
|
||||
if key.is_empty() {
|
||||
return input.to_owned();
|
||||
}
|
||||
let escaped = serde_json::to_string(&key).unwrap_or_default();
|
||||
let mut result = input.replace(&key, "[REDACTED]");
|
||||
if escaped.len() > 2 {
|
||||
result = result.replace(&escaped[1..escaped.len() - 1], "[REDACTED]");
|
||||
}
|
||||
for line in key
|
||||
.lines()
|
||||
.filter(|line| !line.is_empty() && !line.starts_with("-----"))
|
||||
{
|
||||
result = result.replace(line, "[REDACTED]");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn redact_sensitive_text_values(input: &str) -> String {
|
||||
let identity_redacted = redact_agent_identity_text(input);
|
||||
let input = identity_redacted.as_str();
|
||||
let normalized = input.to_ascii_lowercase();
|
||||
let bytes = normalized.as_bytes();
|
||||
let mut ranges: Vec<(usize, usize)> = Vec::new();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -390,6 +390,9 @@ it("preserves an explicit OpenCode permission mode at the runner spawn boundary"
|
||||
runnerDigest: expectedRunnerDigest,
|
||||
environment: {
|
||||
PATH: "/bin",
|
||||
PAPERCLIP_AGENT_KEY_ID: "sha256:identity",
|
||||
PAPERCLIP_AGENT_PUBLIC_KEY: "public-pem",
|
||||
PAPERCLIP_AGENT_PRIVATE_KEY: "private-pem",
|
||||
OPENROUTER_API_KEY: "provider-key",
|
||||
PAPERCLIP_OPENCODE_PERMISSION_MODE: "deny",
|
||||
PAPERCLIP_OPENCODE_RUNTIME_DIR: "/runner/opencode",
|
||||
@@ -419,6 +422,9 @@ it("preserves an explicit OpenCode permission mode at the runner spawn boundary"
|
||||
expect(launches).toHaveLength(1);
|
||||
expect(launches[0]!.environment).toMatchObject({
|
||||
PATH: "/bin",
|
||||
PAPERCLIP_AGENT_KEY_ID: "sha256:identity",
|
||||
PAPERCLIP_AGENT_PUBLIC_KEY: "public-pem",
|
||||
PAPERCLIP_AGENT_PRIVATE_KEY: "private-pem",
|
||||
OPENROUTER_API_KEY: "provider-key",
|
||||
PAPERCLIP_OPENCODE_PERMISSION_MODE: "deny",
|
||||
PAPERCLIP_OPENCODE_RUNTIME_DIR: "/runner/opencode",
|
||||
|
||||
@@ -3371,6 +3371,9 @@ const runnerPlatformEnvironmentKeys = [
|
||||
] as const;
|
||||
|
||||
const runnerExplicitProviderEnvironmentKeys = [
|
||||
"PAPERCLIP_AGENT_KEY_ID",
|
||||
"PAPERCLIP_AGENT_PUBLIC_KEY",
|
||||
"PAPERCLIP_AGENT_PRIVATE_KEY",
|
||||
...ACPX_CREDENTIAL_NAMES.pi,
|
||||
...ACPX_CREDENTIAL_NAMES.cursor,
|
||||
...ACPX_CREDENTIAL_NAMES.copilot,
|
||||
|
||||
@@ -110,6 +110,7 @@ export function createSanitizedAcpxSpawnInput(
|
||||
"RUST_BACKTRACE",
|
||||
"PAPERCLIP_NATIVE_MCP_NAME",
|
||||
"PAPERCLIP_NATIVE_MCP_URL",
|
||||
...(environment === undefined ? [] : ["PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY"]),
|
||||
...credentialNames,
|
||||
]);
|
||||
let retainedBytes = 0;
|
||||
|
||||
@@ -453,6 +453,14 @@ export async function prepareAcpxRuntimeSandbox(input: {
|
||||
// also affect provider startup and belongs at the launch boundary.
|
||||
"[features]",
|
||||
"shell_snapshot = false",
|
||||
...(input.environment?.PAPERCLIP_AGENT_KEY_ID ? [
|
||||
"[shell_environment_policy]", 'inherit = "all"', "ignore_default_excludes = true",
|
||||
`include_only = ${JSON.stringify([...new Set([
|
||||
"PATH", "HOME", "LANG", "LANGUAGE", "TZ", "TMPDIR", "TEMP", "TMP", "CODEX_HOME",
|
||||
"PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY",
|
||||
...Object.keys(input.environment).filter(key => !/key|secret|token/i.test(key)),
|
||||
])])}`,
|
||||
] : []),
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
@@ -227,8 +227,10 @@ const SAFE_ENVIRONMENT_KEYS = [
|
||||
* separate empty-by-default environment and filesystem permission profile.
|
||||
*/
|
||||
export function createSanitizedCodexEnvironment(
|
||||
source: NodeJS.ProcessEnv = process.env,
|
||||
source?: NodeJS.ProcessEnv,
|
||||
): NodeJS.ProcessEnv {
|
||||
const explicit = source;
|
||||
source ??= process.env;
|
||||
const environment: NodeJS.ProcessEnv = {};
|
||||
for (const key of SAFE_ENVIRONMENT_KEYS) {
|
||||
const value = source[key];
|
||||
@@ -236,6 +238,9 @@ export function createSanitizedCodexEnvironment(
|
||||
if (key.includes("PROXY") && proxyContainsCredentials(value)) continue;
|
||||
environment[key] = value;
|
||||
}
|
||||
for (const key of ["PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY"]) {
|
||||
if (explicit?.[key] !== undefined) environment[key] = explicit[key];
|
||||
}
|
||||
Object.assign(environment, githubCredentialEnvironment(source));
|
||||
return environment;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -2020,7 +2020,11 @@ async function startRuntime(input: {
|
||||
const assignedMcp = nativeMcpLaunchBinding(
|
||||
input.options.environment ?? process.env,
|
||||
);
|
||||
const identityKey = input.options.environment?.PAPERCLIP_AGENT_PRIVATE_KEY;
|
||||
const identityValues = identityKey ? [identityKey, JSON.stringify(identityKey).slice(1, -1),
|
||||
...identityKey.split(/\r?\n/).filter(line => line && !line.startsWith("-----"))] : [];
|
||||
input.trace?.addSensitiveValues([
|
||||
...identityValues,
|
||||
password,
|
||||
authHeader,
|
||||
bridge.secret,
|
||||
@@ -2197,6 +2201,7 @@ async function startRuntime(input: {
|
||||
bridge,
|
||||
trace: input.trace,
|
||||
sensitiveValues: [
|
||||
...identityValues,
|
||||
password,
|
||||
input.options.environment?.OPENROUTER_API_KEY,
|
||||
].filter((value): value is string => Boolean(value)),
|
||||
@@ -2599,6 +2604,7 @@ function sanitizedEnvironment(
|
||||
overrides: Record<string, string>,
|
||||
): NodeJS.ProcessEnv {
|
||||
const allowed = [
|
||||
"PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY",
|
||||
"PATH",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
|
||||
@@ -3211,6 +3211,7 @@ export function resolveRunnerdAcpxPermissionMode(
|
||||
}
|
||||
|
||||
const OPEN_CODE_RUNNER_ENVIRONMENT_KEYS = new Set([
|
||||
"PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY",
|
||||
"PATH",
|
||||
"LANG",
|
||||
"LANGUAGE",
|
||||
@@ -3245,6 +3246,10 @@ function createSanitizedOpenCodeRunnerEnvironment(
|
||||
source: NodeJS.ProcessEnv | undefined,
|
||||
): NodeJS.ProcessEnv {
|
||||
const candidate = { ...process.env, ...source };
|
||||
for (const key of ["PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY"]) {
|
||||
delete candidate[key];
|
||||
if (source?.[key] !== undefined) candidate[key] = source[key];
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(candidate).filter(
|
||||
([key, value]) =>
|
||||
|
||||
@@ -933,6 +933,7 @@ export type {
|
||||
AgentDetail,
|
||||
ClearAgentErrorResponse,
|
||||
AgentPermissions,
|
||||
AgentPublicIdentity,
|
||||
AgentInstructionsBundleMode,
|
||||
AgentInstructionsFileSummary,
|
||||
AgentInstructionsFileDetail,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,223 @@
|
||||
import { randomBytes, randomUUID, verify, generateKeyPairSync } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agentIdentityKeys, agents, companies, createDb, closeRegisteredClients, ensurePostgresDatabase, runDatabaseBackup, runDatabaseRestore, type Db } from "@paperclipai/db";
|
||||
import { resolveWorktreeSeedPlan } from "../../../cli/src/commands/worktree-lib.js";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { agentIdentityService, supportsManagedAgentIdentity } from "../services/agent-identity.js";
|
||||
import { agentService } from "../services/agents.js";
|
||||
import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js";
|
||||
import { execute } from "../adapters/process/execute.js";
|
||||
import { createAgentIdentityRedactor } from "../services/agent-identity-redaction.js";
|
||||
import { heartbeatService } from "../services/heartbeat.js";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
import { actorMiddleware } from "../middleware/auth.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.js";
|
||||
|
||||
describe("agent cryptographic identity", () => {
|
||||
let db: Db;
|
||||
let database: Awaited<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)).toHaveLength(0);
|
||||
for (const type of ["http", "openclaw_gateway", "hermes_gateway"]) expect(supportsManagedAgentIdentity(type)).toBe(false);
|
||||
for (const provider of ["claude_managed", "aws_agentcore"]) expect(supportsManagedAgentIdentity("paperclip_runner", { provider })).toBe(false);
|
||||
expect(supportsManagedAgentIdentity("paperclip_runner", { provider: "codex" }, "claude_managed_agents_api")).toBe(false);
|
||||
expect(supportsManagedAgentIdentity("paperclip_runner", { provider: "claude_managed" }, "codex_app_server")).toBe(true);
|
||||
});
|
||||
|
||||
it("concurrent first runs converge, restart/model/name changes preserve keys, and ownership is enforced", async () => {
|
||||
const agent = await oldAgent();
|
||||
const pairs = await Promise.all(Array.from({ length: 8 }, () =>
|
||||
agentIdentityService(db).ensureAgentIdentity(companyId, agent.id)));
|
||||
expect(new Set(pairs.map(pair => pair.privateKeyPem)).size).toBe(1);
|
||||
await db.update(agents).set({ name: "Renamed", adapterConfig: { model: "another-model" }, status: "paused" }).where(eq(agents.id, agent.id));
|
||||
const freshDb = createDb(database.connectionString);
|
||||
expect(await agentIdentityService(freshDb).ensureAgentIdentity(companyId, agent.id)).toEqual(pairs[0]);
|
||||
expect(await agentIdentityService(db).getPublicIdentity(randomUUID(), agent.id)).toBeNull();
|
||||
await expect(agentIdentityService(db).ensureAgentIdentity(randomUUID(), agent.id)).rejects.toThrow("Agent not found");
|
||||
const second = await oldAgent();
|
||||
expect((await agentIdentityService(db).ensureAgentIdentity(companyId, second.id)).keyId).not.toBe(pairs[0].keyId);
|
||||
await db.delete(agents).where(eq(agents.id, second.id));
|
||||
expect(await agentIdentityService(db).getPublicIdentity(companyId, second.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("creates identities in the agent creation transaction and rolls both writes back on failure", async () => {
|
||||
const created = await agentService(db).create(companyId, { name: "New identity", role: "engineer", status: "pending_approval" });
|
||||
expect(await agentIdentityService(db).getPublicIdentity(companyId, created.id)).toMatchObject({ algorithm: "Ed25519" });
|
||||
const id = randomUUID();
|
||||
await expect(db.transaction(async tx => {
|
||||
await tx.insert(agents).values({ id, companyId, name: "Rollback", role: "engineer" });
|
||||
await agentIdentityService(tx as unknown as Db).ensureAgentIdentity(companyId, id);
|
||||
throw new Error("rollback");
|
||||
})).rejects.toThrow("rollback");
|
||||
expect(await db.select().from(agents).where(eq(agents.id, id))).toHaveLength(0);
|
||||
expect(await agentIdentityService(db).getPublicIdentity(companyId, id)).toBeNull();
|
||||
const fail = vi.spyOn(localEncryptedProvider, "createSecret").mockRejectedValueOnce(new Error("encryption unavailable"));
|
||||
await expect(agentService(db).create(companyId, { name: "Must roll back", role: "engineer", status: "pending_approval" })).rejects.toThrow("encryption unavailable");
|
||||
fail.mockRestore();
|
||||
expect(await db.select().from(agents).where(eq(agents.name, "Must roll back"))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fails closed for wrong master keys, corrupted material and mismatched public keys", async () => {
|
||||
const agent = await oldAgent();
|
||||
const identity = await agentIdentityService(db).ensureAgentIdentity(companyId, agent.id);
|
||||
const [stored] = await db.select().from(agentIdentityKeys).where(eq(agentIdentityKeys.agentId, agent.id));
|
||||
expect(JSON.stringify(stored)).not.toContain(identity.privateKeyPem.split("\n")[1]);
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", randomBytes(32).toString("base64"));
|
||||
await expect(agentIdentityService(db).ensureAgentIdentity(companyId, agent.id)).rejects.toThrow("decryption failed");
|
||||
expect(await agentIdentityService(db).getPublicIdentity(companyId, agent.id)).toMatchObject({ keyId: identity.keyId });
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "");
|
||||
await db.update(agentIdentityKeys).set({ privateKeyMaterial: { ...stored.privateKeyMaterial, ciphertext: "AAAA" } }).where(eq(agentIdentityKeys.agentId, agent.id));
|
||||
await expect(agentIdentityService(db).ensureAgentIdentity(companyId, agent.id)).rejects.toThrow();
|
||||
const other = generateKeyPairSync("ed25519").publicKey.export({ type: "spki", format: "pem" }).toString();
|
||||
await db.update(agentIdentityKeys).set({ privateKeyMaterial: stored.privateKeyMaterial, publicKeyPem: other }).where(eq(agentIdentityKeys.agentId, agent.id));
|
||||
await expect(agentIdentityService(db).ensureAgentIdentity(companyId, agent.id)).rejects.toThrow("do not match");
|
||||
expect((await db.select().from(agentIdentityKeys).where(eq(agentIdentityKeys.agentId, agent.id)))[0].keyId).toBe(identity.keyId);
|
||||
});
|
||||
|
||||
it("a real managed process signs a random challenge before and after reconnect, ignoring hostile env overrides", async () => {
|
||||
const agent = await oldAgent();
|
||||
const challenge = randomBytes(32).toString("base64");
|
||||
let publicPem = "";
|
||||
for (let restart = 0; restart < 2; restart++) {
|
||||
const restarted = agentIdentityService(createDb(database.connectionString));
|
||||
const identity = await restarted.ensureAgentIdentity(companyId, agent.id);
|
||||
const publicIdentity = await restarted.getPublicIdentity(companyId, agent.id);
|
||||
if (restart) expect(publicIdentity!.publicKeyPem).toBe(publicPem);
|
||||
publicPem = publicIdentity!.publicKeyPem;
|
||||
vi.stubEnv("PAPERCLIP_AGENT_PRIVATE_KEY", "inherited override");
|
||||
const logs: string[] = [];
|
||||
const metas: unknown[] = [];
|
||||
await execute({
|
||||
runId: randomUUID(), agent, agentIdentity: identity,
|
||||
config: { command: process.execPath, cwd: directory, args: ["-e", `const {sign}=require('node:crypto'); process.stdout.write(sign(null,Buffer.from(process.env.CHALLENGE,'base64'),process.env.PAPERCLIP_AGENT_PRIVATE_KEY).toString('base64'));`], env: { CHALLENGE: challenge, PAPERCLIP_AGENT_PRIVATE_KEY: "configured override" } },
|
||||
context: {}, runtime: { sessionId: null, sessionParams: null, taskKey: null },
|
||||
onLog: async (_stream, chunk) => { logs.push(chunk); }, onMeta: async meta => { metas.push(meta); },
|
||||
});
|
||||
expect(verify(null, Buffer.from(challenge, "base64"), publicPem, Buffer.from(logs.join(""), "base64"))).toBe(true);
|
||||
expect(JSON.stringify(metas)).not.toContain(identity.privateKeyPem.split("\n")[1]);
|
||||
}
|
||||
});
|
||||
|
||||
it("lazily provisions through managed run preparation, exposes only the public API key, and redacts emitted keys", async () => {
|
||||
const agent = await oldAgent();
|
||||
const app = express();
|
||||
app.use(actorMiddleware(db, { deploymentMode: "local_trusted" }));
|
||||
app.use("/api", agentRoutes(db));
|
||||
app.use(errorHandler);
|
||||
const firstRead = await request(app).get(`/api/agents/${agent.id}/identity`);
|
||||
expect(firstRead.status).toBe(200);
|
||||
expect(firstRead.body).toBeNull();
|
||||
const challenge = randomBytes(32).toString("base64");
|
||||
await db.update(agents).set({ adapterType: "process", adapterConfig: {
|
||||
command: process.execPath, cwd: directory,
|
||||
args: ["-e", `const {sign}=require('node:crypto'); const k=process.env.PAPERCLIP_AGENT_PRIVATE_KEY; console.log('SIGNATURE:'+sign(null,Buffer.from(process.env.CHALLENGE,'base64'),k).toString('base64')); process.stdout.write(k.slice(0,40)); setTimeout(()=>process.stdout.write(k.slice(40)),30);`],
|
||||
env: { CHALLENGE: challenge, PAPERCLIP_AGENT_PRIVATE_KEY: "ignored override" },
|
||||
} }).where(eq(agents.id, agent.id));
|
||||
let previousKeyId = "";
|
||||
for (let restart = 0; restart < 2; restart++) {
|
||||
const heartbeat = heartbeatService(createDb(database.connectionString));
|
||||
try {
|
||||
const run = await heartbeat.invoke(agent.id, "on_demand", {}, "manual");
|
||||
expect(run).toBeTruthy();
|
||||
await drainHeartbeatRunsToQuiescence(db, heartbeat);
|
||||
const finished = await heartbeat.getRun(run!.id);
|
||||
expect(finished?.status, JSON.stringify(finished)).toBe("succeeded");
|
||||
const publicResponse = await request(app).get(`/api/agents/${agent.id}/identity`);
|
||||
expect(publicResponse.status).toBe(200);
|
||||
expect(Object.keys(publicResponse.body).sort()).toEqual(["algorithm", "createdAt", "keyId", "publicKeyPem"]);
|
||||
if (restart) expect(publicResponse.body.keyId).toBe(previousKeyId);
|
||||
previousKeyId = publicResponse.body.keyId;
|
||||
const signature = /SIGNATURE:([A-Za-z0-9+/=]+)/.exec(finished!.stdoutExcerpt ?? "")?.[1];
|
||||
expect(signature).toBeTruthy();
|
||||
expect(verify(null, Buffer.from(challenge, "base64"), publicResponse.body.publicKeyPem, Buffer.from(signature!, "base64"))).toBe(true);
|
||||
const privateBody = (await agentIdentityService(db).ensureAgentIdentity(companyId, agent.id)).privateKeyPem.split("\n")[1];
|
||||
expect(JSON.stringify(finished)).not.toContain(privateBody);
|
||||
expect(JSON.stringify(await heartbeat.readLog(run!.id))).not.toContain(privateBody);
|
||||
} finally {
|
||||
await drainHeartbeatRunsToQuiescence(db, heartbeat);
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it("disaster recovery retains identity; both development seed modes omit it and mint fresh keys", async () => {
|
||||
const agent = await oldAgent();
|
||||
const original = await agentIdentityService(db).ensureAgentIdentity(companyId, agent.id);
|
||||
for (const mode of ["backup", "minimal", "full"] as const) {
|
||||
const seed = mode === "backup" ? undefined : resolveWorktreeSeedPlan(mode);
|
||||
const backup = await runDatabaseBackup({
|
||||
connectionString: database.connectionString, backupDir: join(directory, mode),
|
||||
backupEngine: "javascript", retention: { dailyDays: 1, weeklyWeeks: 0, monthlyMonths: 0 },
|
||||
excludeTables: seed?.excludedTables, nullifyColumns: seed?.nullifyColumns,
|
||||
});
|
||||
const target = new URL(database.connectionString);
|
||||
const admin = new URL(database.connectionString);
|
||||
admin.pathname = "/postgres";
|
||||
await ensurePostgresDatabase(admin.toString(), `identity_${mode}`);
|
||||
target.pathname = `/identity_${mode}`;
|
||||
await runDatabaseRestore({ connectionString: target.toString(), backupFile: backup.backupFile });
|
||||
const restored = agentIdentityService(createDb(target.toString()));
|
||||
if (mode === "backup") expect(await restored.ensureAgentIdentity(companyId, agent.id)).toEqual(original);
|
||||
else {
|
||||
expect(await restored.getPublicIdentity(companyId, agent.id)).toBeNull();
|
||||
expect((await restored.ensureAgentIdentity(companyId, agent.id)).keyId).not.toBe(original.keyId);
|
||||
}
|
||||
await closeRegisteredClients(target.toString());
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe("identity output redaction", () => {
|
||||
const pem = generateKeyPairSync("ed25519").privateKey.export({ format: "pem", type: "pkcs8" }).toString();
|
||||
it("redacts PEM, JSON-escaped PEM and raw private material", () => {
|
||||
const redactor = createAgentIdentityRedactor(pem);
|
||||
for (const text of [pem, JSON.stringify(pem), pem.split("\n")[1]]) {
|
||||
expect(redactor.redact({ output: text }).output).not.toContain(pem.split("\n")[1]);
|
||||
}
|
||||
});
|
||||
it("redacts every split boundary and interleaved streams, including interrupted prefixes", () => {
|
||||
for (let split = 1; split < pem.length; split++) {
|
||||
const redactor = createAgentIdentityRedactor(pem);
|
||||
let output = redactor.chunk("stdout", pem.slice(0, split));
|
||||
expect(redactor.chunk("stderr", "ordinary output\n")).toBe("ordinary output\n");
|
||||
output += redactor.chunk("stdout", pem.slice(split));
|
||||
output += redactor.finish("stdout");
|
||||
expect(output).not.toContain(pem.split("\n")[1]);
|
||||
}
|
||||
const redactor = createAgentIdentityRedactor(pem);
|
||||
expect(redactor.chunk("stdout", pem.slice(0, 42))).toBe("");
|
||||
expect(redactor.finish("stdout")).toBe("***REDACTED***");
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { agentIdentityService } from "../services/agent-identity.js";
|
||||
import { aiConnectionRouterService, poolMemberRuntimeConfig } from "../services/ai-connection-router.js";
|
||||
import { connectionIntentService } from "../services/connection-intents.js";
|
||||
import { completeConnectionIntentSchema } from "@paperclipai/shared";
|
||||
@@ -4385,6 +4386,12 @@ export function agentRoutes(
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.get("/agents/:id/identity", async (req, res) => {
|
||||
const agent = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found");
|
||||
if (!agent || !(await assertAgentReadAllowed(req, res, agent))) return;
|
||||
res.json(await agentIdentityService(db).getPublicIdentity(agent.companyId, agent.id));
|
||||
});
|
||||
|
||||
router.get("/agents/:id", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const agent = await getAccessibleResource(req, res, svc.getById(id), "Agent not found");
|
||||
|
||||
@@ -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,37 @@
|
||||
import { REDACTED_EVENT_VALUE } from "../redaction.js";
|
||||
import { redactRegisteredSecretValues } from "./run-secret-redaction.js";
|
||||
|
||||
/** Keep private material in memory only; do not copy it into run snapshots. */
|
||||
export function createAgentIdentityRedactor(privateKeyPem?: string) {
|
||||
const values = privateKeyPem ? [...new Set([
|
||||
privateKeyPem,
|
||||
privateKeyPem.trim(),
|
||||
JSON.stringify(privateKeyPem).slice(1, -1),
|
||||
...privateKeyPem.split(/\r?\n/).filter(line => line && !line.startsWith("-----")),
|
||||
])].sort((a, b) => b.length - a.length) : [];
|
||||
const pending = new Map<string, string>();
|
||||
return {
|
||||
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; }
|
||||
}
|
||||
}
|
||||
pending.set(stream, held ? text.slice(-held) : "");
|
||||
return held ? text.slice(0, -held) : text;
|
||||
},
|
||||
// An interrupted partial secret must not be flushed as plaintext.
|
||||
finish(stream: string): string {
|
||||
const held = pending.get(stream);
|
||||
pending.delete(stream);
|
||||
return held ? REDACTED_EVENT_VALUE : "";
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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;
|
||||
@@ -23432,7 +23448,7 @@ export function heartbeatService(
|
||||
await getCurrentUserRedactionOptions();
|
||||
const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
|
||||
const sanitizedChunk = compactRunLogChunk(
|
||||
redactCurrentUserText(chunk, currentUserRedactionOptions),
|
||||
redactCurrentUserText(identityRedactor.chunk(stream, chunk), currentUserRedactionOptions),
|
||||
);
|
||||
if (stream === "stdout")
|
||||
stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
|
||||
@@ -23593,6 +23609,7 @@ export function heartbeatService(
|
||||
}
|
||||
}
|
||||
const onAdapterMeta = async (meta: AdapterInvocationMeta) => {
|
||||
meta = identityRedactor.redact(meta);
|
||||
if (meta.env && secretKeys.size > 0) {
|
||||
for (const key of secretKeys) {
|
||||
if (key in meta.env) meta.env[key] = "***REDACTED***";
|
||||
@@ -23608,6 +23625,7 @@ export function heartbeatService(
|
||||
};
|
||||
|
||||
const onAdapterEvent = async (event: AdapterRuntimeEvent) => {
|
||||
event = identityRedactor.redact(event);
|
||||
const eventType = event.eventType.trim();
|
||||
if (!eventType) return;
|
||||
await appendRunEvent(currentRun, {
|
||||
@@ -24176,6 +24194,7 @@ export function heartbeatService(
|
||||
getNativeFreshSessionHandoff = nativeReviewRequest ? undefined : getFreshSessionHandoff;
|
||||
const buildExecution = ({ normalizedSessionId, resumedSession }: { normalizedSessionId: string; resumedSession: boolean }) =>
|
||||
buildNativeExecutionInput({
|
||||
agentKeyId: agentIdentity?.keyId,
|
||||
companyId: agent.companyId,
|
||||
runId: run.id,
|
||||
issue: nativeReviewRequest ? { ...issueRef, title: `Review: ${issueRef.title}`, description: nativeReviewRequest } : issueRef,
|
||||
@@ -24909,6 +24928,7 @@ export function heartbeatService(
|
||||
process.env,
|
||||
executionWorkspace.cwd,
|
||||
),
|
||||
...buildAgentIdentityEnv(agentIdentity),
|
||||
...(instructionCopy && isAgentDirectoryCopy(instructionCopy) ? { AGENT_HOME: instructionCopy.executionRoot } : {}),
|
||||
...(nativeMcpServer
|
||||
? {
|
||||
@@ -25065,6 +25085,7 @@ export function heartbeatService(
|
||||
legacyAdapterEntered = true;
|
||||
return withAdapterExecutionPhase(executionPhaseContext, "adapter_execution", () => adapter.execute({
|
||||
getFreshSessionHandoff,
|
||||
agentIdentity,
|
||||
runId: run.id,
|
||||
agent,
|
||||
runtime: runtimeForAdapter,
|
||||
@@ -25145,6 +25166,11 @@ export function heartbeatService(
|
||||
if (!guardedDispatch.dispatched) return;
|
||||
adapterResult = await guardedDispatch.resultPromise;
|
||||
}
|
||||
adapterResult = identityRedactor.redact(adapterResult);
|
||||
for (const stream of ["stdout", "stderr"] as const) {
|
||||
const tail = identityRedactor.finish(stream);
|
||||
if (tail) await onLog(stream, tail);
|
||||
}
|
||||
if (instructionSave) adapterResult.resultJson = { ...adapterResult.resultJson, instructionSave };
|
||||
adapterResult = applyWorkspaceRestoreFailure(adapterResult);
|
||||
// A returned result can include a failed restore. Keep the workspace
|
||||
@@ -26223,7 +26249,7 @@ export function heartbeatService(
|
||||
// child. Let the cancellation write settle before attempting failure.
|
||||
await processRunCancellationSettlements.get(run.id)?.settled;
|
||||
const message = redactCurrentUserText(
|
||||
err instanceof Error ? err.message : "Unknown adapter failure",
|
||||
identityRedactor.redact(err instanceof Error ? err.message : "Unknown adapter failure"),
|
||||
await getCurrentUserRedactionOptions(),
|
||||
);
|
||||
const workspaceValidationFailure = isWorkspaceValidationFailure(err)
|
||||
@@ -26266,7 +26292,7 @@ export function heartbeatService(
|
||||
recordedResponsibleUserDenialCode ??
|
||||
nativeTerminalFailureCode ??
|
||||
"adapter_failed";
|
||||
logger.error({ err, runId }, "heartbeat execution failed");
|
||||
logger.error({ err: identityRedactor.redact({ message: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined }), runId }, "heartbeat execution failed");
|
||||
|
||||
let logSummary: {
|
||||
bytes: number | null;
|
||||
@@ -26480,9 +26506,9 @@ export function heartbeatService(
|
||||
// Setup code before adapter.execute threw (e.g. ensureRuntimeState, resolveWorkspaceForRun).
|
||||
// The inner catch did not fire, so we must record the failure here.
|
||||
const message = redactCurrentUserText(
|
||||
outerErr instanceof Error
|
||||
identityRedactor.redact(outerErr instanceof Error
|
||||
? outerErr.message
|
||||
: "Unknown setup failure",
|
||||
: "Unknown setup failure"),
|
||||
await getCurrentUserRedactionOptions(),
|
||||
);
|
||||
// A missing secret/env binding is a known pre-dispatch configuration gap,
|
||||
@@ -26526,7 +26552,7 @@ export function heartbeatService(
|
||||
nonRetryablePreflightCode ??
|
||||
"setup_failed";
|
||||
logger.error(
|
||||
{ err: outerErr, runId },
|
||||
{ err: identityRedactor.redact({ message: outerErr instanceof Error ? outerErr.message : String(outerErr), stack: outerErr instanceof Error ? outerErr.stack : undefined }), runId },
|
||||
"heartbeat execution setup failed",
|
||||
);
|
||||
const setupFailureAgent = await getAgent(run.agentId).catch(() => null);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -6271,6 +6271,23 @@ describe("native warm session supervision", () => {
|
||||
return { base, next, run };
|
||||
}
|
||||
|
||||
it("replaces an older warm process before the first turn with cryptographic identity", async () => {
|
||||
const { base } = fixture("identity-key-upgrade");
|
||||
const next = { ...base, binding: { ...base.binding, runId: "identity-key-next" } };
|
||||
const close = vi.fn(async () => undefined);
|
||||
state.execute.mockReset().mockImplementationOnce(async options => {
|
||||
options.onSession?.({ close }); return result;
|
||||
}).mockImplementationOnce(async options => {
|
||||
expect(close).toHaveBeenCalledWith({ reason: "warm native session configuration changed" });
|
||||
expect(options.existingSession).toBeUndefined();
|
||||
return result;
|
||||
});
|
||||
await executePaperclipNativeSession({ db: leaseDb(base), execution: base, runnerInstanceId: "runner" });
|
||||
await executePaperclipNativeSession({ db: leaseDb(next), execution: next, runnerInstanceId: "runner",
|
||||
runnerEnvironment: { PAPERCLIP_AGENT_KEY_ID: "sha256:identity-key" } });
|
||||
expect(state.execute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("awaits prior idle ownership retirement before launching the accepted-plan session", async () => {
|
||||
const { base, next, run } = fixture("identity-handoff");
|
||||
let finishClose!: () => void;
|
||||
|
||||
@@ -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");
|
||||
@@ -7853,6 +7864,7 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
controlPlaneSourceInstanceId: controlPlaneInstanceId,
|
||||
},
|
||||
{
|
||||
privateKeyPem: input.runnerEnvironment?.PAPERCLIP_AGENT_PRIVATE_KEY,
|
||||
onCommittedEvent: async (event) => {
|
||||
await toolTrace.observe(event);
|
||||
if (event.eventType === "item.completed" &&
|
||||
@@ -8412,6 +8424,7 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
requireSessionCloseBeforeReturn: runnerdBackend !== null || input.instructionWorkingCopy !== undefined,
|
||||
onSessionClosed: input.instructionWorkingCopy?.collectStopped,
|
||||
onCheckpoint: async (snapshot) => {
|
||||
snapshot = createAgentIdentityRedactor(input.runnerEnvironment?.PAPERCLIP_AGENT_PRIVATE_KEY).redact(snapshot);
|
||||
if (warmSessionId !== null && warmConfigDigest !== null) {
|
||||
await persistWarmNativeCheckpoint(
|
||||
input.execution,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { asc, eq, sql } from "drizzle-orm";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
import { renderPaperclipWakePrompt } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { readCompletedAssistantMessageCandidate, resolveHeartbeatRunResponse, selectHeartbeatRunFinalAgentMessage } from "../heartbeat-run-summary.js";
|
||||
import {
|
||||
@@ -299,6 +299,47 @@ describe("PaperclipControlPlanePort conformance", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("redacts identity private material before persisting native events and checkpoints", async () => {
|
||||
const identity = { ...CONTROL_PLANE_CONFORMANCE_OPEN.identity, runId: randomUUID(), sessionId: randomUUID() };
|
||||
const runnerId = randomUUID();
|
||||
const privateKeyPem = generateKeyPairSync("ed25519").privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
||||
const keyBody = privateKeyPem.split("\n")[1];
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: identity.runId, companyId: identity.companyId, agentId: identity.agentId,
|
||||
status: "running", runtimeMode: "native", nativeIssueId: identity.issueId,
|
||||
nativeSessionId: identity.sessionId, runnerInstanceId: runnerId,
|
||||
completionContractId: contractId, completionContractSha256: contractSha,
|
||||
contextSnapshot: { issueId: identity.issueId },
|
||||
});
|
||||
const observed: PrpEvent[] = [];
|
||||
const port = new PaperclipControlPlanePort(db, {
|
||||
...identity, completionContractId: contractId, completionContractSha256: contractSha,
|
||||
sourceInstanceId: runnerId, controlPlaneSourceInstanceId: `identity-${identity.runId}`,
|
||||
}, { privateKeyPem, onCommittedEvent: async event => { observed.push(event); } });
|
||||
await port.openRun({ identity, backendKind: "mock", sourceInstanceId: runnerId });
|
||||
await port.appendEvent({
|
||||
schema: "paperclip.prp.event.v1", sourceEventId: `${runnerId}:1`, sourceSeq: 1,
|
||||
sourceInstanceId: runnerId, sourceKind: "runner", runId: identity.runId,
|
||||
normalizedSessionId: identity.sessionId, eventType: "tool.execution.started", schemaVersion: 1,
|
||||
priority: 0, emittedAt: new Date().toISOString(), payload: {
|
||||
schema: "paperclip.tool.execution.v1", executionId: "identity-output", transport: "builtin",
|
||||
operation: "execute", name: "diagnostic", target: null, namespace: null, readOnly: true,
|
||||
status: "running", durationMs: null, exitCode: null, progress: null,
|
||||
output: privateKeyPem, outputBytes: Buffer.byteLength(privateKeyPem), outputTruncated: false,
|
||||
outputDigest: `sha256:${"a".repeat(64)}`,
|
||||
},
|
||||
});
|
||||
await port.checkpointSession({ backendKind: "mock", sessionId: identity.sessionId, identity,
|
||||
semanticResult: { ...CONTROL_PLANE_CONFORMANCE_RESULT, summary: privateKeyPem },
|
||||
});
|
||||
const persistedEvents = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, identity.runId));
|
||||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, identity.runId));
|
||||
for (const value of [persistedEvents, observed, run.runnerProfileJson]) {
|
||||
expect(JSON.stringify(value)).not.toContain(keyBody);
|
||||
expect(JSON.stringify(value)).toContain("***REDACTED***");
|
||||
}
|
||||
});
|
||||
|
||||
it("runs the unchanged package conformance suite against Paperclip persistence", async () => {
|
||||
const identity = CONTROL_PLANE_CONFORMANCE_OPEN.identity;
|
||||
const committedEventIds: string[] = [];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAgentIdentityRedactor } from "../agent-identity-redaction.js";
|
||||
import { and, asc, eq, gt, or } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
@@ -71,6 +72,7 @@ function assertTerminal(value: unknown): asserts value is PrpTerminalState {
|
||||
/** Production implementation of the runner package's deliberately narrow persistence port. */
|
||||
export class PaperclipControlPlanePort implements ControlPlanePort {
|
||||
readonly #db: Db;
|
||||
readonly #identityRedactor: ReturnType<typeof createAgentIdentityRedactor>;
|
||||
readonly #binding: PaperclipControlPlaneBinding;
|
||||
#sessionId: string | null = null;
|
||||
readonly #onCommittedEvent?: (event: PrpEvent) => Promise<void>;
|
||||
@@ -80,11 +82,14 @@ export class PaperclipControlPlanePort implements ControlPlanePort {
|
||||
db: Db,
|
||||
binding: PaperclipControlPlaneBinding,
|
||||
options: {
|
||||
/** Runtime-only secret; never part of the persisted binding. */
|
||||
privateKeyPem?: string;
|
||||
onCommittedEvent?: (event: PrpEvent) => Promise<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 +156,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 +189,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort {
|
||||
if (!isPrpEvent(value)) throw new Error("native_legacy_event_not_supported");
|
||||
const validated = validatePrpEvent(value);
|
||||
if (!validated.ok) throw new Error(`native_event_schema_invalid:${validated.issues[0]?.message ?? "unknown"}`);
|
||||
const event = validated.event;
|
||||
const event = this.#identityRedactor.redact(validated.event);
|
||||
if (event.runId !== this.#binding.runId || (this.#sessionId && event.normalizedSessionId !== this.#sessionId)) {
|
||||
throw new Error("native_event_binding_mismatch");
|
||||
}
|
||||
@@ -264,6 +270,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort {
|
||||
}
|
||||
|
||||
async completeRun(value: NativeRunResult | CompleteControlPlaneRunInput): Promise<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,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user