From 85c2d2d491c96fe81074ec4d7f2ed12067ae6fed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 2 Sep 2026 03:39:10 -0400 Subject: [PATCH] fix(node): retry rejected prev_chain_hash within a bounded submit budget; release 2.0.1 Claude-Session: https://claude.ai/code/session_01QoUjdc7HGKTsgj5GhG8HvF --- package-lock.json | 4 +- package.json | 2 +- src/plugins/hook-template.ts | 121 ++++++++++++++++++------------- test/claudecode-runtime.test.mjs | 113 +++++++++++++++++++++++++++++ 4 files changed, 186 insertions(+), 54 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1b48c88..a529168 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@elydora/sdk", - "version": "2.0.0", + "version": "2.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@elydora/sdk", - "version": "2.0.0", + "version": "2.0.1", "license": "MIT", "dependencies": { "@decimalturn/toml-patch": "^2.0.0", diff --git a/package.json b/package.json index c5a17e7..b624f41 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@elydora/sdk", - "version": "2.0.0", + "version": "2.0.1", "description": "Node.js/TypeScript SDK for the Elydora tamper-evident audit platform", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/plugins/hook-template.ts b/src/plugins/hook-template.ts index 42b5b45..ad0236d 100644 --- a/src/plugins/hook-template.ts +++ b/src/plugins/hook-template.ts @@ -38,6 +38,8 @@ const KEY_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'private.key'); const CHAIN_STATE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'chain-state.json'); const ERROR_LOG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'error.log'); const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const MAX_CHAIN_ATTEMPTS = 5; +const SUBMIT_BUDGET_MS = 6000; ${PROTECTED_RUNTIME_READER} @@ -219,27 +221,43 @@ async function readHookInput() { return value; } -async function submitOperation(hookData, runtime) { - const toolEvent = hookData.tool_result && typeof hookData.tool_result === 'object' - ? hookData.tool_result - : hookData.tool_call && typeof hookData.tool_call === 'object' - ? hookData.tool_call - : {}; - const toolName = hookData.tool_name || hookData.toolName || hookData.name || - toolEvent.name || 'unknown'; - const toolInput = hookData.tool_input || hookData.toolInput || hookData.input || - hookData.parameters || toolEvent.input || {}; - const sessionId = hookData.conversation_id || hookData.session_id || hookData.sessionId || - hookData.session || hookData.taskId || 'unknown'; - const previousHash = readChainState(); +async function postOperation(operation, runtime, deadline) { + const remaining = deadline - performance.now(); + if (remaining <= 0) throw new Error('Audit API retry budget exhausted'); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), Math.min(5000, remaining)); + try { + const headers = { 'Content-Type': 'application/json', Accept: 'application/json' }; + if (runtime.config.token) headers.Authorization = 'Bearer ' + runtime.config.token; + const response = await fetch(runtime.baseUrl + '/v1/operations', { + method: 'POST', + headers, + body: JSON.stringify(operation), + signal: controller.signal, + }); + if (response.ok) return { accepted: true }; + const responseBody = await response.text(); + if (response.status === 400 && responseBody) { + let failure; + try { failure = JSON.parse(responseBody); } catch (error) { + throw new Error('Audit API returned invalid error JSON: ' + error.message); + } + if (failure.error && failure.error.code === 'PREV_HASH_MISMATCH') { + const match = String(failure.error.message || '').match( + /Expected prev_chain_hash "([A-Za-z0-9_-]{43})"/, + ); + if (match) return { accepted: false, expected: match[1] }; + } + } + throw new Error('Audit API returned HTTP ' + response.status); + } finally { + clearTimeout(timeout); + } +} + +function buildOperation(runtime, payload, payloadHash, toolName, sessionId, previousHash) { const operationId = uuidv7(); const issuedAt = Date.now(); - const payload = NATIVE_PAYLOAD ? hookData : { - tool_name: toolName, - tool_input: toolInput, - session_id: sessionId, - }; - const payloadHash = computePayloadHash(payload); const chainHash = computeChainHash(previousHash, payloadHash, operationId, issuedAt); const unsigned = { op_version: '1.0', @@ -261,41 +279,42 @@ async function submitOperation(hookData, runtime) { runtime.privateKey, Buffer.from(jcsCanonicalise(unsigned), 'utf-8'), ); - const operation = { ...unsigned, chain_hash: chainHash, signature }; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); - try { - const headers = { 'Content-Type': 'application/json', Accept: 'application/json' }; - if (runtime.config.token) headers.Authorization = 'Bearer ' + runtime.config.token; - const response = await fetch(runtime.baseUrl + '/v1/operations', { - method: 'POST', - headers, - body: JSON.stringify(operation), - signal: controller.signal, - }); - if (response.ok) { - writeChainState(chainHash); + return { operation: { ...unsigned, chain_hash: chainHash, signature }, chainHash }; +} + +async function submitOperation(hookData, runtime) { + const toolEvent = hookData.tool_result && typeof hookData.tool_result === 'object' + ? hookData.tool_result + : hookData.tool_call && typeof hookData.tool_call === 'object' + ? hookData.tool_call + : {}; + const toolName = hookData.tool_name || hookData.toolName || hookData.name || + toolEvent.name || 'unknown'; + const toolInput = hookData.tool_input || hookData.toolInput || hookData.input || + hookData.parameters || toolEvent.input || {}; + const sessionId = hookData.conversation_id || hookData.session_id || hookData.sessionId || + hookData.session || hookData.taskId || 'unknown'; + const payload = NATIVE_PAYLOAD ? hookData : { + tool_name: toolName, + tool_input: toolInput, + session_id: sessionId, + }; + const payloadHash = computePayloadHash(payload); + const deadline = performance.now() + SUBMIT_BUDGET_MS; + let previousHash = readChainState(); + for (let attempt = 1; ; attempt += 1) { + const built = buildOperation(runtime, payload, payloadHash, toolName, sessionId, previousHash); + const result = await postOperation(built.operation, runtime, deadline); + if (result.accepted) { + writeChainState(built.chainHash); return; } - const responseBody = await response.text(); - if (response.status === 400 && responseBody) { - let failure; - try { failure = JSON.parse(responseBody); } catch (error) { - throw new Error('Audit API returned invalid error JSON: ' + error.message); - } - if (failure.error && failure.error.code === 'PREV_HASH_MISMATCH') { - const match = String(failure.error.message || '').match( - /Expected prev_chain_hash "([A-Za-z0-9_-]{43})"/, - ); - if (match) { - writeChainState(match[1]); - logError(new Error('Chain hash resynced to server: ' + match[1])); - } - } + writeChainState(result.expected); + logError(new Error('Chain hash resynced to server: ' + result.expected)); + if (attempt >= MAX_CHAIN_ATTEMPTS) { + throw new Error('Audit API rejected prev_chain_hash ' + attempt + ' times'); } - throw new Error('Audit API returned HTTP ' + response.status); - } finally { - clearTimeout(timeout); + previousHash = result.expected; } } diff --git a/test/claudecode-runtime.test.mjs b/test/claudecode-runtime.test.mjs index c7e8334..96944b5 100644 --- a/test/claudecode-runtime.test.mjs +++ b/test/claudecode-runtime.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; +import http from 'node:http'; import path from 'node:path'; import test from 'node:test'; import { @@ -143,3 +144,115 @@ test('Claude runtime failures stay observable while guard and audit delivery rem await fixture.close(); } }); + +function mismatchServer(respond) { + const submissions = []; + const server = http.createServer((request, response) => { + let raw = ''; + request.on('data', (chunk) => { raw += chunk; }); + request.on('end', () => { + if (request.url !== '/v1/operations') { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ agent: { status: 'active' } })); + return; + } + submissions.push(JSON.parse(raw)); + const reply = respond(submissions) ?? mismatch(String(submissions.length).padStart(43, 'A'), 'x'); + const send = () => { + response.writeHead(reply.status, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify(reply.body)); + }; + if (api.slow) setTimeout(send, 2600); else send(); + }); + }); + const api = { submissions, slow: false }; + return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(Object.assign(api, { + baseUrl: 'http://127.0.0.1:' + server.address().port, + close: () => new Promise((done) => { server.closeAllConnections?.(); server.close(done); }), + })))); +} + +function mismatch(expected, actual) { + return { + status: 400, + body: { error: { code: 'PREV_HASH_MISMATCH', message: 'Expected prev_chain_hash "' + expected + '", got "' + actual + '".' } }, + }; +} + +test('Claude audit retries a rejected prev_chain_hash with the server value', async () => { + const expected = 'Rxlf4j36C3KvIQ3hWuOkX698BR5iDypUFuB70JjEuvM'; + const api = await mismatchServer((submissions) => (submissions.length === 1 + ? mismatch(expected, submissions[0].prev_chain_hash) + : { status: 202, body: { receipt: { seq_no: 2 } } })); + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + assert.equal((await fixture.install()).code, 0); + const { settings } = await readSettings(fixture.settingsPath); + const audit = managedHandler(settings, 'PostToolUse'); + const result = await runClaudeHook( + audit, + JSON.stringify(officialPayload('PostToolUse', { tool_response: { stdout: 'ok' } })), + fixture, + ); + assert.equal(result.code, 0, result.stderr); + assert.equal(api.submissions.length, 2); + assert.equal(api.submissions[1].prev_chain_hash, expected); + assert.notEqual(api.submissions[1].operation_id, api.submissions[0].operation_id); + assert.notEqual(api.submissions[1].nonce, api.submissions[0].nonce); + const state = JSON.parse(await readFile(path.join(fixture.agentDir, 'chain-state.json'), 'utf-8')); + assert.equal(state.prev_chain_hash, api.submissions[1].chain_hash); + assert.match(await readFile(path.join(fixture.agentDir, 'error.log'), 'utf-8'), /resynced to server: Rxlf/); + } finally { + await fixture.close(); + await api.close(); + } +}); + +test('Claude audit stops after five rejected prev_chain_hash attempts', async () => { + const api = await mismatchServer((submissions) => ( + mismatch(String(submissions.length).padStart(43, 'A'), submissions.at(-1).prev_chain_hash) + )); + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + assert.equal((await fixture.install()).code, 0); + const { settings } = await readSettings(fixture.settingsPath); + const audit = managedHandler(settings, 'PostToolUse'); + const result = await runClaudeHook( + audit, + JSON.stringify(officialPayload('PostToolUse', { tool_response: { stdout: 'ok' } })), + fixture, + ); + assert.equal(result.code, 0, result.stderr); + assert.equal(api.submissions.length, 5); + assert.equal(api.submissions[4].prev_chain_hash, '4'.padStart(43, 'A')); + assert.match(await readFile(path.join(fixture.agentDir, 'error.log'), 'utf-8'), /rejected prev_chain_hash 5 times/); + } finally { + await fixture.close(); + await api.close(); + } +}); + +test('Claude audit stops retrying when the submit budget is spent', async () => { + const api = await mismatchServer(() => null); + api.slow = true; + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + assert.equal((await fixture.install()).code, 0); + const { settings } = await readSettings(fixture.settingsPath); + const audit = managedHandler(settings, 'PostToolUse'); + const started = Date.now(); + const result = await runClaudeHook( + audit, + JSON.stringify(officialPayload('PostToolUse', { tool_response: { stdout: 'ok' } })), + fixture, + ); + const elapsed = Date.now() - started; + assert.equal(result.code, 0, result.stderr); + assert.ok(elapsed < 7000, `hook ran ${elapsed}ms`); + assert.ok(api.submissions.length >= 2 && api.submissions.length <= 3, `${api.submissions.length} submissions`); + assert.match(await readFile(path.join(fixture.agentDir, 'error.log'), 'utf-8'), /aborted|retry budget exhausted/i); + } finally { + await fixture.close(); + await api.close(); + } +});