mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - The Claude-local adapter uses `claude --resume <session-id>` to continue prior sessions; the `--resume` value MUST be a UUID per Claude's CLI contract. > - Paperclip internally uses session IDs prefixed with `ses_` (not UUIDs); these get passed straight through to `--resume` and crash the run. > - On top of the crash, when the underlying error path triggers a secret-decryption failure or heartbeat setup failure, the diagnostics are too thin to tell key-mismatch from other failures, and the heartbeat error code is mis-classified as `adapter_failed` instead of `setup_failed`. > - This PR validates `runtimeSessionId` against a UUID regex before letting `canResumeSession` become true, adds `not a valid UUID` to Claude's own retry-error regex, improves AES-256-GCM decryption diagnostics in the local encrypted provider, and re-classifies pre-adapter setup failures. > - The benefit is that Paperclip session IDs are detected and skipped gracefully (logged, no crash), legitimate Claude UUID-rejection errors are treated as retriable, and operators can diagnose decryption/setup failures from the run log. ## Linked Issues or Issue Description **What happened?** The `claude-local` adapter passes Paperclip's internal session identifiers (e.g. `ses_…`) straight to `claude --resume <session-id>`. Because Claude's CLI requires the `--resume` argument to be a UUID, the run crashes with a `not a valid UUID` error. When the surrounding code path also hits a secret-decryption failure, the heartbeat reports it as `adapter_failed`, hiding the real `setup_failed` cause and making diagnosis hard. **Expected behavior** Non-UUID session IDs should be detected before `--resume` is called, the run should fall back to a fresh session with a clear log line, and any decryption / setup failure should be reported with enough detail (and the correct error code) for an operator to tell what failed. **Steps to reproduce** 1. Have a persisted task session whose ID is not a UUID (Paperclip-issued `ses_…` form). 2. Trigger a heartbeat that resumes that session via the `claude-local` adapter. 3. Observe: the adapter crashes with a UUID-validation error; if the path also involves a decryption failure, the heartbeat surfaces `adapter_failed` instead of `setup_failed`. ## What Changed - `packages/adapters/claude-local/src/server/execute.ts`: Validates `runtimeSessionId` against a UUID regex before setting `canResumeSession`; non-UUID IDs are logged and skipped gracefully. Guards the cwd-mismatch log block on `isValidUuid` so it does not fire for non-UUID session IDs. - `packages/adapters/claude-local/src/server/parse.ts`: Adds `not a valid UUID` to the session-error retry regex so Claude's own UUID rejection is treated as a retriable error. - `server/src/services/secrets/local-encrypted-provider.ts`: Wraps AES-256-GCM decryption in try/catch and re-throws with a key fingerprint hint to aid key-mismatch diagnosis. - `server/src/services/heartbeat.ts`: Corrects the outer-catch `errorCode` from `adapter_failed` to `setup_failed` for pre-adapter setup failures. - `AGENTS.md`: Adds task/PR/CI governance sections (10–13) and expands the Definition of Done. ## Verification - `pnpm --filter @paperclipai/adapter-claude-local test` covers UUID validation and the parse retry regex. - `pnpm --filter @paperclipai/server test src/services/secrets` covers decryption diagnostics. - `pnpm --filter @paperclipai/server typecheck` ## Risks Low. UUID validation is strictly additive (non-UUIDs that previously crashed now log and skip). Decryption diagnostics only fire on failure paths. The `setup_failed` error code change is a clearer classification, not a behavior change. ## Model Used Claude (Opus 4.6) — used to identify the UUID-validation root cause, mirror existing parse patterns, and re-classify the heartbeat setup error code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots (N/A — no UI changes) - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green (in progress) - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: CTO Agent <cto@paperclip.ing> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Devin Foley <devin@paperclip.ing>
285 lines
9.0 KiB
TypeScript
285 lines
9.0 KiB
TypeScript
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { resolveDefaultSecretsKeyFilePath } from "../home-paths.js";
|
|
import type {
|
|
PreparedSecretVersion,
|
|
SecretProviderHealthCheck,
|
|
SecretProviderModule,
|
|
SecretProviderValidationResult,
|
|
StoredSecretVersionMaterial,
|
|
} from "./types.js";
|
|
import { badRequest } from "../errors.js";
|
|
|
|
interface LocalEncryptedMaterial extends StoredSecretVersionMaterial {
|
|
scheme: "local_encrypted_v1";
|
|
iv: string;
|
|
tag: string;
|
|
ciphertext: string;
|
|
}
|
|
|
|
function resolveMasterKeyFilePath() {
|
|
const fromEnv = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
|
if (fromEnv && fromEnv.trim().length > 0) return path.resolve(fromEnv.trim());
|
|
return resolveDefaultSecretsKeyFilePath();
|
|
}
|
|
|
|
function decodeMasterKey(raw: string): Buffer | null {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) return null;
|
|
|
|
if (/^[A-Fa-f0-9]{64}$/.test(trimmed)) {
|
|
return Buffer.from(trimmed, "hex");
|
|
}
|
|
|
|
try {
|
|
const decoded = Buffer.from(trimmed, "base64");
|
|
if (decoded.length === 32) return decoded;
|
|
} catch {
|
|
// ignored
|
|
}
|
|
|
|
if (Buffer.byteLength(trimmed, "utf8") === 32) {
|
|
return Buffer.from(trimmed, "utf8");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function loadOrCreateMasterKey(): Buffer {
|
|
const envKeyRaw = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
|
if (envKeyRaw && envKeyRaw.trim().length > 0) {
|
|
const fromEnv = decodeMasterKey(envKeyRaw);
|
|
if (!fromEnv) {
|
|
throw badRequest(
|
|
"Invalid PAPERCLIP_SECRETS_MASTER_KEY (expected 32-byte base64, 64-char hex, or raw 32-char string)",
|
|
);
|
|
}
|
|
return fromEnv;
|
|
}
|
|
|
|
const keyPath = resolveMasterKeyFilePath();
|
|
if (existsSync(keyPath)) {
|
|
enforceKeyFilePermissionsBestEffort(keyPath);
|
|
const raw = readFileSync(keyPath, "utf8");
|
|
const decoded = decodeMasterKey(raw);
|
|
if (!decoded) {
|
|
throw badRequest(`Invalid secrets master key at ${keyPath}`);
|
|
}
|
|
return decoded;
|
|
}
|
|
|
|
const dir = path.dirname(keyPath);
|
|
mkdirSync(dir, { recursive: true });
|
|
const generated = randomBytes(32);
|
|
writeFileSync(keyPath, generated.toString("base64"), { encoding: "utf8", mode: 0o600 });
|
|
try {
|
|
chmodSync(keyPath, 0o600);
|
|
} catch {
|
|
// best effort
|
|
}
|
|
return generated;
|
|
}
|
|
|
|
function enforceKeyFilePermissionsBestEffort(keyPath: string) {
|
|
try {
|
|
const mode = statSync(keyPath).mode & 0o777;
|
|
if ((mode & 0o077) !== 0) {
|
|
chmodSync(keyPath, 0o600);
|
|
}
|
|
} catch {
|
|
// best effort only; health checks surface persistent permission problems.
|
|
}
|
|
}
|
|
|
|
function sha256Hex(value: string): string {
|
|
return createHash("sha256").update(value).digest("hex");
|
|
}
|
|
|
|
function prepareManagedVersion(value: string): PreparedSecretVersion {
|
|
const masterKey = loadOrCreateMasterKey();
|
|
const valueSha256 = sha256Hex(value);
|
|
return {
|
|
material: encryptValue(masterKey, value),
|
|
valueSha256,
|
|
fingerprintSha256: valueSha256,
|
|
externalRef: null,
|
|
};
|
|
}
|
|
|
|
async function inspectLocalEncryptedHealth(): Promise<SecretProviderHealthCheck> {
|
|
const envKeyRaw = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
|
if (envKeyRaw && envKeyRaw.trim().length > 0) {
|
|
if (!decodeMasterKey(envKeyRaw)) {
|
|
return {
|
|
provider: "local_encrypted",
|
|
status: "error",
|
|
message:
|
|
"PAPERCLIP_SECRETS_MASTER_KEY is invalid; expected 32-byte base64, 64-char hex, or raw 32-char string",
|
|
};
|
|
}
|
|
return {
|
|
provider: "local_encrypted",
|
|
status: "ok",
|
|
message: "Local encrypted provider is using PAPERCLIP_SECRETS_MASTER_KEY",
|
|
backupGuidance: [
|
|
"Back up the configured master key separately from the database.",
|
|
"A restore needs both the database metadata and the same master key.",
|
|
],
|
|
details: { keySource: "env" },
|
|
};
|
|
}
|
|
|
|
const keyPath = resolveMasterKeyFilePath();
|
|
if (!existsSync(keyPath)) {
|
|
return {
|
|
provider: "local_encrypted",
|
|
status: "warn",
|
|
message: `Secrets key file does not exist yet: ${keyPath}`,
|
|
warnings: ["The first managed secret write will create this key file with 0600 permissions."],
|
|
backupGuidance: [
|
|
"Back up the key file together with database backups.",
|
|
"The database alone cannot restore local encrypted secret values.",
|
|
],
|
|
details: { keySource: "file", keyFilePath: keyPath },
|
|
};
|
|
}
|
|
|
|
let mode: number | null = null;
|
|
try {
|
|
mode = statSync(keyPath).mode & 0o777;
|
|
} catch (err) {
|
|
return {
|
|
provider: "local_encrypted",
|
|
status: "error",
|
|
message: `Could not stat secrets key file: ${err instanceof Error ? err.message : String(err)}`,
|
|
details: { keySource: "file", keyFilePath: keyPath },
|
|
};
|
|
}
|
|
|
|
try {
|
|
const raw = readFileSync(keyPath, "utf8");
|
|
if (!decodeMasterKey(raw)) {
|
|
return {
|
|
provider: "local_encrypted",
|
|
status: "error",
|
|
message: `Invalid key material in ${keyPath}`,
|
|
details: { keySource: "file", keyFilePath: keyPath },
|
|
};
|
|
}
|
|
} catch (err) {
|
|
return {
|
|
provider: "local_encrypted",
|
|
status: "error",
|
|
message: `Could not read secrets key file: ${err instanceof Error ? err.message : String(err)}`,
|
|
details: { keySource: "file", keyFilePath: keyPath },
|
|
};
|
|
}
|
|
|
|
const warnings =
|
|
mode !== null && (mode & 0o077) !== 0
|
|
? [`Secrets key file permissions are ${mode.toString(8)}; run chmod 600 ${keyPath}`]
|
|
: [];
|
|
return {
|
|
provider: "local_encrypted",
|
|
status: warnings.length > 0 ? "warn" : "ok",
|
|
message: `Local encrypted provider configured with key file ${keyPath}`,
|
|
warnings,
|
|
backupGuidance: [
|
|
"Back up the key file together with database backups.",
|
|
"The database alone cannot restore local encrypted secret values.",
|
|
],
|
|
details: { keySource: "file", keyFilePath: keyPath },
|
|
};
|
|
}
|
|
|
|
function encryptValue(masterKey: Buffer, value: string): LocalEncryptedMaterial {
|
|
const iv = randomBytes(12);
|
|
const cipher = createCipheriv("aes-256-gcm", masterKey, iv);
|
|
const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
const tag = cipher.getAuthTag();
|
|
return {
|
|
scheme: "local_encrypted_v1",
|
|
iv: iv.toString("base64"),
|
|
tag: tag.toString("base64"),
|
|
ciphertext: ciphertext.toString("base64"),
|
|
};
|
|
}
|
|
|
|
function decryptValue(masterKey: Buffer, material: LocalEncryptedMaterial): string {
|
|
const iv = Buffer.from(material.iv, "base64");
|
|
const tag = Buffer.from(material.tag, "base64");
|
|
const ciphertext = Buffer.from(material.ciphertext, "base64");
|
|
const decipher = createDecipheriv("aes-256-gcm", masterKey, iv);
|
|
decipher.setAuthTag(tag);
|
|
try {
|
|
const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
return plain.toString("utf8");
|
|
} catch (err) {
|
|
const keyFingerprint = createHash("sha256").update(masterKey).digest("hex").slice(0, 12);
|
|
throw new Error(
|
|
`Secret decryption failed (master key fingerprint: ${keyFingerprint}). ` +
|
|
`This usually means the master key does not match the key used to encrypt the secret. ` +
|
|
`Original error: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function asLocalEncryptedMaterial(value: StoredSecretVersionMaterial): LocalEncryptedMaterial {
|
|
if (
|
|
value &&
|
|
typeof value === "object" &&
|
|
value.scheme === "local_encrypted_v1" &&
|
|
typeof value.iv === "string" &&
|
|
typeof value.tag === "string" &&
|
|
typeof value.ciphertext === "string"
|
|
) {
|
|
return value as LocalEncryptedMaterial;
|
|
}
|
|
throw badRequest("Invalid local_encrypted secret material");
|
|
}
|
|
|
|
export const localEncryptedProvider: SecretProviderModule = {
|
|
id: "local_encrypted",
|
|
descriptor() {
|
|
return {
|
|
id: "local_encrypted",
|
|
label: "Local encrypted (default)",
|
|
requiresExternalRef: false,
|
|
supportsManagedValues: true,
|
|
supportsExternalReferences: false,
|
|
configured: true,
|
|
};
|
|
},
|
|
async validateConfig(input): Promise<SecretProviderValidationResult> {
|
|
const warnings: string[] = [];
|
|
if (input?.deploymentMode === "authenticated" && input.strictMode !== true) {
|
|
warnings.push("Strict secret mode should be enabled for authenticated deployments");
|
|
}
|
|
const health = await inspectLocalEncryptedHealth();
|
|
if (health.status === "error") {
|
|
throw badRequest(health.message);
|
|
}
|
|
warnings.push(...(health.warnings ?? []));
|
|
return { ok: true, warnings };
|
|
},
|
|
async createSecret(input) {
|
|
return prepareManagedVersion(input.value);
|
|
},
|
|
async createVersion(input) {
|
|
return prepareManagedVersion(input.value);
|
|
},
|
|
async linkExternalSecret() {
|
|
throw badRequest("local_encrypted does not support external reference secrets");
|
|
},
|
|
async resolveVersion(input) {
|
|
const masterKey = loadOrCreateMasterKey();
|
|
return decryptValue(masterKey, asLocalEncryptedMaterial(input.material));
|
|
},
|
|
async deleteOrArchive() {
|
|
// Secret metadata deletion is handled in Paperclip DB; the local key is shared and must remain.
|
|
},
|
|
async healthCheck() {
|
|
return inspectLocalEncryptedHealth();
|
|
},
|
|
};
|