From e941be0c2064504498c64f6469bb42b0aa8fba85 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sun, 8 Nov 2026 20:55:54 -0300 Subject: [PATCH] fix(hooks): keep the session token out of curl's command line Every hook script posted to "http://127.0.0.1:$LICH_PORT/?token=$LICH_TOKEN", so the expanded token was an argument of curl, readable by every user on the machine through /proc//cmdline each time a hook fired. The scripts now send it as an Authorization: Bearer header fed to curl as a config line on stdin (printf is a shell builtin, so the token enters no argv; a -H argument would leak the same way). This is the shape lich adopted in lichdotdev/lich#1067, which makes the listener accept the header. A lich released before that answers 401 to a report whose token is only in the header. The scripts read the status of the first request and, on a 401 alone, send the report again with ?token=, so a card keeps its state under any lich. A 500 or a refused connection is not retried. --- CHANGELOG.md | 12 ++++ CLAUDE.md | 6 +- hooks/report-session-start.sh | 14 ++-- hooks/report-state.sh | 14 ++-- hooks/report-title.sh | 18 +++-- hooks/report-tool.sh | 14 ++-- hooks/report-touched.sh | 14 ++-- tests/contract.mjs | 10 ++- tests/hooks.test.mjs | 128 ++++++++++++++++++++++++++++------ 9 files changed, 183 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36a90d1..b81fa50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security + +- **The session token no longer shows in a command line.** Every hook script + sent its report with the token in the URL it handed `curl`, and a process's + command line can be read by every user on the machine, so the token was there + to read each time a hook fired. The scripts now send it in an + `Authorization: Bearer` header fed to `curl` through its standard input. A + lich released before it accepts that header refuses the report, and only then + is it sent again the old way, so cards keep working under any lich; the token + stays out of the command line once lich is updated. Claude Code's mods and the + opencode and omp modules never ran a process to report and are unchanged. + ## [0.20.9] - 2026-11-01 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index a8cb4b2..7393072 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,8 +87,10 @@ node --test tests/*.test.mjs [tests/hooks.test.mjs](tests/hooks.test.mjs) drives every hook script as a real subprocess — the command line taken from the registration each harness reads — against a stub HTTP server, and asserts the body it POSTs against lich's -contract fixtures: an accepted shape, no rejected one, the right endpoint and -`?token=`, the `X-Lich-Plugin` header naming the manifest's version, no +contract fixtures: an accepted shape, no rejected one, the right endpoint, the +token in an `Authorization: Bearer` header and in no process's command line +(with the `?token=` retry a lich older than the header needs), the +`X-Lich-Plugin` header naming the manifest's version, no deprecated `claude_session_id`, plus the client rules (no lich environment → no report, exit 0 on a 500 or a refused connection). [tests/opencode.test.mjs](tests/opencode.test.mjs) does the same for the diff --git a/hooks/report-session-start.sh b/hooks/report-session-start.sh index 69c01fd..2d80bac 100755 --- a/hooks/report-session-start.sh +++ b/hooks/report-session-start.sh @@ -28,12 +28,18 @@ provider_session_id=$(printf '%s' "$payload" | conversation_id) # escaping needed — same trust model as report-state.sh. body="{\"session_id\":\"${LICH_SESSION_ID}\",\"provider_session_id\":\"${provider_session_id}\",\"provider\":\"${provider}\"}" -curl -s -o /dev/null --max-time 1 \ - -X POST "http://127.0.0.1:${LICH_PORT}/session-start?token=${LICH_TOKEN}" \ +# The token rides a header fed through stdin (printf is a builtin): a query +# string or -H would put it in curl's command line, which every user on the +# machine can read. A lich older than the header answers 401, and only then is +# the report sent the old way. +url="http://127.0.0.1:${LICH_PORT}/session-start" +set -- -s -o /dev/null --max-time 1 -X POST \ -H 'Content-Type: application/json' \ -H "X-Lich-Plugin: ${plugin_version}" \ - -d "$body" \ - || true + -d "$body" +answer=$(printf 'header = "Authorization: Bearer %s"\n' "$LICH_TOKEN" | + curl "$@" -w '%{http_code}' -K - "$url") +[ "$answer" = 401 ] && curl "$@" "$url?token=${LICH_TOKEN}" # Never blocks or fails the turn. exit 0 diff --git a/hooks/report-state.sh b/hooks/report-state.sh index 14c376b..07e6467 100755 --- a/hooks/report-state.sh +++ b/hooks/report-state.sh @@ -83,12 +83,18 @@ fi provider_session_id=$(printf '%s' "$payload" | conversation_id) [ -n "$provider_session_id" ] && body="${body%\}},\"provider_session_id\":\"${provider_session_id}\"}" -curl -s -o /dev/null --max-time 1 \ - -X POST "http://127.0.0.1:${LICH_PORT}/hook?token=${LICH_TOKEN}" \ +# The token rides a header fed through stdin (printf is a builtin): a query +# string or -H would put it in curl's command line, which every user on the +# machine can read. A lich older than the header answers 401, and only then is +# the report sent the old way. +url="http://127.0.0.1:${LICH_PORT}/hook" +set -- -s -o /dev/null --max-time 1 -X POST \ -H 'Content-Type: application/json' \ -H "X-Lich-Plugin: ${plugin_version}" \ - -d "$body" \ - || true + -d "$body" +answer=$(printf 'header = "Authorization: Bearer %s"\n' "$LICH_TOKEN" | + curl "$@" -w '%{http_code}' -K - "$url") +[ "$answer" = 401 ] && curl "$@" "$url?token=${LICH_TOKEN}" # Never blocks or fails the turn. exit 0 diff --git a/hooks/report-title.sh b/hooks/report-title.sh index e7676d3..7ac9a97 100755 --- a/hooks/report-title.sh +++ b/hooks/report-title.sh @@ -77,14 +77,24 @@ body=$(jq -cn --arg sid "$LICH_SESSION_ID" --arg title "$title" \ # -f so a refusal counts as one: a title lich did not take is one worth looking # for again on the next tool call, where latching on it would wait for Stop. -if curl -sf -o /dev/null --max-time 1 \ - -X POST "http://127.0.0.1:${LICH_PORT}/session-title?token=${LICH_TOKEN}" \ +# +# The token rides a header fed through stdin (printf is a builtin): a query +# string or -H would put it in curl's command line, which every user on the +# machine can read. A lich older than the header answers 401, and only then is +# the report sent the old way. +url="http://127.0.0.1:${LICH_PORT}/session-title" +set -- -sf -o /dev/null --max-time 1 -X POST \ -H 'Content-Type: application/json' \ -H "X-Lich-Plugin: ${plugin_version}" \ -d "$body" -then - [ -n "$latch" ] && : >"$latch" 2>/dev/null +answer=$(printf 'header = "Authorization: Bearer %s"\n' "$LICH_TOKEN" | + curl "$@" -w '%{http_code}' -K - "$url") +took=$? +if [ "$answer" = 401 ]; then + curl "$@" "$url?token=${LICH_TOKEN}" + took=$? fi +[ "$took" -eq 0 ] && [ -n "$latch" ] && : >"$latch" 2>/dev/null # Never blocks or fails the turn. exit 0 diff --git a/hooks/report-tool.sh b/hooks/report-tool.sh index 013e353..fe1c62a 100755 --- a/hooks/report-tool.sh +++ b/hooks/report-tool.sh @@ -65,12 +65,18 @@ fi provider_session_id=$(printf '%s' "$payload" | conversation_id) [ -n "$provider_session_id" ] && body="${body%\}},\"provider_session_id\":\"${provider_session_id}\"}" -curl -s -o /dev/null --max-time 1 \ - -X POST "http://127.0.0.1:${LICH_PORT}/hook?token=${LICH_TOKEN}" \ +# The token rides a header fed through stdin (printf is a builtin): a query +# string or -H would put it in curl's command line, which every user on the +# machine can read. A lich older than the header answers 401, and only then is +# the report sent the old way. +url="http://127.0.0.1:${LICH_PORT}/hook" +set -- -s -o /dev/null --max-time 1 -X POST \ -H 'Content-Type: application/json' \ -H "X-Lich-Plugin: ${plugin_version}" \ - -d "$body" \ - || true + -d "$body" +answer=$(printf 'header = "Authorization: Bearer %s"\n' "$LICH_TOKEN" | + curl "$@" -w '%{http_code}' -K - "$url") +[ "$answer" = 401 ] && curl "$@" "$url?token=${LICH_TOKEN}" # Never blocks or fails the turn. exit 0 diff --git a/hooks/report-touched.sh b/hooks/report-touched.sh index 154d150..ba24099 100755 --- a/hooks/report-touched.sh +++ b/hooks/report-touched.sh @@ -8,12 +8,18 @@ # Sent as X-Lich-Plugin on every report; bumped at release (CLAUDE.md, Release). plugin_version=0.20.9 -curl -s -o /dev/null --max-time 1 \ - -X POST "http://127.0.0.1:${LICH_PORT}/session-touched?token=${LICH_TOKEN}" \ +# The token rides a header fed through stdin (printf is a builtin): a query +# string or -H would put it in curl's command line, which every user on the +# machine can read. A lich older than the header answers 401, and only then is +# the report sent the old way. +url="http://127.0.0.1:${LICH_PORT}/session-touched" +set -- -s -o /dev/null --max-time 1 -X POST \ -H 'Content-Type: application/json' \ -H "X-Lich-Plugin: ${plugin_version}" \ - -d "{\"session_id\":\"${LICH_SESSION_ID}\"}" \ - || true + -d "{\"session_id\":\"${LICH_SESSION_ID}\"}" +answer=$(printf 'header = "Authorization: Bearer %s"\n' "$LICH_TOKEN" | + curl "$@" -w '%{http_code}' -K - "$url") +[ "$answer" = 401 ] && curl "$@" "$url?token=${LICH_TOKEN}" # Never blocks or fails the turn. exit 0 diff --git a/tests/contract.mjs b/tests/contract.mjs index ac96a9f..d5ceb0d 100644 --- a/tests/contract.mjs +++ b/tests/contract.mjs @@ -195,12 +195,17 @@ export function matchingAcceptCase(endpoint, body) { const ANY_SHAPE = { '/hook': ['provider_session_id'] } export function assertContractHonoured(endpoint, request) { - assert.equal(request.method, 'POST') assert.equal( request.url, `${endpoint}?token=${TOKEN}`, `posted to ${request.url}, expected ${endpoint}?token=${TOKEN}`, ) + return assertReportHonoured(endpoint, request) +} + +/** Everything a report owes the contract except how its token travels. */ +export function assertReportHonoured(endpoint, request) { + assert.equal(request.method, 'POST') assert.match(request.headers['content-type'] ?? '', /application\/json/) assert.equal( request.headers['x-lich-plugin'], @@ -240,6 +245,7 @@ const liveStubs = new Set() */ export const settleStubs = () => Promise.all([...liveStubs].map((stub) => stub.settle())) +/** `status` is the answer to every request, or a function of the request. */ export async function startStub({ status = 204 } = {}) { const requests = [] const sockets = new Set() @@ -251,7 +257,7 @@ export async function startStub({ status = 204 } = {}) { }) req.on('end', () => { requests.push({ method: req.method, url: req.url, headers: req.headers, raw }) - res.writeHead(status).end() + res.writeHead(typeof status === 'function' ? status(req) : status).end() }) }) server.on('connection', (socket) => { diff --git a/tests/hooks.test.mjs b/tests/hooks.test.mjs index 83fca95..ba3dca5 100644 --- a/tests/hooks.test.mjs +++ b/tests/hooks.test.mjs @@ -24,6 +24,7 @@ import { STATES, TOKEN, assertContractHonoured, + assertReportHonoured, lichEnv, rejected, settleStubs, @@ -96,6 +97,15 @@ function assertHookSucceeded(result, stdout = '') { assert.equal(result.stdout, stdout, `stdout was ${JSON.stringify(result.stdout)}`) } +// A script's token rides a header and stays out of the URL: the URL is an +// argument of curl, and a process's arguments are readable by every user on the +// machine. The in-process clients keep `?token=`, which no process is handed. +function assertHookHonoured(endpoint, request) { + assert.equal(request.url, endpoint, `posted to ${request.url}, expected ${endpoint} with no token in it`) + assert.equal(request.headers.authorization, `Bearer ${TOKEN}`) + return assertReportHonoured(endpoint, request) +} + // ------------------------------------------------------------ registrations -- const TMP = mkdtempSync(path.join(tmpdir(), 'lich-plugin-test-')) @@ -609,7 +619,7 @@ for (const registration of REGISTRATIONS) { assertHookSucceeded(result, registration.stdout ?? '') assert.equal(stub.requests.length, 1, `sent ${stub.requests.length} requests, expected 1`) - const { body } = assertContractHonoured(endpoint, stub.requests[0]) + const { body } = assertHookHonoured(endpoint, stub.requests[0]) assert.equal(body.session_id, LICH_SESSION_ID) if (script === 'report-state.sh') { assert.equal(body.state, argument) @@ -643,7 +653,7 @@ test('session-start falls back to claude when the registration passes no provide stdin: stdinFor({ provider: 'claude', event: 'SessionStart' }), }) assertHookSucceeded(result) - const { body } = assertContractHonoured('/session-start', stub.requests[0]) + const { body } = assertHookHonoured('/session-start', stub.requests[0]) assert.equal(body.provider, 'claude') }) }) @@ -666,7 +676,7 @@ for (const [label, payload] of NO_REASON) { }) assertHookSucceeded(result) assert.equal(stub.requests.length, 1, 'a reason it could not build cost the report') - const { body } = assertContractHonoured('/hook', stub.requests[0]) + const { body } = assertHookHonoured('/hook', stub.requests[0]) assert.equal(body.state, 'waiting') assert.ok(!('reason' in body), `sent a reason it has no words for: ${stub.requests[0].raw}`) }) @@ -683,7 +693,7 @@ test('waiting reports the tool name as its reason without jq', async () => { stdin: stdinFor({ provider: 'codex', event: 'PermissionRequest' }), }) assertHookSucceeded(result) - const { body } = assertContractHonoured('/hook', stub.requests[0]) + const { body } = assertHookHonoured('/hook', stub.requests[0]) assert.equal(body.reason, TOOL_CALL.codex.tool_name) }) }) @@ -695,7 +705,7 @@ test('waiting sends no message-shaped reason without jq', async () => { stdin: stdinFor({ provider: 'claude', event: 'Notification' }), }) assertHookSucceeded(result) - const { body } = assertContractHonoured('/hook', stub.requests[0]) + const { body } = assertHookHonoured('/hook', stub.requests[0]) assert.equal(body.state, 'waiting') assert.ok(!('reason' in body), `escaped nothing and sent it anyway: ${stub.requests[0].raw}`) }) @@ -717,7 +727,7 @@ for (const state of ['busy', 'done', 'idle']) { await settleStubs() assert.equal(code, 0) assert.equal(stub.requests.length, 1) - assert.equal(assertContractHonoured('/hook', stub.requests[0]).body.state, state) + assert.equal(assertHookHonoured('/hook', stub.requests[0]).body.state, state) }) }) } @@ -731,7 +741,7 @@ for (const [script, argument] of [['report-state.sh', 'busy'], ['report-tool.sh' stdin: stdinFor({ provider: 'claude', event: 'PreToolUse' }), }) assertHookSucceeded(result) - const { body } = assertContractHonoured('/hook', stub.requests[0]) + const { body } = assertHookHonoured('/hook', stub.requests[0]) assert.equal(body.provider_session_id, PROVIDER_SESSION_ID.claude) }) }) @@ -744,7 +754,7 @@ for (const [script, argument] of [['report-state.sh', 'busy'], ['report-tool.sh' stdin: JSON.stringify(payload), }) assertHookSucceeded(result) - const { body } = assertContractHonoured('/hook', stub.requests[0]) + const { body } = assertHookHonoured('/hook', stub.requests[0]) assert.ok(!('provider_session_id' in body), `sent an empty conversation: ${stub.requests[0].raw}`) }) }) @@ -805,7 +815,7 @@ for (const [script, argument, lost, reports] of WITHOUT_HELPER) { assert.equal(result.stderr, `lich-plugin: hooks/conversation-id.sh missing; ${lost}\n`) assert.equal(stub.requests.length, reports) if (reports) { - const { body } = assertContractHonoured('/hook', stub.requests[0]) + const { body } = assertHookHonoured('/hook', stub.requests[0]) assert.ok(!('provider_session_id' in body), `sent a conversation it cannot read: ${stub.requests[0].raw}`) } }) @@ -821,7 +831,7 @@ test('session-start parses the payload without jq', async () => { }) assertHookSucceeded(result) assert.equal(stub.requests.length, 1) - const { body } = assertContractHonoured('/session-start', stub.requests[0]) + const { body } = assertHookHonoured('/session-start', stub.requests[0]) assert.equal(body.provider_session_id, PROVIDER_SESSION_ID.claude) }) }) @@ -893,7 +903,7 @@ for (const [provider, make] of [ }) assertHookSucceeded(result) assert.equal(stub.requests.length, 1) - const { body } = assertContractHonoured('/session-title', stub.requests[0]) + const { body } = assertHookHonoured('/session-title', stub.requests[0]) assert.equal([...body.title].length, 80, 'title is not 80 characters') assert.equal(body.title, [...long].slice(0, 80).join('')) assert.ok(!body.title.includes('\uFFFD'), `title carries a broken character: ${body.title}`) @@ -910,7 +920,7 @@ test('session-title reports the first line of a paginated Codex rollout', async }) assertHookSucceeded(result) assert.equal(stub.requests.length, 1) - assert.equal(assertContractHonoured('/session-title', stub.requests[0]).body.title, 'Fix the replay ring overflow') + assert.equal(assertHookHonoured('/session-title', stub.requests[0]).body.title, 'Fix the replay ring overflow') }) }) @@ -922,7 +932,7 @@ test('session-title trims the title it reports', async () => { stdin: JSON.stringify({ session_id: 's', transcript_path: file }), }) assertHookSucceeded(result) - const { body } = assertContractHonoured('/session-title', stub.requests[0]) + const { body } = assertHookHonoured('/session-title', stub.requests[0]) assert.equal(body.title, 'Fix the replay ring overflow') }) }) @@ -946,7 +956,7 @@ test('session-title reported once per session stops scanning', async () => { }) assertHookSucceeded(await run()) - assertContractHonoured('/session-title', stub.requests[0]) + assertHookHonoured('/session-title', stub.requests[0]) assertHookSucceeded(await run()) assert.equal(stub.requests.length, 1, `reported ${stub.requests.length} times, expected 1`) }) @@ -967,7 +977,7 @@ test('session-title without the latch reports every time, for a retitle', async assertHookSucceeded(await run(first)) assertHookSucceeded(await run(second)) assert.equal(stub.requests.length, 2, `reported ${stub.requests.length} times, expected 2`) - assert.equal(assertContractHonoured('/session-title', stub.requests[1]).body.title, 'Rewrite the replay ring') + assert.equal(assertHookHonoured('/session-title', stub.requests[1]).body.title, 'Rewrite the replay ring') }) }) @@ -1002,7 +1012,7 @@ test('session-title escapes a title that would break the JSON body', async () => stdin: JSON.stringify({ session_id: 's', transcript_path: file }), }) assertHookSucceeded(result) - const { body } = assertContractHonoured('/session-title', stub.requests[0]) + const { body } = assertHookHonoured('/session-title', stub.requests[0]) assert.equal(body.title, 'Fix "quoted" \\ and \ttabbed') }) }) @@ -1043,10 +1053,10 @@ test('report-tool no-ops when the tool name is empty', async () => { }) // The detail is optional in the contract: absent, not blank. A blank one is a -// shape no accept case carries, which assertContractHonoured would catch. +// shape no accept case carries, which assertHookHonoured would catch. test('report-tool omits the detail when the call carries nothing to say', async () => { const { requests } = await runToolHook({ tool_name: 'Read', tool_input: {} }) - const { body, match } = assertContractHonoured('/hook', requests[0]) + const { body, match } = assertHookHonoured('/hook', requests[0]) assert.equal(match, 'a tool with nothing to say about it') assert.equal(body.tool, 'Read') assert.ok(!('detail' in body), `sent a detail anyway: ${requests[0].raw}`) @@ -1148,7 +1158,7 @@ const DETAILS = [ for (const [label, payload, want] of DETAILS) { test(`report-tool names ${label}`, async () => { const { requests } = await runToolHook(payload) - const { body } = assertContractHonoured('/hook', requests[0]) + const { body } = assertHookHonoured('/hook', requests[0]) assert.equal(body.state, 'busy') assert.equal(body.tool, payload.tool_name ?? payload.toolCall?.name) assert.equal(body.detail, want) @@ -1163,7 +1173,7 @@ test('report-tool sends the tool alone when a patch names no file', async () => tool_name: 'apply_patch', tool_input: { command: '*** Begin Patch\nmalformed' }, }) - const { body, match } = assertContractHonoured('/hook', requests[0]) + const { body, match } = assertHookHonoured('/hook', requests[0]) assert.equal(match, 'a tool with nothing to say about it') assert.equal(body.tool, 'apply_patch') assert.ok(!('detail' in body), `put a patch envelope on the card: ${requests[0].raw}`) @@ -1172,7 +1182,7 @@ test('report-tool sends the tool alone when a patch names no file', async () => test('report-tool escapes a detail that would break the JSON body', async () => { const command = 'grep -n "state" internal/*.go | sed \'s/\\t/ /g\'' const { requests } = await runToolHook({ tool_name: 'Bash', tool_input: { command } }) - const { body } = assertContractHonoured('/hook', requests[0]) + const { body } = assertHookHonoured('/hook', requests[0]) assert.equal(body.detail, command) }) @@ -1182,7 +1192,7 @@ test('report-tool sends one line of a multi-line command', async () => { tool_name: 'Bash', tool_input: { command: 'cat < { { tool_name: 'Bash', tool_input: { command: 'pnpm test' } }, { env: { PATH: binWithout('jq') } }, ) - const { body, match } = assertContractHonoured('/hook', requests[0]) + const { body, match } = assertHookHonoured('/hook', requests[0]) assert.equal(match, 'a tool with nothing to say about it') assert.equal(body.tool, 'Bash') assert.ok(!('detail' in body), `built a detail without jq: ${requests[0].raw}`) @@ -1259,6 +1269,78 @@ for (const { script, argument, event } of EVERY_SCRIPT) { }) } +/** + * A PATH whose curl and printf record the arguments they were run with, one + * line a run, in the file returned. printf is there because the token reaches + * curl through it: a shell that ran one from PATH instead of its builtin would + * put the token in that process's arguments. + */ +function recordingBin() { + const dir = mkdtempSync(path.join(TMP, 'bin-recording-')) + const argv = path.join(dir, 'argv') + writeFileSync(argv, '') + for (const tool of ['curl', 'printf']) { + const real = spawnSync('sh', ['-c', `command -v ${tool}`], { encoding: 'utf8' }).stdout.trim() + writeFileSync( + path.join(dir, tool), + `#!/bin/sh\necho "${tool} $*" >>"${argv}"\nexec "${real}" "$@"\n`, + { mode: 0o755 }, + ) + } + return { PATH: `${dir}${path.delimiter}${process.env.PATH}`, argv } +} + +// Releases of lich before the Bearer header answer 401 to a report that carries +// its token nowhere else. +const olderLich = { status: (req) => (req.url.includes('?token=') ? 204 : 401) } + +for (const { script, argument, event } of EVERY_SCRIPT) { + const command = `"${ROOT}/hooks/${script}" ${argument}`.trim() + const stdin = stdinFor({ provider: 'claude', event }) + + test(`${script} keeps the token out of every command line`, async () => { + await withStub(async (stub) => { + const { PATH, argv } = recordingBin() + assertHookSucceeded(await runHook(command, { env: { ...lichEnv(stub.port), PATH }, stdin })) + assert.equal(stub.requests.length, 1, `sent ${stub.requests.length} requests, expected 1`) + assertHookHonoured(SCRIPTS[script], stub.requests[0]) + const ran = readFileSync(argv, 'utf8') + assert.match(ran, /^curl /m, 'the recording curl never ran') + assert.ok(!ran.includes(TOKEN), `the token was an argument of a process:\n${ran}`) + }) + }) + + test(`${script} reports to a lich that refuses the header, the old way`, async () => { + await withStub(async (stub) => { + assertHookSucceeded(await runHook(command, { env: lichEnv(stub.port), stdin })) + assert.equal(stub.requests.length, 2, `sent ${stub.requests.length} requests, expected 2`) + const [refused, taken] = stub.requests + assertHookHonoured(SCRIPTS[script], refused) + assertContractHonoured(SCRIPTS[script], taken) + assert.equal(taken.raw, refused.raw) + }, olderLich) + }) +} + +// The latch is what stops the transcript scan, so a title an older lich took +// the old way has to set it like any other. +test('session-title latches on a title an older lich took', async () => { + await withStub(async (stub) => { + const scratch = mkdtempSync(path.join(TMP, 'latch-older-')) + const file = claudeTranscript('Fix the replay ring overflow', 'latched-older.jsonl') + const run = () => + runHook(`"${ROOT}/hooks/report-title.sh" once`, { + env: lichEnv(stub.port), + stdin: JSON.stringify({ session_id: 's', transcript_path: file }), + tmpdir: scratch, + }) + + assertHookSucceeded(await run()) + assertHookSucceeded(await run()) + assert.equal(stub.requests.length, 2, `sent ${stub.requests.length} requests, expected 2`) + }, olderLich) +}) + // The header every report sends is checked against the Claude Code manifest; // this is what holds the Codex one to the same release. test('both plugin manifests name the same release', () => {