fix(node): retry rejected prev_chain_hash within a bounded submit budget; release 2.0.1

Claude-Session: https://claude.ai/code/session_01QoUjdc7HGKTsgj5GhG8HvF
This commit is contained in:
雷电芽衣 committed 2026-09-02 03:39:10 -04:00
1 parent f371d8fc89
commit 85c2d2d491
4 files changed
+186 -54

No files matched your search

+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+70 -51
View File
@@ -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;
}
}
+113
View File
@@ -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();
}
});