fix: validate session ID as UUID before --resume + error diagnostics (DLD-889) (#1742)
## 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>
This commit is contained in:
@@ -229,12 +229,12 @@ describe("claude remote execution", () => {
|
|||||||
adapterConfig: {},
|
adapterConfig: {},
|
||||||
},
|
},
|
||||||
runtime: {
|
runtime: {
|
||||||
sessionId: "session-123",
|
sessionId: "12345678-1234-4abc-9def-123456789012",
|
||||||
sessionParams: {
|
sessionParams: {
|
||||||
sessionId: "session-123",
|
sessionId: "12345678-1234-4abc-9def-123456789012",
|
||||||
cwd: "/remote/workspace",
|
cwd: "/remote/workspace",
|
||||||
},
|
},
|
||||||
sessionDisplayId: "session-123",
|
sessionDisplayId: "12345678-1234-4abc-9def-123456789012",
|
||||||
taskKey: null,
|
taskKey: null,
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
@@ -283,9 +283,9 @@ describe("claude remote execution", () => {
|
|||||||
adapterConfig: {},
|
adapterConfig: {},
|
||||||
},
|
},
|
||||||
runtime: {
|
runtime: {
|
||||||
sessionId: "session-123",
|
sessionId: "12345678-1234-4abc-9def-123456789012",
|
||||||
sessionParams: {
|
sessionParams: {
|
||||||
sessionId: "session-123",
|
sessionId: "12345678-1234-4abc-9def-123456789012",
|
||||||
cwd: managedRemoteWorkspace,
|
cwd: managedRemoteWorkspace,
|
||||||
remoteExecution: {
|
remoteExecution: {
|
||||||
transport: "ssh",
|
transport: "ssh",
|
||||||
@@ -295,7 +295,7 @@ describe("claude remote execution", () => {
|
|||||||
remoteCwd: managedRemoteWorkspace,
|
remoteCwd: managedRemoteWorkspace,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
sessionDisplayId: "session-123",
|
sessionDisplayId: "12345678-1234-4abc-9def-123456789012",
|
||||||
taskKey: null,
|
taskKey: null,
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
@@ -325,7 +325,7 @@ describe("claude remote execution", () => {
|
|||||||
expect(runChildProcess).toHaveBeenCalledTimes(1);
|
expect(runChildProcess).toHaveBeenCalledTimes(1);
|
||||||
const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined;
|
const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined;
|
||||||
expect(call?.[2]).toContain("--resume");
|
expect(call?.[2]).toContain("--resume");
|
||||||
expect(call?.[2]).toContain("session-123");
|
expect(call?.[2]).toContain("12345678-1234-4abc-9def-123456789012");
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -598,8 +598,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
const runtimePromptBundleKey = asString(runtimeSessionParams.promptBundleKey, "");
|
const runtimePromptBundleKey = asString(runtimeSessionParams.promptBundleKey, "");
|
||||||
const hasMatchingPromptBundle =
|
const hasMatchingPromptBundle =
|
||||||
runtimePromptBundleKey.length === 0 || runtimePromptBundleKey === promptBundle.bundleKey;
|
runtimePromptBundleKey.length === 0 || runtimePromptBundleKey === promptBundle.bundleKey;
|
||||||
|
const isValidUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(runtimeSessionId);
|
||||||
const canResumeSession =
|
const canResumeSession =
|
||||||
runtimeSessionId.length > 0 &&
|
runtimeSessionId.length > 0 &&
|
||||||
|
isValidUuid &&
|
||||||
hasMatchingPromptBundle &&
|
hasMatchingPromptBundle &&
|
||||||
claudeSessionCwdMatchesExecutionTarget({
|
claudeSessionCwdMatchesExecutionTarget({
|
||||||
runtimeSessionCwd,
|
runtimeSessionCwd,
|
||||||
@@ -608,9 +610,16 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
}) &&
|
}) &&
|
||||||
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget);
|
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget);
|
||||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||||
|
if (runtimeSessionId && !isValidUuid) {
|
||||||
|
await onLog(
|
||||||
|
"stdout",
|
||||||
|
`[paperclip] Claude session "${runtimeSessionId}" is not a valid UUID and will not be passed to --resume.\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
executionTargetIsRemote &&
|
executionTargetIsRemote &&
|
||||||
runtimeSessionId &&
|
runtimeSessionId &&
|
||||||
|
isValidUuid &&
|
||||||
!canResumeSession
|
!canResumeSession
|
||||||
) {
|
) {
|
||||||
await onLog(
|
await onLog(
|
||||||
@@ -619,6 +628,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
);
|
);
|
||||||
} else if (
|
} else if (
|
||||||
runtimeSessionId &&
|
runtimeSessionId &&
|
||||||
|
isValidUuid &&
|
||||||
runtimeSessionCwd.length > 0 &&
|
runtimeSessionCwd.length > 0 &&
|
||||||
path.resolve(runtimeSessionCwd) !== path.resolve(effectiveExecutionCwd)
|
path.resolve(runtimeSessionCwd) !== path.resolve(effectiveExecutionCwd)
|
||||||
) {
|
) {
|
||||||
@@ -626,7 +636,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
"stdout",
|
"stdout",
|
||||||
`[paperclip] Claude session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`,
|
`[paperclip] Claude session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`,
|
||||||
);
|
);
|
||||||
} else if (runtimeSessionId && !canResumeSession) {
|
} else if (runtimeSessionId && isValidUuid && !canResumeSession) {
|
||||||
await onLog(
|
await onLog(
|
||||||
"stdout",
|
"stdout",
|
||||||
`[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`,
|
`[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`,
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export function isClaudeUnknownSessionError(parsed: Record<string, unknown>): bo
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
return allMessages.some((msg) =>
|
return allMessages.some((msg) =>
|
||||||
/no conversation found with session id|unknown session|session .* not found/i.test(msg),
|
/no conversation found with session id|unknown session|session .* not found|not a valid UUID/i.test(msg),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,9 +66,9 @@ const payload = {
|
|||||||
if (capturePath) {
|
if (capturePath) {
|
||||||
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
|
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
|
||||||
}
|
}
|
||||||
console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-1", model: "claude-sonnet" }));
|
console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "11111111-1111-4111-8111-111111111111", model: "claude-sonnet" }));
|
||||||
console.log(JSON.stringify({ type: "assistant", session_id: "claude-session-1", message: { content: [{ type: "text", text: "hello" }] } }));
|
console.log(JSON.stringify({ type: "assistant", session_id: "11111111-1111-4111-8111-111111111111", message: { content: [{ type: "text", text: "hello" }] } }));
|
||||||
console.log(JSON.stringify({ type: "result", session_id: "claude-session-1", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } }));
|
console.log(JSON.stringify({ type: "result", session_id: "11111111-1111-4111-8111-111111111111", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } }));
|
||||||
`;
|
`;
|
||||||
await fs.writeFile(commandPath, script, "utf8");
|
await fs.writeFile(commandPath, script, "utf8");
|
||||||
await fs.chmod(commandPath, 0o755);
|
await fs.chmod(commandPath, 0o755);
|
||||||
@@ -112,15 +112,15 @@ if (shouldFailResume) {
|
|||||||
console.log(JSON.stringify({
|
console.log(JSON.stringify({
|
||||||
type: "result",
|
type: "result",
|
||||||
subtype: "success",
|
subtype: "success",
|
||||||
session_id: "claude-session-poisoned",
|
session_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
is_error: true,
|
is_error: true,
|
||||||
result: "API Error: 400 diagnostics.previous_message_id: must be the \`id\` from a prior /v1/messages response (starts with \`msg_\`)",
|
result: "API Error: 400 diagnostics.previous_message_id: must be the \`id\` from a prior /v1/messages response (starts with \`msg_\`)",
|
||||||
}));
|
}));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-new", model: "claude-sonnet" }));
|
console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", model: "claude-sonnet" }));
|
||||||
console.log(JSON.stringify({ type: "assistant", session_id: "claude-session-new", message: { content: [{ type: "text", text: "hello" }] } }));
|
console.log(JSON.stringify({ type: "assistant", session_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", message: { content: [{ type: "text", text: "hello" }] } }));
|
||||||
console.log(JSON.stringify({ type: "result", session_id: "claude-session-new", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } }));
|
console.log(JSON.stringify({ type: "result", session_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } }));
|
||||||
`;
|
`;
|
||||||
await fs.writeFile(commandPath, script, "utf8");
|
await fs.writeFile(commandPath, script, "utf8");
|
||||||
await fs.chmod(commandPath, 0o755);
|
await fs.chmod(commandPath, 0o755);
|
||||||
@@ -146,7 +146,7 @@ if (capturePath) {
|
|||||||
console.log(JSON.stringify({
|
console.log(JSON.stringify({
|
||||||
type: "result",
|
type: "result",
|
||||||
subtype: "success",
|
subtype: "success",
|
||||||
session_id: "claude-session-poisoned-fresh",
|
session_id: "fffff111-0000-4000-8000-000000000003",
|
||||||
is_error: true,
|
is_error: true,
|
||||||
result: "API Error: 400 diagnostics.previous_message_id: must be the \`id\` from a prior /v1/messages response (starts with \`msg_\`)",
|
result: "API Error: 400 diagnostics.previous_message_id: must be the \`id\` from a prior /v1/messages response (starts with \`msg_\`)",
|
||||||
}));
|
}));
|
||||||
@@ -183,15 +183,15 @@ if (shouldFailResume) {
|
|||||||
console.log(JSON.stringify({
|
console.log(JSON.stringify({
|
||||||
type: "result",
|
type: "result",
|
||||||
subtype: "error",
|
subtype: "error",
|
||||||
session_id: "claude-session-1",
|
session_id: "11111111-1111-4111-8111-111111111111",
|
||||||
result: "No conversation found with session id claude-session-1",
|
result: "No conversation found with session id 11111111-1111-4111-8111-111111111111",
|
||||||
errors: ["No conversation found with session id claude-session-1"],
|
errors: ["No conversation found with session id 11111111-1111-4111-8111-111111111111"],
|
||||||
}));
|
}));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-2", model: "claude-sonnet" }));
|
console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "22222222-2222-4222-8222-222222222222", model: "claude-sonnet" }));
|
||||||
console.log(JSON.stringify({ type: "assistant", session_id: "claude-session-2", message: { content: [{ type: "text", text: "hello" }] } }));
|
console.log(JSON.stringify({ type: "assistant", session_id: "22222222-2222-4222-8222-222222222222", message: { content: [{ type: "text", text: "hello" }] } }));
|
||||||
console.log(JSON.stringify({ type: "result", session_id: "claude-session-2", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } }));
|
console.log(JSON.stringify({ type: "result", session_id: "22222222-2222-4222-8222-222222222222", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } }));
|
||||||
`;
|
`;
|
||||||
await fs.writeFile(commandPath, script, "utf8");
|
await fs.writeFile(commandPath, script, "utf8");
|
||||||
await fs.chmod(commandPath, 0o755);
|
await fs.chmod(commandPath, 0o755);
|
||||||
@@ -305,7 +305,7 @@ describe("claude execute", () => {
|
|||||||
await execute({
|
await execute({
|
||||||
runId: "run-resume",
|
runId: "run-resume",
|
||||||
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
||||||
runtime: { sessionId: "claude-session-1", sessionParams: null, sessionDisplayId: null, taskKey: null },
|
runtime: { sessionId: "11111111-1111-4111-8111-111111111111", sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||||
config: {
|
config: {
|
||||||
command: commandPath,
|
command: commandPath,
|
||||||
cwd: workspace,
|
cwd: workspace,
|
||||||
@@ -373,7 +373,7 @@ describe("claude execute", () => {
|
|||||||
await execute({
|
await execute({
|
||||||
runId: "run-notes-resume",
|
runId: "run-notes-resume",
|
||||||
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
||||||
runtime: { sessionId: "claude-session-1", sessionParams: null, sessionDisplayId: null, taskKey: null },
|
runtime: { sessionId: "11111111-1111-4111-8111-111111111111", sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||||
config: {
|
config: {
|
||||||
command: commandPath,
|
command: commandPath,
|
||||||
cwd: workspace,
|
cwd: workspace,
|
||||||
@@ -405,7 +405,7 @@ describe("claude execute", () => {
|
|||||||
const result = await execute({
|
const result = await execute({
|
||||||
runId: "run-resume-fallback",
|
runId: "run-resume-fallback",
|
||||||
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
||||||
runtime: { sessionId: "claude-session-1", sessionParams: null, sessionDisplayId: null, taskKey: null },
|
runtime: { sessionId: "11111111-1111-4111-8111-111111111111", sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||||
config: {
|
config: {
|
||||||
command: commandPath,
|
command: commandPath,
|
||||||
cwd: workspace,
|
cwd: workspace,
|
||||||
@@ -448,7 +448,7 @@ describe("claude execute", () => {
|
|||||||
expect(metaEvents).toHaveLength(2);
|
expect(metaEvents).toHaveLength(2);
|
||||||
expect(metaEvents[0]?.commandNotes).toHaveLength(0);
|
expect(metaEvents[0]?.commandNotes).toHaveLength(0);
|
||||||
expect(metaEvents[1]?.commandNotes.some((note) => note.includes("--append-system-prompt-file"))).toBe(true);
|
expect(metaEvents[1]?.commandNotes.some((note) => note.includes("--append-system-prompt-file"))).toBe(true);
|
||||||
expect(result.sessionId).toBe("claude-session-2");
|
expect(result.sessionId).toBe("22222222-2222-4222-8222-222222222222");
|
||||||
expect(result.clearSession).toBe(false);
|
expect(result.clearSession).toBe(false);
|
||||||
} finally {
|
} finally {
|
||||||
restore();
|
restore();
|
||||||
@@ -461,7 +461,7 @@ describe("claude execute", () => {
|
|||||||
const resultEvent = {
|
const resultEvent = {
|
||||||
type: "result",
|
type: "result",
|
||||||
subtype: "error_max_turns",
|
subtype: "error_max_turns",
|
||||||
session_id: "claude-session-1",
|
session_id: "11111111-1111-4111-8111-111111111111",
|
||||||
is_error: true,
|
is_error: true,
|
||||||
result: "Maximum turns reached.",
|
result: "Maximum turns reached.",
|
||||||
usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 },
|
usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 },
|
||||||
@@ -501,7 +501,7 @@ describe("claude execute", () => {
|
|||||||
const resultEvent = {
|
const resultEvent = {
|
||||||
type: "result",
|
type: "result",
|
||||||
subtype: "error",
|
subtype: "error",
|
||||||
session_id: "claude-session-1",
|
session_id: "11111111-1111-4111-8111-111111111111",
|
||||||
is_error: true,
|
is_error: true,
|
||||||
result: "Tool output said: Maximum turns reached.",
|
result: "Tool output said: Maximum turns reached.",
|
||||||
};
|
};
|
||||||
@@ -796,7 +796,7 @@ describe("claude execute", () => {
|
|||||||
expect(first.exitCode).toBe(0);
|
expect(first.exitCode).toBe(0);
|
||||||
expect(first.errorMessage).toBeNull();
|
expect(first.errorMessage).toBeNull();
|
||||||
expect(first.sessionParams).toMatchObject({
|
expect(first.sessionParams).toMatchObject({
|
||||||
sessionId: "claude-session-1",
|
sessionId: "11111111-1111-4111-8111-111111111111",
|
||||||
cwd: workspace,
|
cwd: workspace,
|
||||||
});
|
});
|
||||||
expect(typeof first.sessionParams?.promptBundleKey).toBe("string");
|
expect(typeof first.sessionParams?.promptBundleKey).toBe("string");
|
||||||
@@ -888,7 +888,7 @@ describe("claude execute", () => {
|
|||||||
expect(capture1.instructionsContents).toContain(`The above agent instructions were loaded from ${instructionsPath}.`);
|
expect(capture1.instructionsContents).toContain(`The above agent instructions were loaded from ${instructionsPath}.`);
|
||||||
expect(capture1.skillEntries).toContain("paperclip");
|
expect(capture1.skillEntries).toContain("paperclip");
|
||||||
expect(capture2.argv).toContain("--resume");
|
expect(capture2.argv).toContain("--resume");
|
||||||
expect(capture2.argv).toContain("claude-session-1");
|
expect(capture2.argv).toContain("11111111-1111-4111-8111-111111111111");
|
||||||
expect(capture2.prompt).toContain("## Paperclip Resume Delta");
|
expect(capture2.prompt).toContain("## Paperclip Resume Delta");
|
||||||
expect(capture2.prompt).not.toContain("Follow the paperclip heartbeat.");
|
expect(capture2.prompt).not.toContain("Follow the paperclip heartbeat.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1016,7 +1016,7 @@ describe("claude execute", () => {
|
|||||||
resultEvent: {
|
resultEvent: {
|
||||||
type: "result",
|
type: "result",
|
||||||
subtype: "error",
|
subtype: "error",
|
||||||
session_id: "claude-session-extra",
|
session_id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||||
is_error: true,
|
is_error: true,
|
||||||
result: "You're out of extra usage · resets 4pm (America/Chicago)",
|
result: "You're out of extra usage · resets 4pm (America/Chicago)",
|
||||||
errors: [{ type: "rate_limit_error", message: "You're out of extra usage" }],
|
errors: [{ type: "rate_limit_error", message: "You're out of extra usage" }],
|
||||||
@@ -1081,7 +1081,7 @@ describe("claude execute", () => {
|
|||||||
resultEvent: {
|
resultEvent: {
|
||||||
type: "result",
|
type: "result",
|
||||||
subtype: "error",
|
subtype: "error",
|
||||||
session_id: "claude-session-overloaded",
|
session_id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
||||||
is_error: true,
|
is_error: true,
|
||||||
result: "Overloaded",
|
result: "Overloaded",
|
||||||
errors: [{ type: "overloaded_error", message: "Overloaded_error: API is overloaded." }],
|
errors: [{ type: "overloaded_error", message: "Overloaded_error: API is overloaded." }],
|
||||||
@@ -1139,7 +1139,7 @@ describe("claude execute", () => {
|
|||||||
resultEvent: {
|
resultEvent: {
|
||||||
type: "result",
|
type: "result",
|
||||||
subtype: "error_max_turns",
|
subtype: "error_max_turns",
|
||||||
session_id: "claude-session-max-turns",
|
session_id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
|
||||||
is_error: true,
|
is_error: true,
|
||||||
result: "Maximum turns reached.",
|
result: "Maximum turns reached.",
|
||||||
},
|
},
|
||||||
@@ -1193,7 +1193,7 @@ describe("claude execute", () => {
|
|||||||
const result = await execute({
|
const result = await execute({
|
||||||
runId: "run-poisoned-msgid",
|
runId: "run-poisoned-msgid",
|
||||||
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
||||||
runtime: { sessionId: "claude-session-poisoned", sessionParams: null, sessionDisplayId: null, taskKey: null },
|
runtime: { sessionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||||
config: {
|
config: {
|
||||||
command: commandPath,
|
command: commandPath,
|
||||||
cwd: workspace,
|
cwd: workspace,
|
||||||
@@ -1214,7 +1214,7 @@ describe("claude execute", () => {
|
|||||||
expect(captured[0]?.argv).toContain("--resume");
|
expect(captured[0]?.argv).toContain("--resume");
|
||||||
expect(captured[1]?.argv).not.toContain("--resume");
|
expect(captured[1]?.argv).not.toContain("--resume");
|
||||||
// Result comes from the fresh retry
|
// Result comes from the fresh retry
|
||||||
expect(result.sessionId).toBe("claude-session-new");
|
expect(result.sessionId).toBe("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb");
|
||||||
expect(result.errorCode).toBeNull();
|
expect(result.errorCode).toBeNull();
|
||||||
// Adapter logged the fallback reason
|
// Adapter logged the fallback reason
|
||||||
expect(logs.some((l) => l.includes("poisoned message-id"))).toBe(true);
|
expect(logs.some((l) => l.includes("poisoned message-id"))).toBe(true);
|
||||||
@@ -1270,7 +1270,7 @@ describe("claude execute", () => {
|
|||||||
* Regression for RED-978: if the auto-retry after a poisoned resume *also*
|
* Regression for RED-978: if the auto-retry after a poisoned resume *also*
|
||||||
* fails with a poisoned previous_message_id, the adapter must still emit
|
* fails with a poisoned previous_message_id, the adapter must still emit
|
||||||
* clearSession=true so the next heartbeat starts from a clean transcript.
|
* clearSession=true so the next heartbeat starts from a clean transcript.
|
||||||
* Before this fix, the retry result's session_id ("claude-session-poisoned-fresh")
|
* Before this fix, the retry result's session_id ("fffff111-0000-4000-8000-000000000003")
|
||||||
* was persisted and every subsequent continuation hit the same 400 again.
|
* was persisted and every subsequent continuation hit the same 400 again.
|
||||||
*/
|
*/
|
||||||
it("forces clearSession when the recovery retry also reports a poisoned previous_message_id", async () => {
|
it("forces clearSession when the recovery retry also reports a poisoned previous_message_id", async () => {
|
||||||
@@ -1283,7 +1283,7 @@ describe("claude execute", () => {
|
|||||||
runId: "run-poisoned-retry",
|
runId: "run-poisoned-retry",
|
||||||
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
||||||
runtime: {
|
runtime: {
|
||||||
sessionId: "claude-session-already-poisoned",
|
sessionId: "aaaaaaaa-0000-4000-8000-000000000004",
|
||||||
sessionParams: null,
|
sessionParams: null,
|
||||||
sessionDisplayId: null,
|
sessionDisplayId: null,
|
||||||
taskKey: null,
|
taskKey: null,
|
||||||
|
|||||||
@@ -211,8 +211,17 @@ function decryptValue(masterKey: Buffer, material: LocalEncryptedMaterial): stri
|
|||||||
const ciphertext = Buffer.from(material.ciphertext, "base64");
|
const ciphertext = Buffer.from(material.ciphertext, "base64");
|
||||||
const decipher = createDecipheriv("aes-256-gcm", masterKey, iv);
|
const decipher = createDecipheriv("aes-256-gcm", masterKey, iv);
|
||||||
decipher.setAuthTag(tag);
|
decipher.setAuthTag(tag);
|
||||||
const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
try {
|
||||||
return plain.toString("utf8");
|
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 {
|
function asLocalEncryptedMaterial(value: StoredSecretVersionMaterial): LocalEncryptedMaterial {
|
||||||
|
|||||||
@@ -9255,11 +9255,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
const setupFailureAgent = await getAgent(run.agentId).catch(() => null);
|
const setupFailureAgent = await getAgent(run.agentId).catch(() => null);
|
||||||
await setRunStatus(runId, "failed", {
|
await setRunStatus(runId, "failed", {
|
||||||
error: message,
|
error: message,
|
||||||
errorCode: "adapter_failed",
|
errorCode: "setup_failed",
|
||||||
finishedAt: new Date(),
|
finishedAt: new Date(),
|
||||||
...(setupFailureAgent ? {
|
...(setupFailureAgent ? {
|
||||||
resultJson: mergeRunStopMetadataForAgent(setupFailureAgent, "failed", {
|
resultJson: mergeRunStopMetadataForAgent(setupFailureAgent, "failed", {
|
||||||
errorCode: "adapter_failed",
|
errorCode: "setup_failed",
|
||||||
errorMessage: message,
|
errorMessage: message,
|
||||||
}),
|
}),
|
||||||
} : {}),
|
} : {}),
|
||||||
|
|||||||
Reference in New Issue
Block a user