diff --git a/hypaware-core/smoke/flows/claude_attach_detach.js b/hypaware-core/smoke/flows/claude_attach_detach.js index 17c5b1a1..dc693101 100644 --- a/hypaware-core/smoke/flows/claude_attach_detach.js +++ b/hypaware-core/smoke/flows/claude_attach_detach.js @@ -26,7 +26,9 @@ import { requireAiGatewayRuntime } from '../../plugins-workspace/ai-gateway/src/ * * - `hyp attach --client claude` patches `~/.claude/settings.json` * with the HypAware marker, `env.ANTHROPIC_BASE_URL`, and the - * managed session-context hook entries (golden compare). + * managed hook entries (golden compare): `session-context` on every + * managed event, plus the LLP 0106 `classify-cwd` hook, which the + * plugin scopes to the two fresh-cwd events. * - A `client.attach` span exists with `hyp_plugin=@hypaware/claude`, * `client_name=claude`, `status=ok`, `restored=false`. * - `hyp detach --client claude` removes the managed keys and the @@ -181,23 +183,51 @@ export async function run({ harness, expect }) { typeof v.state_file === 'string' && v.state_file.endsWith('session-context.jsonl') ) + // LLP 0106 settles that attach installs the classification hook *alongside* + // the existing session-context hook, which is what makes a golden compare + // expecting session-context on its own stale. + // + // Which events each kind rides is not 0106's to say and is not stated + // there: `session-context` on every managed event, `classify-cwd` only + // where a *fresh* working directory appears (SessionStart, CwdChanged), is + // decided by `MANAGED_HOOK_SPECS` in the claude plugin's `src/settings.js`, + // with the reasoning in the comment above it. Both sides are asserted here + // so dropping either kind, or leaking `classify-cwd` onto the per-prompt + // and per-tool events, fails the golden compare instead of passing quietly. + // @ref LLP 0106#decision [tests]: attach installs the classification hook beside the session-context hook expect.that( - 'settings: SessionStart hook installed with --state-file pointing at the plugin state dir', - attached?.hooks?.SessionStart, + 'settings: SessionStart carries session-context (with --state-file) then classify-cwd', + hookCommands(attached?.hooks?.SessionStart), (v) => - Array.isArray(v) && - v.length === 1 && - Array.isArray(v[0].hooks) && - v[0].hooks[0]?.type === 'command' && - typeof v[0].hooks[0]?.command === 'string' && - v[0].hooks[0].command.includes('claude-hook session-context') && - v[0].hooks[0].command.includes('--state-file ') && - v[0].hooks[0].command.includes('session-context.jsonl') + v.length === 2 && + v[0].includes('claude-hook session-context') && + v[0].includes('--state-file ') && + v[0].includes('session-context.jsonl') && + v[1].endsWith('claude-hook classify-cwd') + ) + expect.that( + 'settings: CwdChanged carries the same pair (the other fresh-cwd event)', + hookCommands(attached?.hooks?.CwdChanged), + (v) => + v.length === 2 && + v[0].includes('claude-hook session-context') && + v[0].includes('session-context.jsonl') && + v[1].endsWith('claude-hook classify-cwd') ) expect.that( - 'settings: PostToolUse hook scoped to Bash matcher', - attached?.hooks?.PostToolUse?.[0]?.matcher, - (v) => v === 'Bash' + 'settings: UserPromptSubmit carries session-context only (no re-ask per prompt)', + hookCommands(attached?.hooks?.UserPromptSubmit), + (v) => v.length === 1 && v[0].includes('claude-hook session-context') + ) + expect.that( + 'settings: PostToolUse carries session-context only, scoped to the Bash matcher', + attached?.hooks?.PostToolUse, + (v) => + Array.isArray(v) && + v.length === 1 && + v[0]?.matcher === 'Bash' && + hookCommands(v).length === 1 && + hookCommands(v)[0].includes('claude-hook session-context') ) // Drive `hyp detach --client claude` through the dispatcher. @@ -295,6 +325,25 @@ export async function run({ harness, expect }) { } } +/** + * Flatten a `hooks.` block into its command strings, in install order. + * Each element of the block is a `{ matcher?, hooks: [{ type, command }] }` + * group, and attach pushes one group per command kind the event carries. + * + * @param {unknown} groups + * @returns {string[]} + */ +function hookCommands(groups) { + if (!Array.isArray(groups)) return [] + return groups.flatMap((group) => { + const handlers = group?.hooks + if (!Array.isArray(handlers)) return [] + return handlers + .filter((/** @type {any} */ h) => h?.type === 'command' && typeof h.command === 'string') + .map((/** @type {any} */ h) => /** @type {string} */ (h.command)) + }) +} + /** * @param {{ hypHome: string }} harness */ diff --git a/hypaware-core/smoke/flows/client_attach_on_join.js b/hypaware-core/smoke/flows/client_attach_on_join.js index 540d499e..214d3641 100644 --- a/hypaware-core/smoke/flows/client_attach_on_join.js +++ b/hypaware-core/smoke/flows/client_attach_on_join.js @@ -10,6 +10,7 @@ import { defaultConfigPath } from '../../../src/core/config/schema.js' import { readConfigControlStatus } from '../../../src/core/config/apply.js' import { readClientActionStatus } from '../../../src/core/config/action_reconciler.js' import { DAEMON_RESTART_EXIT_CODE, runDaemon } from '../../../src/core/daemon/runtime.js' +import { readStatusFile, resolveLiveGatewayEndpointFromStatus } from '../../../src/core/daemon/status.js' import { dispatch } from '../../../src/core/cli/dispatch.js' /** @@ -28,10 +29,16 @@ import { dispatch } from '../../../src/core/cli/dispatch.js' * schedules a reconcile pass → **claude auto-attaches**: the `_hypaware` * marker + the gateway `ANTHROPIC_BASE_URL` land in the client settings, * and the `attach.claude` client-action marker reads `done`. - * 3. a second confirmed boot pass (a fresh relaunch on the same rev-1) is a - * **no-op**: the `done` marker short-circuits, so the attach is not - * re-applied (the marker timestamp is unchanged). - * 4. the server drops `@hypaware/claude` (rev-2) → apply → staged restart → + * 3. a second confirmed boot pass (a fresh relaunch on the same rev-1) hits + * the **drift** branch of the freshness check: rev-1 lets the gateway bind + * an ephemeral port, so the relaunch is at a *new* endpoint, the `done` + * marker is stale, and the forward gap re-attaches at the new port. + * 4. rev-1b pins the gateway's `listen` to the port the drifted daemon is + * already bound to (read back from its own `status.json`) → apply → + * staged restart → the relaunch reclaims that same port, so it is the + * **no-op** branch: the `done` marker short-circuits and nothing is + * re-applied (the marker timestamp and the client settings are unchanged). + * 5. the server drops `@hypaware/claude` (rev-2) → apply → staged restart → * relaunch without the adapter → the reconcile **reverse gap** runs the * disk-driven undo: the marker is removed and the client settings are * restored to their pre-attach state, the Part 5 config-drop trigger, @@ -44,6 +51,7 @@ import { dispatch } from '../../../src/core/cli/dispatch.js' * @ref LLP 0045#part-1-the-client-seam-in-the-reconcile-context [tests]: the daemon threads clientDescriptors/clients/endpoint onto the reconcile context; a confirm-edge pass reaches the attach handler * @ref LLP 0045#part-5-reverse-triggers-config-drop-not-hyp-leave [tests]: a central config drop reverses the attach post-restart via the disk-driven undo * @ref LLP 0044#consent-join-implies-consent-default-on [tests]: a joined host confirming a config that names @hypaware/claude auto-attaches (default-on) + * @ref LLP 0086#re-attach-on-drift [tests]: both branches of the freshness check, a relaunch at a rebound ephemeral port re-attaches, a relaunch at a pinned one short-circuits */ export async function run({ harness, expect }) { const obs = installObservability() @@ -71,11 +79,24 @@ export async function run({ harness, expect }) { const localConfigPath = defaultConfigPath(harness.hypHome) const stateRoot = path.join(harness.hypHome, 'hypaware') + // The freshness check watches the gateway's live endpoint, so the smoke needs + // both a moving one (rev-1's ephemeral bind) and a stable one (rev-1b's pin). + // The pinned value is deliberately *not* reserved up front: it is read back + // from the running daemon's status.json below, so rev-1b pins a port the + // daemon is already holding rather than one this process probed and released. + /** @type {string} */ + let pinnedListen = '' + /** @type {string} */ + let pinnedEndpoint = '' + /** @type {string | undefined} */ + let driftStartedAt + const server = await startStubCentralServer() try { // rev-1: a joined fleet config that enables the gateway + the claude client - // adapter. Confirming it must auto-attach claude. - server.setConfig(rev1Config(server.baseUrl), 'rev-1') + // adapter. Confirming it must auto-attach claude. Its gateway binds an + // ephemeral port, which is what makes the relaunch below drift. + server.setConfig(rev1Config(server.baseUrl, EPHEMERAL_LISTEN), 'rev-1') // An empty local layer so `join` has something to leave untouched. await fs.writeFile(localConfigPath, JSON.stringify({ version: 2, plugins: [] }, null, 2) + '\n') @@ -139,14 +160,108 @@ export async function run({ harness, expect }) { await attachHandle.done } - // Snapshot the post-attach state for the idempotency assertion below. + // Snapshot the post-attach state for the drift assertions below. const attachedAt = attachMarker(stateRoot)?.at - const attachedBody = await fs.readFile(claudeSettingsPath, 'utf8') + const attachedEndpoint = attachMarker(stateRoot)?.endpoint - // ----- smoke_step: no_reattach (a second confirmed boot pass is a no-op) ----- + // ----- smoke_step: reattach_on_drift (relaunch rev-1 → new port → re-attach) ----- // A fresh relaunch on the *same* rev-1 runs the after-activation // already-confirmed pass (probation is cleared), so desired() names claude - // again, but the `done` marker short-circuits, so nothing is re-applied. + // again and the `done` marker is consulted. rev-1's gateway binds an + // ephemeral port, so this boot is at a *different* endpoint: the marker is + // stale, the unit is a forward gap, and the attach re-performs at the new + // port instead of short-circuiting forever. + // @ref LLP 0086#re-attach-on-drift [tests]: a done marker at a moved endpoint re-performs, which is what keeps ANTHROPIC_BASE_URL pointing at a bound port + const driftHandle = await runDaemonHandle(harness) + try { + await waitFor( + () => readConfigControlStatus({ stateRoot }).probation === null, + 15_000, + 'probation was unexpectedly re-armed on the drift relaunch' + ) + await waitFor( + () => { + const marker = attachMarker(stateRoot) + return marker?.status === 'done' && marker.at !== attachedAt + }, + 15_000, + 'the attach.claude marker was not refreshed after the gateway rebound to a new port' + ) + const drifted = attachMarker(stateRoot) + expect.that( + 'drift: the refreshed marker records the newly bound endpoint', + drifted?.endpoint, + (v) => typeof v === 'string' && v.length > 0 && v !== attachedEndpoint + ) + const rewritten = JSON.parse(await fs.readFile(claudeSettingsPath, 'utf8')) + expect.that( + 'drift: env.ANTHROPIC_BASE_URL was rewritten to the newly bound port', + rewritten?.env?.ANTHROPIC_BASE_URL, + (v) => typeof v === 'string' && v === drifted?.endpoint + ) + expect.that( + 'drift: the unrelated seed key (ANTHROPIC_API_KEY) survived the re-attach', + rewritten?.env?.ANTHROPIC_API_KEY, + (v) => v === 'sk-seed' + ) + + // ----- smoke_step: pin_port (serve rev-1b → apply → restart) ----- + // rev-1b is rev-1 with the gateway's `listen` pinned, so every later boot + // binds the same port: the input the freshness check watches stops moving. + // + // The port it pins is the one *this* daemon is bound to right now, read + // back out of its own status.json. That is what keeps the pin race-free: + // probing a free port by binding and releasing it would hand rev-1b a + // port nobody holds and hope it is still free seconds later (any + // co-resident process, including this smoke's own stub server, could take + // it), whereas a port the daemon already owns is simply reclaimed across + // the staged restart. + // @ref LLP 0086#endpoint-discovery [tests]: the live bound port is readable from status.json, which is what lets the pin name a port the daemon already holds + driftStartedAt = statusStartedAt(stateRoot) + const liveEndpoint = resolveLiveGatewayEndpointFromStatus({ stateRoot }) + expect.that( + 'pin: the drifted gateway reports its live bound endpoint in status.json', + liveEndpoint, + (v) => typeof v === 'string' && v === drifted?.endpoint + ) + // Not decoration: the relaunch below is told apart from this boot's + // leftover snapshot by `startedAt`, so an unread one would make that + // check pass on stale data. + expect.that( + 'pin: this boot is identifiable in status.json by its startedAt', + driftStartedAt, + (v) => typeof v === 'string' && v.length > 0 + ) + pinnedEndpoint = String(liveEndpoint) + pinnedListen = pinnedEndpoint.slice('http://'.length) + server.setConfig(rev1Config(server.baseUrl, pinnedListen), 'rev-1b') + const pinExit = await withTimeout( + driftHandle.done, + 30_000, + 'the rev-1b pinned-port revision did not request a staged restart within 30s' + ) + expect.that( + `pin: daemon exited with the restart code (got ${pinExit})`, + pinExit, + (v) => v === DAEMON_RESTART_EXIT_CODE + ) + } finally { + // `driftHandle.done` already resolved (restart): stop() is idempotent. + await driftHandle.stop() + } + + // Snapshot the post-drift state for the no-op assertions below. `pinnedAt` + // is a genuinely fresh timestamp, not a leftover: the drift step above + // asserted the re-attach moved the marker off `attachedAt`. + const pinnedAt = attachMarker(stateRoot)?.at + const pinnedBody = await fs.readFile(claudeSettingsPath, 'utf8') + + // ----- smoke_step: no_reattach (a boot at an unchanged endpoint is a no-op) ----- + // The complement of the drift branch: rev-1b pins the port the drift boot + // bound, so this relaunch resolves the same endpoint the marker records, + // the freshness check calls the marker current, and the `done` marker + // short-circuits as it always did. + // @ref LLP 0086#re-attach-on-drift [tests]: the guard side of the same check, an unmoved endpoint still short-circuits rather than churning the attach every boot const steadyHandle = await runDaemonHandle(harness) try { await waitFor( @@ -154,24 +269,38 @@ export async function run({ harness, expect }) { 15_000, 'probation was unexpectedly re-armed on the steady relaunch' ) + // The pin only holds the endpoint still if the gateway actually reclaimed + // the port it released on the staged restart, so read that back off *this* + // boot's status.json (`startedAt` moved) before calling the no-op below a + // no-op. A gateway that failed to rebind, or fell back to another port + // (LLP 0114), would otherwise look identical to a clean short-circuit. + await waitFor( + () => { + const startedAt = statusStartedAt(stateRoot) + if (startedAt === undefined || startedAt === driftStartedAt) return false + return resolveLiveGatewayEndpointFromStatus({ stateRoot }) === pinnedEndpoint + }, + 15_000, + `the relaunched gateway did not reclaim the pinned endpoint ${pinnedEndpoint}` + ) // Give the boot-already-confirmed pass time to run (and prove it does not // re-attach): the marker timestamp must be identical. await sleep(500) expect.that( 'no re-attach: the attach.claude marker timestamp is unchanged (done short-circuits)', attachMarker(stateRoot)?.at, - (v) => v === attachedAt + (v) => v === pinnedAt ) expect.that( 'no re-attach: the client settings are byte-for-byte unchanged', await fs.readFile(claudeSettingsPath, 'utf8'), - (v) => v === attachedBody + (v) => v === pinnedBody ) // ----- smoke_step: drop_claude (serve rev-2 → apply → restart) ----- // rev-2 drops @hypaware/claude fleet-wide; the running daemon's next poll // applies it and requests a staged restart. - server.setConfig(rev2Config(server.baseUrl), 'rev-2') + server.setConfig(rev2Config(server.baseUrl, pinnedListen), 'rev-2') const dropExit = await withTimeout( steadyHandle.done, 30_000, @@ -256,21 +385,17 @@ export async function run({ harness, expect }) { /* ---------- served revisions ---------- */ -/** @param {string} baseUrl */ -function rev1Config(baseUrl) { +// The default gateway bind: a port the kernel picks fresh on every boot, which +// is what makes a relaunch on an unchanged revision drift (LLP 0086). +const EPHEMERAL_LISTEN = '127.0.0.1:0' + +/** @param {string} baseUrl @param {string} listen */ +function rev1Config(baseUrl, listen) { return { version: 2, plugins: [ { name: '@hypaware/central' }, - { - name: '@hypaware/ai-gateway', - config: { - listen: '127.0.0.1:0', - upstreams: [ - { name: 'anthropic', base_url: 'https://api.anthropic.com', path_prefix: '/' }, - ], - }, - }, + { name: '@hypaware/ai-gateway', config: gatewayConfig(listen) }, { name: '@hypaware/claude' }, ], sinks: centralSink(baseUrl), @@ -278,27 +403,33 @@ function rev1Config(baseUrl) { } } -/** rev-2 is rev-1 minus the claude client plugin: the fleet-drop trigger. @param {string} baseUrl */ -function rev2Config(baseUrl) { +/** + * rev-2 is rev-1 minus the claude client plugin: the fleet-drop trigger. + * @param {string} baseUrl + * @param {string} listen + */ +function rev2Config(baseUrl, listen) { return { version: 2, plugins: [ { name: '@hypaware/central' }, - { - name: '@hypaware/ai-gateway', - config: { - listen: '127.0.0.1:0', - upstreams: [ - { name: 'anthropic', base_url: 'https://api.anthropic.com', path_prefix: '/' }, - ], - }, - }, + { name: '@hypaware/ai-gateway', config: gatewayConfig(listen) }, ], sinks: centralSink(baseUrl), query: { cache: { retention: { default_days: 30 } } }, } } +/** @param {string} listen */ +function gatewayConfig(listen) { + return { + listen, + upstreams: [ + { name: 'anthropic', base_url: 'https://api.anthropic.com', path_prefix: '/' }, + ], + } +} + /** @param {string} baseUrl */ function centralSink(baseUrl) { return { @@ -344,10 +475,28 @@ async function runDaemonHandle(harness) { }) } +/** + * The `startedAt` of the daemon boot that wrote the current `status.json`, or + * `undefined` when there is no readable status file yet. + * + * Which boot wrote a status file matters here because a pinned port makes two + * consecutive boots report the *same* endpoint: an endpoint read on its own + * cannot tell a fresh bind from the outgoing daemon's leftover snapshot. + * @param {string} stateRoot + * @returns {string | undefined} + */ +function statusStartedAt(stateRoot) { + try { + return readStatusFile(stateRoot)?.startedAt + } catch { + return undefined + } +} + /** * Read the `attach.claude` client-action marker, or `undefined` when absent. * @param {string} stateRoot - * @returns {{ status?: string, request_key?: string, at?: string } | undefined} + * @returns {{ status?: string, request_key?: string, at?: string, endpoint?: string } | undefined} */ function attachMarker(stateRoot) { const byKind = readClientActionStatus({ stateRoot }).byKind