diff --git a/hypaware-core/plugins-workspace/ai-gateway/src/session_command.js b/hypaware-core/plugins-workspace/ai-gateway/src/session_command.js index db59409d..455d6790 100644 --- a/hypaware-core/plugins-workspace/ai-gateway/src/session_command.js +++ b/hypaware-core/plugins-workspace/ai-gateway/src/session_command.js @@ -38,6 +38,38 @@ const FOLDER_GOVERNOR_NOTE = 'folder: see `hyp policy show` (this verb reports const EPHEMERAL_NOTE = 'this opt-out is in-memory only: a gateway restart drops it, and a fork (`claude --fork-session`, `codex fork`) mints a new session id it no longer covers. Re-check with `hyp session status`.' +/** + * The control plane's authenticity contract, printed beside every **confirmed** + * answer, by the writer and the reader alike. + * + * `validateControlResponse` proves the responder saw our token; nothing proves + * the responder IS the gateway. A local process that binds the resolved port and + * echoes the token back yields a confident `ignored: true` for a session nothing + * is dropping (issue #451). Authenticating it would need peer-process identity, + * which has no portable form, and a gateway-written secret defends nothing + * because whoever can bind that port runs as the same uid and can read the same + * file ([LLP 0067 §cli-response-check](../../../../llp/0067-session-opt-out.design.md#cli-response-check)). + * + * So the guarantee is stated rather than proved, and the statement is + * **unconditional**: the verb cannot tell the gateway from the impostor, so a + * note printed only "when spoofed" would be a claim it cannot make, and its + * absence would read as proof of authenticity. + * + * The endpoint is named in the note rather than left as "that port": on the + * `daemon_status` path this is the only line about the endpoint at all, and a + * reader pasting the output into a support thread should not have to reconstruct + * which address was trusted. + * + * @ref LLP 0166#stated-not-proved [implements]: the responder is never + * authenticated, and every confirmed answer says so. + * + * @param {string} endpoint + * @returns {string} + */ +function responderTrustNote(endpoint) { + return `trust: nothing proves the responder at ${endpoint} is the HypAware gateway - any process on this machine could bind that port and answer. This answer is only as trustworthy as this machine.` +} + /** * `hyp session status` exit code for a **confirmed** "this session is NOT * being dropped" read. Distinct from `SESSION_EXIT_UNKNOWN` on purpose: the @@ -276,6 +308,9 @@ async function runMutation(argv, ctx, method, usage) { total, endpoint: endpoint.endpoint, endpoint_source: endpoint.source, + // Same field, same constant, on the verbs whose output reads as done. + // @ref LLP 0166#stated-not-proved [implements] + endpoint_authenticated: false, }) + '\n' ) return 0 @@ -295,6 +330,7 @@ async function runMutation(argv, ctx, method, usage) { idSource: resolvedId.source, idEvidence: resolvedId.evidence ?? null, threadId: resolvedId.threadId ?? null, + endpoint: endpoint.endpoint, endpointSource: endpoint.source, })) { ctx.stdout.write(`${note}\n`) @@ -313,7 +349,19 @@ async function runMutation(argv, ctx, method, usage) { */ function writeStatus(ctx, json, report) { if (json) { - ctx.stdout.write(JSON.stringify({ ...report, folder_policy: 'hyp policy show' }) + '\n') + ctx.stdout.write( + JSON.stringify({ + ...report, + // The human note's machine-readable twin, so a JSON consumer does not + // have to parse prose (or, worse, infer authenticity from silence). + // Constant by contract, `unknown` reports included: it is `false` + // because no answer this verb can obtain is authenticated, not because + // this particular one failed a check. + // @ref LLP 0166#stated-not-proved [implements] + endpoint_authenticated: false, + folder_policy: 'hyp policy show', + }) + '\n' + ) } else if (report.status === 'unknown') { const who = report.session_id ?? '(unresolved)' ctx.stdout.write(`session ${who}: UNKNOWN - cannot confirm the opt-out is in effect\n`) @@ -327,6 +375,7 @@ function writeStatus(ctx, json, report) { idSource: report.session_id_source, idEvidence: report.session_id_evidence, threadId: report.thread_id, + endpoint: report.endpoint, endpointSource: report.endpoint_source, })) { ctx.stdout.write(`${note}\n`) @@ -339,6 +388,7 @@ function writeStatus(ctx, json, report) { idSource: report.session_id_source, idEvidence: report.session_id_evidence, threadId: report.thread_id, + endpoint: report.endpoint, endpointSource: report.endpoint_source, })) { ctx.stdout.write(`${note}\n`) @@ -368,9 +418,12 @@ function writeStatus(ctx, json, report) { * bound, and qualifying it too would train the reader to skip the caveat on the * paths where it is load-bearing. * - * A live daemon's `status.json` proves the second; a - * pinned `listen` only asserts it, and `validateControlResponse` can prove the - * responder saw our token but not that it is the gateway. Naming the weaker + * The second claim is never proved, only graded. A live daemon's `status.json` + * says the gateway bound that port; a pinned `listen` says only that it was + * asked to, so the weaker source gets its own note. Neither says who answers + * there NOW, and `validateControlResponse` can prove the responder saw our + * token but not that it is the gateway, so `responderTrustNote` rides every + * confirmed answer under both sources (issue #451, LLP 0166). Naming the * evidence in the output is the only remedy available at this layer, and it is * this change's own thesis: a control that can be wrong must at least say so. * @@ -388,12 +441,13 @@ function writeStatus(ctx, json, report) { * idSource: SessionStatusReport['session_id_source'], * idEvidence: string | null, * threadId: string | null, + * endpoint: string | null, * endpointSource: SessionStatusReport['endpoint_source'], * }} args * @returns {string[]} */ function provenanceNotes(args) { - const { idSource, idEvidence, threadId, endpointSource } = args + const { idSource, idEvidence, threadId, endpoint, endpointSource } = args /** @type {string[]} */ const notes = [] if (idSource === 'codex_rollout') { @@ -416,6 +470,10 @@ function provenanceNotes(args) { 'endpoint: from the pinned `listen`, not a live daemon - nothing proved the gateway still owns that port.' ) } + // Last, and on every confirmed answer: the weaker of the two endpoint + // sources gets the extra note above, but neither of them authenticates the + // responder, so the contract is stated whichever one produced the port. + if (endpoint) notes.push(responderTrustNote(endpoint)) return notes } diff --git a/hypaware-core/plugins-workspace/ai-gateway/src/types.d.ts b/hypaware-core/plugins-workspace/ai-gateway/src/types.d.ts index cfef55a0..0e498889 100644 --- a/hypaware-core/plugins-workspace/ai-gateway/src/types.d.ts +++ b/hypaware-core/plugins-workspace/ai-gateway/src/types.d.ts @@ -211,8 +211,22 @@ export type SessionEndpointResolution = * gateway" - and only some of the ways of establishing them are authoritative. * Hiding which one was used is how a confident answer about the wrong session * reads as a confident answer about yours. + * + * **The `--json` envelope is this record plus two fields the writer adds and + * this interface deliberately does not carry**, because they are constants + * rather than results: `folder_policy` (the other governor's verb) and + * `endpoint_authenticated`, which is always `false`. The second is `false` **by + * contract, not by outcome** - no answer this verb can obtain is authenticated, + * on `unknown` reports included - so a peer-identity check, if one is ever + * adopted, needs a NEW field rather than flipping this one: a consumer that + * learned "false means nobody checked" must not have to relearn "false now + * means the check ran and failed". Anything that adds a report shape here owes + * it the same constant. + * * @ref LLP 0066#readable [implements]: R10 and R12 shape this record - `ignored` * is nullable so an unconfirmable read cannot render as `false`. + * @ref LLP 0166#stated-not-proved [constrained-by]: the `--json` envelope states + * the responder was never authenticated, on every shape. */ export interface SessionStatusReport { status: 'ignored' | 'not_ignored' | 'unknown' diff --git a/llp/0067-session-opt-out.design.md b/llp/0067-session-opt-out.design.md index 59f0f504..30b891e6 100644 --- a/llp/0067-session-opt-out.design.md +++ b/llp/0067-session-opt-out.design.md @@ -293,6 +293,13 @@ dependency for one check makes it a separate design decision rather than a hardening tweak. Recorded, not adopted; the residual stays mitigated by the `endpoint_source` disclosure ([§cli-provenance](#cli-provenance)). +> **Extended-by: [LLP 0166 §stated-not-proved](./0166-session-control-plane-states-its-guarantees.decision.md#stated-not-proved).** +> Issue #451 settled the deferred question as **accept and document**: the +> residual is not closed, and every confirmed answer now states that the +> responder was never authenticated (`trust:` in the human output, +> `endpoint_authenticated: false` in `--json`) beside the `endpoint_source` +> disclosure this section points at. + ### Endpoint resolution: disk, then config, never a guess {#cli-endpoint} The daemon's live bound port from `status.json` wins diff --git a/llp/0166-session-control-plane-states-its-guarantees.decision.md b/llp/0166-session-control-plane-states-its-guarantees.decision.md new file mode 100644 index 00000000..68ccaa92 --- /dev/null +++ b/llp/0166-session-control-plane-states-its-guarantees.decision.md @@ -0,0 +1,173 @@ +# LLP 0166: the session control plane states its guarantees rather than proving them + +**Type:** Decision +**Status:** Accepted +**Systems:** Gateway, Plugins +**Author:** Phil / Claude +**Date:** 2026-07-31 +**Related:** LLP 0066, LLP 0067, LLP 0086 + +> Two questions were asked of the `hyp session` control plane in the same week, +> and they have the same answer. **#451:** can the CLI prove that whatever +> answers on the resolved port *is* the gateway? No, and it never will at this +> layer. **#460:** can a caller learn from `ignored: true` that the opt-out will +> actually match live traffic? No, only that the token it sent is in the drop +> set. Both are **accept and document**: the control plane keeps the behaviour +> it has and states the limit of the guarantee in its own output, in human form +> and in `--json`, rather than letting silence read as assurance. +> +> @ref LLP 0067#cli-response-check [constrained-by]: extends the recorded dead +> end into a stated contract - the residual is disclosed, not closed. + +## Context + +`hyp session status | ignore | unignore` resolves a control endpoint +(`status.json` from a live daemon, else the pinned `listen`), POSTs / GETs +`/_hypaware/ignore/session`, and reports the answer. It is a **privacy** +control: the answer a user acts on is "this session is not being recorded". + +Two independent things stand between that answer and the truth, and neither is +a defect in the code that was written: + +1. **Nobody authenticated the responder** (issue #451). PR #439's + `validateControlResponse` refuses a reply that is not an object, whose + `ignored` is not a boolean, or whose `session_id` is not echoed + byte-for-byte. A local process that binds the port and *echoes the token + back* satisfies every one of those checks by construction, and the user is + told they are opted out while nothing is dropping anything. +2. **The route confirms a write, not a match** (issue #460). `control.js` adds + the token to a `Set` and answers `ignored: true`; the drop consumer is a bare + `Set.has`. A caller that registered the wrong key - a Codex *thread* id + rather than the session container, say - gets the identical happy answer. A + `GET` afterwards does not help: it asks the same `Set` the same question. + +## Why authentication is not the fix for #451 {#why-not-authenticate} + +Costed in [LLP 0067 §cli-response-check](./0067-session-opt-out.design.md#cli-response-check) +and unchanged by this document: + +- **A gateway-written secret defends nothing.** Any process able to bind that + port runs as the same uid as the daemon, and can therefore read whatever file + the secret lives in. It raises the cost of the attack by one `read()`. +- **Peer-process identity is the only real signal, and it is not portable.** + `status.json` already carries a liveness-gated daemon pid, so the check would + be "is the process on the other end of this socket that pid" - + `/proc/net/tcp` plus `/proc//fd` on Linux, `lsof` on macOS, + `GetExtendedTcpTable` (native addon or `netstat -o` scraping) on Windows. + Three implementations and a native dependency for one check. +- **A non-squattable transport is a redesign.** A unix socket in a + daemon-created directory would carry the property in the namespace itself, but + it changes the control-plane transport, the skills' shell path, and Windows + support all at once. + +**The trigger condition is what settles it.** The attack requires a process on +this machine that can bind the gateway's port before or instead of the daemon, +which means the attacker already has local code execution as this user. At that +point they can read the cache, the config, and the credentials directly; a +spoofed opt-out answer is not the marginal capability worth a native dependency +in three platform flavours. The guarantee `hyp session` can honestly offer is +bounded by the machine it runs on, so that is the guarantee it states. + +## The contract, stated in the output {#stated-not-proved} + +**The responder is never authenticated, and every confirmed answer says so.** + +- **Human output** carries a `trust:` note beside the answer, on `status` and on + both mutation verbs: nothing proves the responder **at the named endpoint** is + the HypAware gateway, any process on this machine could bind that port and + answer, and the answer is only as trustworthy as this machine. The endpoint is + named in the note itself because on the `daemon_status` path it is the only + line about the endpoint at all. +- **`--json`** carries `endpoint_authenticated: false`, so a consumer acts on + the guarantee without parsing prose. +- The existing `endpoint_source` disclosure (`daemon_status` / `config_listen`) + **stays** and keeps its own note for the weaker source. The two say different + things: `endpoint_source` grades the evidence that the *port* is the + gateway's, `endpoint_authenticated` reports that the *responder* was never + checked at all. + +Three properties of the statement are load-bearing: + +**It is unconditional.** The verb cannot tell the gateway from the impostor, so +a note printed only "when spoofed" would be a claim it cannot make. It rides +the `daemon_status` path too, where a live daemon reported the port it bound: +that is evidence about a bind in the past, not about who answers now. + +**`endpoint_authenticated` is `false` by contract, not by outcome.** It is not a +check result that might come back `true` on a good day, and it is `false` on +`unknown` reports as well. Should a peer-identity check ever be adopted, that is +a new decision superseding this section, and it would need a distinct field +rather than quietly flipping this one - a consumer that has learned "false means +unauthenticated" must not have to relearn "false now means the check ran and +failed". + +**Fail-closed behaviour is untouched.** `validateControlResponse` keeps every +refusal it has (LLP 0067 §cli-response-check): a malformed answer, an answer +about a different session, a non-200, an oversized body are all still `unknown`. +This document adds a disclosure to the answers that *are* believed; it does not +believe anything new. + +## The companion contract: membership is not a match {#membership-not-grain} + +The same decision was taken on issue #460, and the two read as one story: the +control plane answers exactly what it knows and names what it does not. + +**`ignored: true` means "this token is in the drop set", and nothing more.** It +is not a statement that live traffic will match it. **The caller is responsible +for resolving the correct key before calling** - which is the shape PR #458 +already established for `hyp session` ([LLP 0067 +§cli-session-id](./0067-session-opt-out.design.md#cli-session-id): the session +container, never the thread id, and a refusal rather than a guess). This makes +that responsibility the stated contract rather than an accident of who resolved +first, and it is why option 1 there (the gateway resolving and echoing the +grain) was declined: the route treats the token as opaque +([LLP 0066 §enforcement](./0066-session-opt-out.spec.md#enforcement)), and +teaching it Codex grain would move provider knowledge into the one component +that has deliberately never had any. + +Two consequences carried by that decision, realized in the change set for #460 +rather than here: + +- The skills' shell path gains the echo check the JS resolver already has + (`validateControlResponse`'s equivalent), for as long as that path exists. +- When #435 lands, the skills stop resolving independently and the verification + question narrows to the single CLI resolver, which is the durable shape. + +## Scope of this change set + +This document is the joint record; the code lands in two places, and this half +is the **#451** half: + +- **Here (#451):** `responderTrustNote(endpoint)` and + `endpoint_authenticated: false` in + `hypaware-core/plugins-workspace/ai-gateway/src/session_command.js`, on + `status` and both mutation verbs, human and `--json`. The note is a function + of the endpoint rather than a constant string because it names the address it + trusted, which on the `daemon_status` path is the only line about the endpoint + at all. +- **In its own change set (#460):** the membership-not-grain wording on the CLI + and skill surfaces, and the shell-path echo check. + +## What would reopen this + +A change in the trigger condition, not a cheaper mitigation. If the gateway ever +serves a control plane reachable by a process that is *not* already running as +this user - a shared or multi-user host, a container boundary, a network-exposed +listener - the "local code execution is a precondition" argument stops holding +and authentication becomes load-bearing again. The disclosure fields make that +reopening cheap to spot: anything consuming `endpoint_authenticated` is a caller +that already knows to ask. + +## Test plan {#tests} + +`test/plugins/ai-gateway-session-responder-trust.test.js` stands up an +**impostor** responder - a listener that reads the session id off the query +string or body and echoes it back with `ignored: true` - and pins both halves of +the accepted outcome: + +- the impostor's answer **is** believed (exit 0, `ignored: true`), including on + the `daemon_status` path with a live pid file and a `status.json` naming the + impostor's port, so the residual cannot change by accident, and +- every such answer discloses that the responder was not authenticated, in the + human text and as `endpoint_authenticated: false`, on `status`, on `ignore`, + on the genuine control route, and on `unknown` reports. diff --git a/test/plugins/ai-gateway-session-responder-trust.test.js b/test/plugins/ai-gateway-session-responder-trust.test.js new file mode 100644 index 00000000..08ff6484 --- /dev/null +++ b/test/plugins/ai-gateway-session-responder-trust.test.js @@ -0,0 +1,256 @@ +// @ts-check + +import assert from 'node:assert/strict' +import fs from 'node:fs' +import http from 'node:http' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' + +import { createControlHandler } from '../../hypaware-core/plugins-workspace/ai-gateway/src/control.js' +import { writePidFile } from '../../src/core/daemon/pid.js' +import { writeStatusFile } from '../../src/core/daemon/status.js' +import { + runSessionIgnore, + runSessionStatus, +} from '../../hypaware-core/plugins-workspace/ai-gateway/src/session_command.js' + +// Regression suite for issue #451: `hyp session` cannot prove the control +// responder is the real gateway. +// +// The impostor below is the exact responder the issue names: it does not have +// to guess anything, it just ECHOES the token it was sent. That defeats +// `validateControlResponse` (LLP 0067 §cli-response-check) by construction, +// because every check that function makes is a check the echo satisfies. +// +// The decision on #451 is accept-and-document, so these tests do NOT assert +// that the impostor is rejected - it cannot be, at this layer. They assert the +// contract that replaces the rejection: every confirmed answer states that the +// responder was never authenticated, in human output and in `--json`, on both +// endpoint-discovery paths. +// +// @ref LLP 0166#stated-not-proved [tests]: an unauthenticated responder is +// disclosed, on every confirmed answer, rather than silently trusted. + +/* ------------------------------------------------------------------ */ +/* The impostor: a listener that echoes the token back */ +/* ------------------------------------------------------------------ */ + +test('an impostor that echoes the token is believed - and every answer discloses that it was never authenticated', async () => { + await withImpostorServer(async (base) => { + const home = daemonHome(base) + const env = { HYP_HOME: home, CLAUDE_CODE_SESSION_ID: 'sess-spoofed' } + + const json = fakeCtx({ env }) + const code = await runSessionStatus(['--json'], json.ctx) + + // The accepted residual, pinned so it cannot change by accident: the + // impostor's answer IS believed. Nothing at this layer separates it from + // the gateway, and pretending otherwise is what the disclosure replaces. + assert.equal(code, 0, 'the echoing impostor still yields a confident answer') + const out = JSON.parse(json.stdout()) + assert.equal(out.status, 'ignored') + assert.equal(out.ignored, true) + assert.equal(out.endpoint_source, 'daemon_status', 'a live daemon named this port; another process owns it') + + // The contract: a machine reader can see the answer is unauthenticated + // without parsing prose. + assert.equal( + out.endpoint_authenticated, + false, + 'the JSON must state that the responder was not authenticated' + ) + + // And a human reader is told the same thing next to the answer, on the + // daemon_status path, which said nothing at all before. + const human = fakeCtx({ env }) + assert.equal(await runSessionStatus([], human.ctx), 0) + assert.match(human.stdout(), /session sess-spoofed: ignored/) + assert.match( + human.stdout(), + /nothing proves the responder .* is the HypAware gateway/, + 'the human answer must say the responder is unauthenticated' + ) + assert.match( + human.stdout(), + /only as trustworthy as this machine/, + 'the disclosure must name the trust root: this machine' + ) + assert.ok( + human.stdout().includes(base), + 'and it must name the endpoint it trusted - on this path no other line does' + ) + }) +}) + +test('`hyp session ignore` carries the disclosure too, where a spoofed success reads as done', async () => { + // The louder half of the harm in #451: `ignore` against an impostor prints + // "the gateway will drop this session" while nothing recorded the decision. + await withImpostorServer(async (base) => { + const home = daemonHome(base) + const env = { HYP_HOME: home, CLAUDE_CODE_SESSION_ID: 'sess-spoofed-write' } + + const human = fakeCtx({ env }) + assert.equal(await runSessionIgnore([], human.ctx), 0) + assert.match(human.stdout(), /ignored - the gateway will drop this session/) + assert.match(human.stdout(), /nothing proves the responder .* is the HypAware gateway/) + + const json = fakeCtx({ env }) + assert.equal(await runSessionIgnore(['--json'], json.ctx), 0) + assert.equal(JSON.parse(json.stdout()).endpoint_authenticated, false) + }) +}) + +test('the disclosure is unconditional: a real gateway answer carries it too', async () => { + // It has to be. The CLI cannot tell the real gateway from the impostor, so a + // disclosure printed only "when spoofed" would be a claim it cannot make - + // and its absence would read as proof of authenticity. + const set = /** @type {Set} */ (new Set()) + await withControlServer(set, async (base) => { + const ctx = fakeCtx({ endpoint: base, env: { CLAUDE_CODE_SESSION_ID: 'sess-real' } }) + // `not_ignored` from the genuine route: still unauthenticated, still said. + assert.equal(await runSessionStatus([], ctx.ctx), 1) + assert.match(ctx.stdout(), /not ignored - this session IS being recorded/) + assert.match(ctx.stdout(), /nothing proves the responder .* is the HypAware gateway/) + + const json = fakeCtx({ endpoint: base, env: { CLAUDE_CODE_SESSION_ID: 'sess-real' } }) + await runSessionStatus(['--json'], json.ctx) + const out = JSON.parse(json.stdout()) + assert.equal(out.endpoint_authenticated, false) + assert.equal(out.endpoint_source, 'config_listen', 'the pinned-listen path discloses it as well') + }) +}) + +test('an UNKNOWN answer reports the field too, so a reader never has to infer it from absence', async () => { + const ctx = fakeCtx({ endpoint: `http://127.0.0.1:${await closedPort()}`, env: { CLAUDE_CODE_SESSION_ID: 'sess-gone' } }) + assert.equal(await runSessionStatus(['--json'], ctx.ctx), 3) + const out = JSON.parse(ctx.stdout()) + assert.equal(out.status, 'unknown') + assert.equal(out.endpoint_authenticated, false) +}) + +/* ------------------------------------------------------------------ */ +/* helpers */ +/* ------------------------------------------------------------------ */ + +/** + * A listener that is not the gateway and does not need to be: it reads the + * session id off the query string or the body and echoes it back with + * `ignored: true`. Shape-valid, token-matching, and completely fabricated. + * + * @param {(base: string) => Promise} fn + */ +async function withImpostorServer(fn) { + const server = http.createServer((req, res) => { + /** @type {Buffer[]} */ + const chunks = [] + req.on('data', (c) => chunks.push(Buffer.from(c))) + req.on('end', () => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1') + let sessionId = url.searchParams.get('session_id') + if (sessionId === null) { + try { + sessionId = JSON.parse(Buffer.concat(chunks).toString('utf8')).session_id + } catch { + sessionId = null + } + } + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ session_id: sessionId, ignored: true, total: 1 })) + }) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(undefined))) + const addr = server.address() + const port = typeof addr === 'object' && addr ? addr.port : 0 + try { + await fn(`http://127.0.0.1:${port}`) + } finally { + await new Promise((resolve) => server.close(() => resolve(undefined))) + } +} + +/** The genuine control route, for the unconditional-disclosure test. + * @param {Set} set + * @param {(base: string) => Promise} fn + */ +async function withControlServer(set, fn) { + const handler = createControlHandler({ + ignoredSessions: set, + log: /** @type {any} */ ({ info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }), + }) + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1') + handler(req, res, url) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(undefined))) + const addr = server.address() + const port = typeof addr === 'object' && addr ? addr.port : 0 + try { + await fn(`http://127.0.0.1:${port}`) + } finally { + await new Promise((resolve) => server.close(() => resolve(undefined))) + } +} + +/** + * A `HYP_HOME` whose live daemon snapshot names the given endpoint's port. This + * is the strongest evidence the endpoint resolver has - a running daemon that + * reported the port it bound - and it is still not evidence about who answers + * on that port now. + * + * @param {string} base + * @returns {string} + */ +function daemonHome(base) { + const url = new URL(base) + const hypHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hyp-session-impostor-')) + const stateRoot = path.join(hypHome, 'hypaware') + fs.mkdirSync(path.join(stateRoot, 'run'), { recursive: true }) + writePidFile(stateRoot, /** @type {any} */ ({ pid: process.pid, runId: 'test-run', mode: 'foreground' })) + writeStatusFile(stateRoot, /** @type {any} */ ({ + state: 'running', + sources: [ + { + name: 'ai-gateway', + plugin: '@hypaware/ai-gateway', + state: 'ready', + details: { host: url.hostname, port: Number(url.port) }, + }, + ], + sinks: [], + })) + return hypHome +} + +/** + * @param {{ endpoint?: string, env?: Record }} args + */ +function fakeCtx(args) { + let out = '' + let err = '' + const listen = args.endpoint ? args.endpoint.replace(/^https?:\/\//, '') : undefined + const hypHome = args.env?.HYP_HOME ?? fs.mkdtempSync(path.join(os.tmpdir(), 'hyp-session-home-')) + const ctx = { + stdout: { write: (/** @type {string} */ s) => { out += s; return true } }, + stderr: { write: (/** @type {string} */ s) => { err += s; return true } }, + env: { HYP_HOME: hypHome, ...(args.env ?? {}) }, + cwd: '/repo/here', + config: { + version: 2, + plugins: listen + ? [{ name: '@hypaware/ai-gateway', config: { listen } }] + : [{ name: '@hypaware/ai-gateway' }], + }, + } + return { ctx: /** @type {any} */ (ctx), stdout: () => out, stderr: () => err } +} + +/** A port that was bound and released, so nothing answers on it. */ +async function closedPort() { + const server = http.createServer(() => {}) + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(undefined))) + const addr = server.address() + const port = typeof addr === 'object' && addr ? addr.port : 0 + await new Promise((resolve) => server.close(() => resolve(undefined))) + return port +}