From 7d1b11193ab4a3c267dbc53ce2e01e2153baa5c6 Mon Sep 17 00:00:00 2001 From: test Date: Fri, 31 Jul 2026 01:26:55 +0000 Subject: [PATCH 1/3] hyp status names recent clients from gateway-tracked entrypoints (#421 item 5) Issue #421 items 1-4 shipped in #425. Item 5 - "make `hyp status` or the verification surface identify recent Codex Desktop traffic when `entrypoint` or `originator` proves it" - was left open because closing it needed an architecture choice, recorded as four candidates in LLP 0141's consequences. The maintainer picked **option 4** on the issue thread: the gateway tracks last-seen entrypoints into `status.json` as traffic flows, and `hyp status` reports them with no cache read. - The AI-gateway source folds each committed batch of projected rows' `entrypoint` / `client_name` into an in-memory, bounded, LRU-evicting map (new `entrypoint_activity.js`), and exposes it as `recent_entrypoints` in its `status()` details. It counts and timestamps; it interprets nothing, so no client-specific string enters core (LLP 0130, LLP 0003). - The daemon re-reads started sources' `status()` details on every sink tick and once at shutdown. Boot captured them exactly once, which was enough for details fixed at bind time (host, port, LLP 0114 fallback marker) and is not enough for a detail that accrues. - `hyp status` renders a `recent clients:` block and `--json` a `recent_entrypoints` array, read from `status.json`. It is deliberately not liveness-gated: unlike a bound port, "last seen at T" stays true after the daemon exits, and the rendered age carries the staleness. - `claude-desktop verify` step 3 told users to "confirm capture via `hyp status`", which was not achievable. It now is, and the wording points at the line that answers it. Docs: new LLP 0164; LLP 0141's open consequence resolved; LLP 0086's status.json note corrected; `docs/ACCEPTANCE.md`'s `codex_desktop_capture` step 4 gains the query-free check plus its two expected-negative cases. Co-Authored-By: Claude --- docs/ACCEPTANCE.md | 29 +- .../ai-gateway/src/entrypoint_activity.js | 97 +++++++ .../ai-gateway/src/source.js | 27 +- .../claude-desktop/src/verify.js | 9 +- .../smoke/flows/gateway_codex_capture.js | 22 ++ ...6-attach-tracks-ephemeral-port.decision.md | 5 +- ...esktop-rides-the-codex-adapter.decision.md | 21 +- ...ients-from-gateway-entrypoints.decision.md | 139 ++++++++++ src/core/commands/status.js | 54 ++++ src/core/daemon/runtime.js | 30 ++ src/core/daemon/status.js | 61 ++++- src/core/daemon/types.d.ts | 27 ++ .../daemon-source-details-refresh.test.js | 166 +++++++++++ test/core/status-recent-entrypoints.test.js | 258 ++++++++++++++++++ .../ai-gateway-entrypoint-activity.test.js | 106 +++++++ 15 files changed, 1033 insertions(+), 18 deletions(-) create mode 100644 hypaware-core/plugins-workspace/ai-gateway/src/entrypoint_activity.js create mode 100644 llp/0164-status-names-recent-clients-from-gateway-entrypoints.decision.md create mode 100644 test/core/daemon-source-details-refresh.test.js create mode 100644 test/core/status-recent-entrypoints.test.js create mode 100644 test/plugins/ai-gateway-entrypoint-activity.test.js diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 219bdc05..3b9e1768 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -26,7 +26,8 @@ forwarding, or anything on a machine other than the one you ran it on. **Requires:** macOS with Codex Desktop installed and signed in, HypAware installed from the package under test, and a working `~/.codex`. -**Related:** [LLP 0141](../llp/0141-codex-desktop-rides-the-codex-adapter.decision.md). +**Related:** [LLP 0141](../llp/0141-codex-desktop-rides-the-codex-adapter.decision.md), +[LLP 0164](../llp/0164-status-names-recent-clients-from-gateway-entrypoints.decision.md). ### Steps @@ -73,6 +74,23 @@ installed from the package under test, and a working `~/.codex`. `entrypoint` value you observed into the release notes: that string is the one every "is Desktop landing?" query keys off. + Then confirm the same answer without a query, which is the check a user + who has not learned SQL will actually run: + + ```sh + hyp status + hyp status --json | grep -A 6 recent_entrypoints + ``` + + Pass condition: a `recent clients:` line naming the same `entrypoint` + string you just observed, with an age of a few minutes. This is read from + the running daemon's `status.json`, not from the cache + ([LLP 0164](../llp/0164-status-names-recent-clients-from-gateway-entrypoints.decision.md)), + so two things follow and both are expected, not failures: a daemon that + has been restarted since the conversation shows nothing here (the rows are + still in the cache - step 4 is the durable check), and the list is bounded + to what this daemon process has seen. + 5. Confirm the backfill route independently. The rollout tree is shared by Codex CLI and Codex Desktop, so the session from step 3 must also be re-importable from disk: @@ -137,7 +155,14 @@ installed from the package under test, and a working `~/.codex`. - Rows arrive but `entrypoint` is null: Codex sent no `originator` header on that route. Capture still worked; attribution did not. File that as its own issue with the observed request path, and do not paper over it by matching - on `client_name` alone. + on `client_name` alone. `hyp status`'s `recent clients:` line will also be + missing the Desktop entry, for the same one reason: it counts `entrypoint` + values and invents nothing for a row that has none. +- The query in step 4 finds Desktop rows but `hyp status` names no recent + client: the daemon that captured them has since restarted (the tracker is + in-memory and daemon-scoped by design), or the gateway wrote no status + refresh before it exited. Re-run step 3 against the current daemon before + filing anything. - Step 5 finds no rollout for the session: Desktop wrote its history somewhere other than `$CODEX_HOME/sessions`. That would invalidate [LLP 0141](../llp/0141-codex-desktop-rides-the-codex-adapter.decision.md)'s diff --git a/hypaware-core/plugins-workspace/ai-gateway/src/entrypoint_activity.js b/hypaware-core/plugins-workspace/ai-gateway/src/entrypoint_activity.js new file mode 100644 index 00000000..94f5030d --- /dev/null +++ b/hypaware-core/plugins-workspace/ai-gateway/src/entrypoint_activity.js @@ -0,0 +1,97 @@ +// @ts-check + +/** + * How many distinct `entrypoint` values the tracker keeps. The set is + * naturally tiny (one per client surface on the machine: `codex-tui`, + * whatever Codex Desktop reports, `local-agent`, ...), but `entrypoint` + * is a client-supplied string that rides in from request headers and + * rollout metadata, so an odd or hostile client must not be able to grow + * a daemon-lifetime map without bound. On overflow the least recently + * seen entry is evicted, which is exactly the entry a "recent clients" + * readout would drop anyway. + */ +const MAX_TRACKED_ENTRYPOINTS = 32 + +/** + * Track which client surfaces have produced rows through this gateway, + * and when. The daemon lifts the snapshot into `status.json` so + * `hyp status` can answer "did Codex Desktop traffic arrive recently?" + * without a cache read and without any client-specific knowledge in + * core: the tracker never interprets an `entrypoint`, it only counts and + * timestamps whatever the projector put in the column. + * + * In-memory and daemon-scoped by construction. It is an activity signal, + * not a store: the cache remains the only durable record of a row. + * + * @param {{ max?: number, now?: () => number }} [options] + * @ref LLP 0164#gateway-tracks-what-core-cannot-name [implements]: the gateway counts and timestamps entrypoints it never interprets + */ +export function createEntrypointActivity(options = {}) { + const max = options.max ?? MAX_TRACKED_ENTRYPOINTS + const now = options.now ?? (() => Date.now()) + /** @type {Map} */ + const seen = new Map() + + return { + /** + * Fold a batch of projected message rows into the activity map. Rows + * with no `entrypoint` are ignored rather than bucketed under a + * placeholder: "unknown" is not a client surface, and inventing one + * would put a name in `hyp status` that no query can reproduce. + * + * @param {readonly Record[]} rows + */ + record(rows) { + if (!Array.isArray(rows) || rows.length === 0) return + const at = now() + for (const row of rows) { + if (!row || typeof row !== 'object') continue + const entrypoint = row.entrypoint + if (typeof entrypoint !== 'string' || entrypoint.length === 0) continue + const clientName = typeof row.client_name === 'string' && row.client_name.length > 0 + ? row.client_name + : null + const existing = seen.get(entrypoint) + if (existing) { + existing.lastSeenMs = at + existing.rows += 1 + if (clientName) existing.clientName = clientName + // Re-insert so Map iteration order stays least-recently-seen + // first, which is what the eviction below relies on. + seen.delete(entrypoint) + seen.set(entrypoint, existing) + continue + } + seen.set(entrypoint, { clientName, lastSeenMs: at, rows: 1 }) + while (seen.size > max) { + const oldest = seen.keys().next() + if (oldest.done) break + seen.delete(oldest.value) + } + } + }, + + /** + * The status-file view: most recently seen first, ISO timestamps, and + * snake_case keys because this lands verbatim in `status.json` + * alongside the gateway's other `details`. + * + * @returns {{ entrypoint: string, client_name: string | null, last_seen: string, rows: number }[]} + */ + snapshot() { + return Array.from(seen.entries()) + .map(([entrypoint, entry]) => ({ + entrypoint, + client_name: entry.clientName, + last_seen: new Date(entry.lastSeenMs).toISOString(), + rows: entry.rows, + })) + .sort((a, b) => (a.last_seen < b.last_seen ? 1 : a.last_seen > b.last_seen ? -1 : 0)) + }, + + /** @returns {number} */ + size() { + return seen.size + }, + } +} diff --git a/hypaware-core/plugins-workspace/ai-gateway/src/source.js b/hypaware-core/plugins-workspace/ai-gateway/src/source.js index 94751dba..e1438cca 100644 --- a/hypaware-core/plugins-workspace/ai-gateway/src/source.js +++ b/hypaware-core/plugins-workspace/ai-gateway/src/source.js @@ -10,6 +10,7 @@ import { import { compileConfig, FALLBACK_LISTEN } from './config.js' import { createControlHandler } from './control.js' import { AI_GATEWAY_SCHEMA_COLUMNS, aiGatewayTablePath, DATASET_NAME } from './dataset.js' +import { createEntrypointActivity } from './entrypoint_activity.js' import { createAiGatewayMessageProjector } from './message_projector.js' import { startProxy } from './proxy.js' import { createRecorder } from './recorder.js' @@ -36,8 +37,17 @@ export function createStartSource(state) { * @returns {Promise} */ return async function startAiGatewaySource(ctx) { - /** @type {{ rowsWritten: number, exchangeBytes: number, lastError: string | undefined, listenFallbackFrom: string | undefined }} */ - const liveState = { rowsWritten: 0, exchangeBytes: 0, lastError: undefined, listenFallbackFrom: undefined } + /** @type {{ rowsWritten: number, exchangeBytes: number, lastError: string | undefined, listenFallbackFrom: string | undefined, entrypoints: ReturnType }} */ + const liveState = { + rowsWritten: 0, + exchangeBytes: 0, + lastError: undefined, + listenFallbackFrom: undefined, + // Lives on `liveState`, not on the per-bind closure below, so a + // config reload (which tears the listener down and builds a fresh + // recorder) does not erase what this daemon has already seen. + entrypoints: createEntrypointActivity(), + } let proxy = await launchListener(ctx, state, liveState) @@ -61,6 +71,13 @@ export function createStartSource(state) { ...(liveState.listenFallbackFrom ? { listen_fallback: true, listen_fallback_from: liveState.listenFallbackFrom } : {}), + // Which client surfaces have actually produced rows through this + // gateway, and when. The daemon refreshes source details on every + // tick, so this reaches status.json steadily and `hyp status` can + // answer "did Codex Desktop traffic arrive recently?" with no + // cache read (LLP 0164). + // @ref LLP 0164#status-reads-it-from-the-status-file [implements]: last-seen entrypoints ride the gateway source's status details + recent_entrypoints: liveState.entrypoints.snapshot(), }, } if (liveState.lastError) status.lastError = liveState.lastError @@ -93,7 +110,7 @@ export function createStartSource(state) { * * @param {PluginActivationContext} ctx * @param {GatewayState} state - * @param {{ rowsWritten: number, exchangeBytes: number, lastError: string | undefined, listenFallbackFrom: string | undefined }} liveState + * @param {{ rowsWritten: number, exchangeBytes: number, lastError: string | undefined, listenFallbackFrom: string | undefined, entrypoints: ReturnType }} liveState * @returns {Promise} */ async function launchListener(ctx, state, liveState) { @@ -132,6 +149,10 @@ async function launchListener(ctx, state, liveState) { if (messageRows.length > 0) { await ctx.storage.appendRows(tablePath, [...AI_GATEWAY_SCHEMA_COLUMNS], messageRows) liveState.rowsWritten += messageRows.length + // Recorded only after the append resolves: "recent clients" in + // `hyp status` must mean rows that landed, not rows that were + // projected and then lost to a write failure. + liveState.entrypoints.record(messageRows) kernelInstruments.rowsWritten.add(messageRows.length, { [Attr.DATASET]: DATASET_NAME, [Attr.PLUGIN]: PLUGIN_NAME, diff --git a/hypaware-core/plugins-workspace/claude-desktop/src/verify.js b/hypaware-core/plugins-workspace/claude-desktop/src/verify.js index b108974a..8b398526 100644 --- a/hypaware-core/plugins-workspace/claude-desktop/src/verify.js +++ b/hypaware-core/plugins-workspace/claude-desktop/src/verify.js @@ -86,9 +86,14 @@ export async function runVerify(argv, cmdCtx, opts) { cmdCtx.stdout.write(' 2. Send it a message.\n') // 'local-agent' is what Desktop's 3p mode writes on the current build; // 'claude-desktop-3p' was observed on an earlier one (LLP 0133#attribution). + // Pointing at `hyp status` was aspirational until the gateway started + // tracking last-seen entrypoints: the command activates no plugins and + // reads no cache, so it had no way to see a row. It does now. + // @ref LLP 0164#status-reads-it-from-the-status-file [implements]: "confirm capture via hyp status" is a check a human can actually run cmdCtx.stdout.write( - " 3. Confirm capture: rows land under entrypoint 'local-agent' (older builds: 'claude-desktop-3p') " - + "in ai_gateway_messages (check via 'hyp status' or 'hyp mcp').\n", + " 3. Confirm capture: run 'hyp status' and look for entrypoint 'local-agent' " + + "(older builds: 'claude-desktop-3p') under 'recent clients'. The rows themselves " + + "are in ai_gateway_messages (query via 'hyp query' or 'hyp mcp').\n", ) if (!result.ok) { diff --git a/hypaware-core/smoke/flows/gateway_codex_capture.js b/hypaware-core/smoke/flows/gateway_codex_capture.js index a1832098..48f20503 100644 --- a/hypaware-core/smoke/flows/gateway_codex_capture.js +++ b/hypaware-core/smoke/flows/gateway_codex_capture.js @@ -171,6 +171,28 @@ export async function run({ harness, expect }) { await sleep(120) await handle.stop() await handle.done + + // ----- The gateway's last-seen entrypoints reached status.json ----- + // This is the half of `hyp status` that answers "did Desktop traffic + // arrive?" with no dataset registry and no cache read (LLP 0164). The + // originator above is synthetic, as the header block says, so what this + // proves is the plumbing: an entrypoint the projector wrote into a row it + // committed is readable from the status file after the daemon exits. + // @ref LLP 0164#status-reads-it-from-the-status-file [tests]: + const { readStatusFile, recentEntrypointsFromSources } = await import('../../../src/core/daemon/status.js') + const finalStatus = readStatusFile(path.join(harness.hypHome, 'hypaware')) + const recent = recentEntrypointsFromSources(finalStatus?.sources) + expect.that( + 'status.json: recent_entrypoints names the Desktop originator the rows carry', + recent, + (v) => Array.isArray(v) && v.some((e) => e.entrypoint === 'Codex Desktop' && e.rows > 0), + ) + expect.that( + 'status.json: the recent-entrypoint entry carries the projected client_name', + recent.find((e) => e.entrypoint === 'Codex Desktop')?.clientName, + (v) => typeof v === 'string' && v.length > 0, + ) + await obs.shutdown() await openai.close() diff --git a/llp/0086-attach-tracks-ephemeral-port.decision.md b/llp/0086-attach-tracks-ephemeral-port.decision.md index 0693198f..92a926e4 100644 --- a/llp/0086-attach-tracks-ephemeral-port.decision.md +++ b/llp/0086-attach-tracks-ephemeral-port.decision.md @@ -37,7 +37,10 @@ so "the proven value changes" is the common case, not an edge. The data to detect the drift already exists on disk: the gateway source's `status()` returns `details: { host, port, ... }`, which `startConfiguredSources` -captures into each `SourceSnapshot.details` in `status.json`; and the client +captures into each `SourceSnapshot.details` in `status.json` (and, since +[LLP 0164](./0164-status-names-recent-clients-from-gateway-entrypoints.decision.md), +the tick loop re-captures on the way past - which changes nothing here, since +`host` and `port` are fixed at bind time); and the client settings marker records the `port` it attached at (`probeClientAttachFromDescriptor` reads it back). Nothing compared them. diff --git a/llp/0141-codex-desktop-rides-the-codex-adapter.decision.md b/llp/0141-codex-desktop-rides-the-codex-adapter.decision.md index 58c178f4..b4b79b87 100644 --- a/llp/0141-codex-desktop-rides-the-codex-adapter.decision.md +++ b/llp/0141-codex-desktop-rides-the-codex-adapter.decision.md @@ -95,17 +95,20 @@ a `covered_by` attribute naming both routes, so the flag cannot be read as - The picker gains no new row. Codex Desktop is not a separate pick, because it is not a separate attach. -- `hyp status` still cannot say "Codex Desktop traffic arrived recently". - It boots with no plugins activated by design (`decideBootProfile` returns +- `hyp status` can say "Codex Desktop traffic arrived recently" as of + [LLP 0164](./0164-status-names-recent-clients-from-gateway-entrypoints.decision.md), + which resolved what this document left open. The constraint that made it a + design decision rather than a bug fix still holds: `hyp status` boots with + no plugins activated by design (`decideBootProfile` returns `{ activate: [] }` for `status`), so it has no dataset registry and no - cache read; and client-specific knowledge in core would contradict + cache read, and client-specific knowledge in core would contradict [LLP 0130](./0130-declarative-picker-descriptors.decision.md)'s - "rendering needs no plugin code" and LLP 0003's core/plugin split. Closing - that gap needs a design decision (a declarative activity-probe descriptor, - a status boot-profile change, an entrypoint section in - `hyp query overview`, or gateway-tracked last-seen entrypoints in - `status.json`), and is deliberately left open here. Until then the - supported check is the query itself, documented in + "rendering needs no plugin code" and LLP 0003's core/plugin split. Of the + four candidate shapes named here, the gateway-tracked one was chosen: the + gateway counts and timestamps the `entrypoint` values it writes, the daemon + carries them into `status.json`, and core renders them without + interpreting any of them. The list is in-memory and daemon-scoped, so the + **query remains the durable check** and is still the pass condition in [`docs/ACCEPTANCE.md`](../docs/ACCEPTANCE.md). - Proving the live Desktop route end to end needs Codex Desktop on a real machine, which no hermetic smoke can supply. `docs/ACCEPTANCE.md` carries diff --git a/llp/0164-status-names-recent-clients-from-gateway-entrypoints.decision.md b/llp/0164-status-names-recent-clients-from-gateway-entrypoints.decision.md new file mode 100644 index 00000000..d7dad1e7 --- /dev/null +++ b/llp/0164-status-names-recent-clients-from-gateway-entrypoints.decision.md @@ -0,0 +1,139 @@ +# LLP 0164: `hyp status` names recent clients from gateway-tracked entrypoints + +**Type:** Decision +**Status:** Accepted +**Systems:** Sources, Gateway, Daemon, Onboarding +**Author:** Phil / Claude +**Date:** 2026-07-31 +**Related:** LLP 0003, LLP 0017, LLP 0086, LLP 0114, LLP 0130, LLP 0131, LLP 0133, LLP 0141 + +> Closes the one consequence [LLP 0141](./0141-codex-desktop-rides-the-codex-adapter.decision.md) +> left deliberately open: `hyp status` could not say "Codex Desktop traffic +> arrived recently". The gateway now counts and timestamps the `entrypoint` +> values it writes into rows, the daemon carries that into `status.json` on +> every tick, and `hyp status` renders it. No dataset registry, no cache read, +> and no client-specific string anywhere in core. + +## Context + +[LLP 0141](./0141-codex-desktop-rides-the-codex-adapter.decision.md) made +Codex Desktop coverage legible in the picker, the reference docs, and the +`unsupported_location` event, and wrote the manual `codex_desktop_capture` +procedure. It closed with one item open, and named four candidate shapes for +closing it: + +1. a declarative activity-probe key on the client descriptor, +2. changing `hyp status`'s boot profile, or giving it a cache read, +3. a fifth entrypoint section in `hyp query overview`, +4. the gateway tracking last-seen entrypoints into `status.json`. + +The constraint that rules out the obvious answers: `decideBootProfile` +(`src/core/cli/dispatch.js`) returns `{ activate: [] }` for `status`, on +purpose - a diagnostics command must not bind a gateway or OTLP listener just +to print a report. So `hyp status` has no dataset registry and cannot see a +row. Teaching core which `entrypoint` strings mean "Codex Desktop" would put +client-specific data in core, against +[LLP 0130](./0130-declarative-picker-descriptors.decision.md)'s "rendering +needs no plugin code" and LLP 0003's core/plugin split. + +The same gap had already produced a false instruction elsewhere: +`claude-desktop verify` told the user to "confirm capture via `hyp status`", +which was not achievable for exactly this reason. + +## Decision + +**Option 4.** The gateway tracks last-seen entrypoints; `hyp status` reports +them from `status.json`. + +**The gateway counts and +timestamps entrypoints it never interprets.** As each batch of projected +message rows is committed, the AI-gateway source folds their `entrypoint` and +`client_name` values into an in-memory map: last-seen timestamp and a row +count per distinct `entrypoint`. It is recorded *after* the append resolves, +so "recent clients" means rows that landed, not rows that were projected and +then lost to a write failure. + +Three properties make this the client-agnostic option rather than a disguised +version of option 2: + +- **No interpretation.** Nothing in the gateway or in core decides what a + value means. `codex-tui`, whatever Codex Desktop reports, and + `local-agent` are all just strings that arrived in a column. The vendor + owns them ([LLP 0141](./0141-codex-desktop-rides-the-codex-adapter.decision.md) + pins none of them), and the value printed is byte-identical to the one a + follow-up `ai_gateway_messages` query filters on. +- **No placeholder.** A row with no `entrypoint` is skipped, not bucketed + under "unknown". A name in `hyp status` that no query can reproduce would + be worse than a short list. +- **Bounded.** `entrypoint` is a client-supplied string riding in from + request headers, so the map is capped (32) with least-recently-seen + eviction. An odd or hostile client cannot grow daemon-lifetime state, and + the entry an overflow drops is the one a "recent" readout would have + dropped anyway. + +This is an **activity signal, not a store**. It is in-memory and +daemon-scoped: it dies with the process and is never written anywhere but the +status snapshot. The cache remains the only durable record of a row. + +**`hyp status` reads it from +`status.json`, which the daemon now refreshes.** Boot wrote each source's +`status()` details exactly once (`startConfiguredSources`), which was enough +while every detail was fixed at bind time: host, port, and the LLP 0114 +fallback marker never change after the bind. It is not enough for a detail +that accrues. So the daemon re-reads started sources' details on every sink +tick, and once more at shutdown before the sources are torn down (a daemon +that stopped between ticks must not leave a file claiming it saw nothing). +Name, plugin, and state are left alone: liveness is the lifecycle's business, +not a status probe's, and the refresh is best-effort per source like +`safeStatus` itself. + +Core's whole share of the feature is `recentEntrypointsFromSources`: find the +gateway source's snapshot, validate the shape, order by time, drop what is +malformed. `hyp status` then renders a `recent clients:` block, and +`--json` a `recent_entrypoints` array. The block appears only when the daemon +recorded something, so an install that has never captured keeps the V1 text +surface unchanged. + +This also makes `claude-desktop verify`'s step 3 true rather than +aspirational: Claude Desktop's 3p route lands under `entrypoint: +"local-agent"` ([LLP 0133](./0133-desktop-solo-sudo-plist.decision.md)#attribution), +which is a value like any other, so it appears with no Claude-specific code. + +**Unlike a bound port, it is not gated on +daemon liveness.** `resolveLiveGatewayEndpointFromStatus` checks the pid file +first, because a port in a stale status file is a claim about *now* and +becomes false the moment the daemon dies. "Last seen at T" does not: it stays +true afterwards. So the list is reported whether or not a daemon is running, +and the rendered age (`just now` / `5m ago` / `3h ago` / `2d ago`) is what +carries the staleness. The age is coarse on purpose - the question the line +answers is "was that just now, or last week?", and a precise timestamp would +invite reading the value as a query bound it is not. + +## Consequences + +- **A restart clears the list.** The tracker is in-memory, so a daemon that + restarts after a conversation reports no recent client for it even though + the rows are in the cache. This is the price of not adding a durable store + for a diagnostic, and it is documented as an expected outcome in + [`docs/ACCEPTANCE.md`](../docs/ACCEPTANCE.md)'s `codex_desktop_capture` + step 4 and its failure notes. The query remains the durable check. +- **Row counts are per daemon process, not per install.** `rows` in the + status output counts what this process committed. It is an activity + signal; `select count(*) from ai_gateway_messages` is the number. +- **Only gateway-captured surfaces appear.** Backfill writes rows through the + materializer, not through the live gateway, so importing a Desktop rollout + does not put Desktop in `recent clients`. That is the honest reading of the + line ("traffic arrived here"), but it means the two capture routes of + [LLP 0141](./0141-codex-desktop-rides-the-codex-adapter.decision.md) are + not symmetric on this surface. +- **`status.json` writes are marginally larger and slightly more frequent in + content**, since the tick now re-probes source details. The tick already + ran and already wrote the file (LLP 0017), so this adds a bounded + per-source `status()` call, not a new loop. +- **`hyp query overview` was not changed.** Option 3 would have altered that + block's calibrated window budget (`OVERVIEW_SECTIONS.length` feeds + `rowsAffordable`, LLP 0135#window); this decision leaves it untouched. +- **The live route is still only provable on real hardware.** Nothing here + changes that: `gateway_codex_capture` asserts the plumbing against a + synthetic Desktop-shaped exchange, and the real-app claim stays with the + manual `codex_desktop_capture` procedure. diff --git a/src/core/commands/status.js b/src/core/commands/status.js index fd7e862d..b31098c3 100644 --- a/src/core/commands/status.js +++ b/src/core/commands/status.js @@ -194,6 +194,17 @@ export function renderStatusJson({ report, clientNames, datasets, cacheRoot }) { client_sync: report.clientSync ? { syncing: report.clientSync.syncing, local_only: report.clientSync.localOnly } : null, + // Client surfaces the daemon's gateway actually produced rows for (LLP + // 0164). Always an array so a consumer can pin the key; empty means "no + // daemon has recorded any", which is not the same as "no traffic ever" - + // the cache is the durable record, this is only the activity signal. + // @ref LLP 0164#status-reads-it-from-the-status-file [implements]: --json carries the machine-readable last-seen list + recent_entrypoints: report.recentEntrypoints.map((e) => ({ + entrypoint: e.entrypoint, + client_name: e.clientName, + last_seen: e.lastSeen, + rows: e.rows, + })), datasets: datasets.map((d) => ({ name: d.name, plugin: d.plugin })), cache: { dir: cacheRoot, @@ -367,6 +378,24 @@ export function renderStatusText({ report, clientNames, datasets, cacheRoot, std ) } + // Which client surfaces have actually produced rows, and when (LLP 0164). + // This is the line that answers "did Codex Desktop traffic arrive?" and + // "did Claude Desktop's 3p route land?" without a query. Rendered only when + // the daemon recorded something, so an install that has never captured + // keeps the V1 text surface unchanged; the entrypoint strings are printed + // verbatim because they are the client's to choose, and they are the exact + // values a follow-up `ai_gateway_messages` query filters on. + // @ref LLP 0164#status-reads-it-from-the-status-file [implements]: hyp status names recent client surfaces and their age + if (report.recentEntrypoints.length > 0) { + stdout.write(' recent clients:\n') + for (const e of report.recentEntrypoints) { + const client = e.clientName ? ` (${e.clientName})` : '' + stdout.write( + ` - ${e.entrypoint}${client} last seen ${formatEntrypointAge(e.lastSeen)}, ${e.rows} row${e.rows === 1 ? '' : 's'}\n` + ) + } + } + stdout.write(` cache: ${cacheRoot}\n`) stdout.write( ` cache retention: ${report.retention.days} days${ @@ -462,6 +491,31 @@ export function renderStatusText({ report, clientNames, datasets, cacheRoot, std } } +/** + * Human-readable age of a `recent clients` entry. Coarse on purpose: the + * question the line answers is "was that just now, or last week?", and a + * precise timestamp would invite reading the value as a query bound it is + * not (the durable record is the cache). A future timestamp - a status file + * written under a clock that has since gone backwards - reads as `just now` + * rather than a negative age. + * + * @param {string} lastSeen ISO timestamp + * @param {number} [nowMs] + * @returns {string} + */ +export function formatEntrypointAge(lastSeen, nowMs = Date.now()) { + const thenMs = Date.parse(lastSeen) + if (Number.isNaN(thenMs)) return 'at an unreadable time' + const deltaSec = Math.floor((nowMs - thenMs) / 1000) + if (deltaSec < 60) return 'just now' + const minutes = Math.floor(deltaSec / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} + /** * Per-entry layer provenance tag for `hyp status` text. Empty on a host * that never joined (no central layer → the V1 surface is unchanged); diff --git a/src/core/daemon/runtime.js b/src/core/daemon/runtime.js index 07ffbde3..a713b420 100644 --- a/src/core/daemon/runtime.js +++ b/src/core/daemon/runtime.js @@ -471,6 +471,30 @@ export async function runDaemon(opts = {}) { } // ----- Tick loop ----- + /** + * Re-read every started source's `status()` details into the snapshot + * list. Boot writes the details once (`startConfiguredSources`), which + * was enough while every detail was fixed at bind time (host, port, + * fallback marker). It is not enough for details that accrue as traffic + * flows: the gateway's `recent_entrypoints` would be frozen at "nothing + * seen yet" for the daemon's whole life, and `hyp status` reads exactly + * this file. Name, plugin, and state are left alone - liveness is the + * lifecycle's business, not a status probe's. + * + * Best-effort per source, like `safeStatus` itself: a source whose + * probe throws or returns nothing keeps the details it already had + * rather than losing them. + * + * @ref LLP 0164#status-reads-it-from-the-status-file [implements]: the tick refreshes source details so accruing details reach status.json + */ + async function refreshSourceDetails() { + for (const snap of status.sources) { + if (snap.state !== 'started') continue + const details = await safeStatus(boot.runtime, snap.name) + if (details !== undefined) snap.details = details + } + } + async function runTick() { const now = new Date() await withSpan( @@ -502,6 +526,7 @@ export async function runDaemon(opts = {}) { fileLog.error('daemon.tick_failed', { message }) }) status.sinks = collectSinkSnapshots({ runtime: boot.runtime, sinkSnapshots }) + await refreshSourceDetails() persist() } @@ -579,6 +604,11 @@ export async function runDaemon(opts = {}) { // mid-import. Abandoning a pass would orphan the spawned `hyp backfill` // child and interrupt the marker write. await reconcileScheduler.settle() + // Last chance to capture accruing source details: the sources are still + // running here, and after `stopAllSources` below their probes are gone. + // A daemon that never reached a tick (or stopped between ticks) would + // otherwise leave a status file claiming no client was ever seen. + await refreshSourceDetails() persist({ state: 'stopping' }) fileLog.info('daemon.stopping', { reason }) diff --git a/src/core/daemon/status.js b/src/core/daemon/status.js index f389c17b..6afb8f27 100644 --- a/src/core/daemon/status.js +++ b/src/core/daemon/status.js @@ -40,7 +40,7 @@ import { /** * @import { HypAwareV2Config, PluginConfigInstance } from '../../../hypaware-plugin-kernel-types.js' * @import { ClientActionStatus, ConfigControlStatus, ConfigValidationError } from '../../../src/core/config/types.js' - * @import { ClientActionReport, ClientActionsReport, ClientAttachReport, CollectStatusOptions, DaemonStatus, HypAwareStatusReport, ServiceState, SinkSnapshot, SourceSnapshot, StatusDiagnostic } from '../../../src/core/daemon/types.js' + * @import { ClientActionReport, ClientActionsReport, ClientAttachReport, CollectStatusOptions, DaemonStatus, HypAwareStatusReport, RecentEntrypoint, ServiceState, SinkSnapshot, SourceSnapshot, StatusDiagnostic } from '../../../src/core/daemon/types.js' * @import { Dirent } from 'node:fs' * @import { ClientDescriptor, LoadedManifest, PluginCatalog } from '../../../src/core/types.js' */ @@ -124,6 +124,52 @@ export function gatewaySourceDetails(sources) { return { host, port, listenFallback, ...(listenFallbackFrom ? { listenFallbackFrom } : {}) } } +/** + * Lift the gateway source's `recent_entrypoints` detail out of a status-file + * source-snapshot list, most recently seen first. + * + * This is the whole of core's knowledge about client surfaces: it validates + * shape and orders by time, and never interprets an `entrypoint` string. Which + * value means "Codex Desktop" stays Codex's business, exactly as + * [LLP 0130]'s "rendering needs no plugin code" and LLP 0003's core/plugin + * split require. Malformed or partial entries are dropped rather than repaired: + * a name in `hyp status` that no query could reproduce would be worse than a + * short list. + * + * @param {SourceSnapshot[] | undefined} sources + * @returns {RecentEntrypoint[]} + * @ref LLP 0164#status-reads-it-from-the-status-file [implements]: hyp status answers from status.json, with no dataset registry and no cache read + */ +export function recentEntrypointsFromSources(sources) { + const list = Array.isArray(sources) ? sources : [] + const source = + list.find((s) => s && s.plugin === GATEWAY_PLUGIN_NAME) ?? + list.find((s) => s && s.name === 'ai-gateway') + const rawDetails = source && typeof source.details === 'object' ? source.details : undefined + if (!rawDetails) return [] + const raw = /** @type {Record} */ (rawDetails).recent_entrypoints + if (!Array.isArray(raw)) return [] + /** @type {RecentEntrypoint[]} */ + const out = [] + for (const item of raw) { + if (!isPlainObject(item)) continue + const entrypoint = item.entrypoint + const lastSeen = item.last_seen + if (typeof entrypoint !== 'string' || entrypoint.length === 0) continue + if (typeof lastSeen !== 'string' || Number.isNaN(Date.parse(lastSeen))) continue + out.push({ + entrypoint, + clientName: typeof item.client_name === 'string' && item.client_name.length > 0 + ? item.client_name + : null, + lastSeen, + rows: typeof item.rows === 'number' && Number.isFinite(item.rows) ? item.rows : 0, + }) + } + out.sort((a, b) => (a.lastSeen < b.lastSeen ? 1 : a.lastSeen > b.lastSeen ? -1 : 0)) + return out +} + /** * Resolve the AI gateway's live bound base URL from the on-disk daemon status * snapshot, **guarded by a daemon-liveness check** so a stale snapshot from a @@ -450,6 +496,18 @@ export async function collectHypAwareStatus(opts = {}) { sources.push(...inferConfiguredSources(activePlugins)) } + // ----- recent client surfaces (LLP 0164) ----- + // Read from the status file specifically, never from `sources` above: the + // daemon is the only process traffic flows through, so an in-process + // gateway source booted by this very CLI call has by definition seen + // nothing. It is deliberately NOT gated on daemon liveness the way + // `resolveLiveGatewayEndpointFromStatus` is - a bound port is a claim + // about now and goes stale the moment the daemon dies, whereas "last seen + // at T" stays true afterwards, and the rendered age makes staleness + // self-evident. + // @ref LLP 0164#not-liveness-gated [implements]: a last-seen timestamp survives its daemon; the rendered age carries the staleness + const recentEntrypoints = recentEntrypointsFromSources(daemonStatusFile?.sources) + // Sinks are derived from the loaded config (so the count reflects // "how many sinks does the user have configured?", the same number // a fresh kernel boot or a running daemon would surface). When @@ -720,6 +778,7 @@ export async function collectHypAwareStatus(opts = {}) { clientActions, usagePolicy, firstSyncHoldDeadline, + recentEntrypoints, } } diff --git a/src/core/daemon/types.d.ts b/src/core/daemon/types.d.ts index 957cd260..f31ce53d 100644 --- a/src/core/daemon/types.d.ts +++ b/src/core/daemon/types.d.ts @@ -31,6 +31,25 @@ export interface SourceSnapshot { details?: object } +/** + * One client surface the running gateway has produced rows for, lifted + * out of the gateway source's `status()` details in `status.json`. + * + * `entrypoint` is verbatim whatever the projector wrote into the row's + * `entrypoint` column (Codex owns `codex-tui` and whatever Codex Desktop + * reports; Claude Desktop's 3p route reports `local-agent`). Core never + * interprets it, which is what keeps this surface client-agnostic. + */ +export interface RecentEntrypoint { + entrypoint: string + /** `client_name` last seen with this entrypoint, or null when unset. */ + clientName: string | null + /** ISO timestamp of the most recent row projected under it. */ + lastSeen: string + /** Rows projected under it since the daemon booted. */ + rows: number +} + export interface SinkSnapshot { instance: string plugin: string @@ -250,6 +269,14 @@ export interface HypAwareStatusReport { * holding ticks. A held machine must never be a silent state (LLP 0100 R9). */ firstSyncHoldDeadline: number | null + /** + * Client surfaces the running (or last-run) daemon's gateway produced rows + * for, most recent first, read from `status.json` (LLP 0164). Empty when no + * daemon has run for this state root, when the gateway recorded nothing, or + * when the daemon predates the tracker - `hyp status` activates no plugins + * and reads no cache, so this is the only place the answer can come from. + */ + recentEntrypoints: RecentEntrypoint[] } export interface CollectStatusOptions { diff --git a/test/core/daemon-source-details-refresh.test.js b/test/core/daemon-source-details-refresh.test.js new file mode 100644 index 00000000..0a490209 --- /dev/null +++ b/test/core/daemon-source-details-refresh.test.js @@ -0,0 +1,166 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { runDaemon } from '../../src/core/daemon/runtime.js' +import { readStatusFile } from '../../src/core/daemon/status.js' +import { defaultConfigPath } from '../../src/core/config/schema.js' +import { writeLock } from '../../src/core/plugin_install/lock.js' + +// Boot wrote each source's `status()` details exactly once, which was enough +// while every detail was fixed at bind time. It is not enough for details that +// accrue as traffic flows: the gateway's `recent_entrypoints` would be frozen +// at "nothing seen yet" for the daemon's whole life, and `hyp status` reads +// exactly this file. +// @ref LLP 0164#status-reads-it-from-the-status-file [tests]: + +const PLUGIN = '@third-party/accruing-fixture' + +/** + * Stage a plugin whose source's `status()` details change on every call, the + * way the gateway's do as exchanges land. + * + * @param {string} hypHome + * @returns {Promise} + */ +async function stageAccruingPlugin(hypHome) { + const installDir = path.join(hypHome, 'hypaware', 'plugins', PLUGIN) + await fs.mkdir(installDir, { recursive: true }) + await fs.writeFile(path.join(installDir, 'hypaware.plugin.json'), JSON.stringify({ + schema_version: 1, + name: PLUGIN, + version: '0.1.0', + hypaware_api: '^1.0.0', + runtime: 'node', + entrypoint: './index.js', + })) + await fs.writeFile( + path.join(installDir, 'index.js'), + ` +export async function activate(ctx) { + ctx.sources.register({ + name: 'accruing-fixture', + plugin: '${PLUGIN}', + async start() { + let probes = 0 + return { + async status() { + probes += 1 + return { state: 'ready', details: { probes, seen: probes > 1 ? ['late-arrival'] : [] } } + }, + async stop() {}, + } + }, + }) +} +` + ) + return installDir +} + +/** + * @param {string} hypHome + * @param {string} installDir + */ +async function writeInstall(hypHome, installDir) { + await writeLock(path.join(hypHome, 'hypaware'), { + schema_version: 1, + plugins: { + [PLUGIN]: { + name: PLUGIN, + version: '0.1.0', + source: { kind: 'local-dir', raw: installDir, path: installDir }, + install_dir: installDir, + content_hash: 'a'.repeat(64), + manifest_hash: 'b'.repeat(64), + installed_at: '2026-07-30T00:00:00.000Z', + }, + }, + }) + const configPath = defaultConfigPath(hypHome) + await fs.mkdir(path.dirname(configPath), { recursive: true }) + await fs.writeFile(configPath, JSON.stringify({ + version: 2, + plugins: [{ name: PLUGIN, config: {} }], + })) + return configPath +} + +test('the daemon refreshes source details on every tick, so accruing details reach status.json', async () => { + const hypHome = await fs.mkdtemp(path.join(os.tmpdir(), 'hypaware-source-details-tick-')) + const stateRoot = path.join(hypHome, 'hypaware') + let handle + try { + const configPath = await writeInstall(hypHome, await stageAccruingPlugin(hypHome)) + handle = await runDaemon({ + hypHome, + configPath, + env: { ...process.env, HYP_HOME: hypHome }, + runId: 'source-details-tick', + // The floor the daemon clamps to, so a tick lands inside the wait below. + tickIntervalMs: 1, + installSignalHandlers: false, + }) + + const atBoot = readStatusFile(stateRoot) + assert.ok(atBoot) + assert.deepEqual(atBoot.sources[0].details, { probes: 1, seen: [] }) + + const deadline = Date.now() + 20_000 + /** @type {any} */ + let details + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)) + const snapshot = readStatusFile(stateRoot) + details = snapshot?.sources?.[0]?.details + if (details && /** @type {any} */ (details).probes > 1) break + } + assert.ok(details, 'no source snapshot on disk') + assert.ok(details.probes > 1, `source details never refreshed (probes=${details.probes})`) + assert.deepEqual(details.seen, ['late-arrival']) + } finally { + if (handle) { + await handle.stop() + await handle.done + } + await fs.rm(hypHome, { recursive: true, force: true }) + } +}) + +test('a daemon that never reached a tick still refreshes source details before it stops', async () => { + const hypHome = await fs.mkdtemp(path.join(os.tmpdir(), 'hypaware-source-details-stop-')) + const stateRoot = path.join(hypHome, 'hypaware') + let handle + try { + const configPath = await writeInstall(hypHome, await stageAccruingPlugin(hypHome)) + handle = await runDaemon({ + hypHome, + configPath, + env: { ...process.env, HYP_HOME: hypHome }, + runId: 'source-details-stop', + // No tick loop at all: the shutdown refresh is the only chance. + tickIntervalMs: 0, + installSignalHandlers: false, + }) + + await handle.stop() + await handle.done + handle = undefined + + const final = readStatusFile(stateRoot) + assert.ok(final) + assert.deepEqual(final.sources[0].details, { probes: 2, seen: ['late-arrival'] }) + // Liveness is still the lifecycle's business, not the probe's. + assert.equal(final.sources[0].state, 'stopped') + } finally { + if (handle) { + await handle.stop() + await handle.done + } + await fs.rm(hypHome, { recursive: true, force: true }) + } +}) diff --git a/test/core/status-recent-entrypoints.test.js b/test/core/status-recent-entrypoints.test.js new file mode 100644 index 00000000..05ee1b83 --- /dev/null +++ b/test/core/status-recent-entrypoints.test.js @@ -0,0 +1,258 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { + collectHypAwareStatus, + recentEntrypointsFromSources, + writeStatusFile, +} from '../../src/core/daemon/status.js' +import { writePidFile } from '../../src/core/daemon/pid.js' +import { + formatEntrypointAge, + renderStatusJson, + renderStatusText, +} from '../../src/core/commands/status.js' +import { defaultConfigPath } from '../../src/core/config/schema.js' + +/** @import { CollectStatusOptions } from '../../src/core/daemon/types.js' */ + +// `hyp status` must be able to say "Codex Desktop traffic arrived N minutes +// ago" without activating a plugin and without reading the cache. The only +// place that answer can come from is the gateway's own status-file details. +// @ref LLP 0164#status-reads-it-from-the-status-file [tests]: + +async function makeHome() { + const hypHome = await fs.mkdtemp(path.join(os.tmpdir(), 'hyp-status-entrypoints-')) + const stateRoot = path.join(hypHome, 'hypaware') + await fs.mkdir(path.join(stateRoot, 'run'), { recursive: true }) + await fs.writeFile(defaultConfigPath(hypHome), JSON.stringify({ version: 2, plugins: [] }) + '\n') + return { hypHome, stateRoot } +} + +/** + * @param {string} stateRoot + * @param {Record} details + * @param {{ alive?: boolean }} [opts] + */ +function writeDaemonStatus(stateRoot, details, opts = {}) { + if (opts.alive !== false) { + writePidFile(stateRoot, /** @type {any} */ ({ pid: process.pid, runId: 'test-run', mode: 'foreground' })) + } + writeStatusFile(stateRoot, /** @type {any} */ ({ + state: 'healthy', + sources: [{ name: 'ai-gateway', plugin: '@hypaware/ai-gateway', state: 'started', details }], + sinks: [], + })) +} + +/** + * @param {string} hypHome + * @returns {CollectStatusOptions} + */ +function collectOpts(hypHome) { + return { + env: { ...process.env, HYP_HOME: hypHome, HYP_CONFIG: '' }, + platform: 'darwin', + isLaunchAgentInstalled: () => false, + } +} + +/** @returns {{ write(chunk: string): void, text(): string }} */ +function buffer() { + /** @type {string[]} */ + const chunks = [] + return { write: (chunk) => { chunks.push(chunk) }, text: () => chunks.join('') } +} + +test('recentEntrypointsFromSources lifts the gateway detail, newest first', () => { + const out = recentEntrypointsFromSources(/** @type {any} */ ([ + { + name: 'ai-gateway', + plugin: '@hypaware/ai-gateway', + details: { + host: '127.0.0.1', + port: 18521, + recent_entrypoints: [ + { entrypoint: 'codex-tui', client_name: 'codex', last_seen: '2026-07-30T09:00:00.000Z', rows: 40 }, + { entrypoint: 'Codex Desktop', client_name: 'codex', last_seen: '2026-07-30T11:00:00.000Z', rows: 6 }, + ], + }, + }, + ])) + assert.deepEqual(out, [ + { entrypoint: 'Codex Desktop', clientName: 'codex', lastSeen: '2026-07-30T11:00:00.000Z', rows: 6 }, + { entrypoint: 'codex-tui', clientName: 'codex', lastSeen: '2026-07-30T09:00:00.000Z', rows: 40 }, + ]) +}) + +test('a malformed or partial entry is dropped, not repaired into a name no query can reproduce', () => { + const out = recentEntrypointsFromSources(/** @type {any} */ ([ + { + name: 'ai-gateway', + plugin: '@hypaware/ai-gateway', + details: { + recent_entrypoints: [ + null, + 'codex-tui', + { client_name: 'codex', last_seen: '2026-07-30T11:00:00.000Z' }, + { entrypoint: 'codex-tui', last_seen: 'not a date' }, + { entrypoint: 'local-agent', last_seen: '2026-07-30T11:00:00.000Z' }, + ], + }, + }, + ])) + assert.deepEqual(out, [ + { entrypoint: 'local-agent', clientName: null, lastSeen: '2026-07-30T11:00:00.000Z', rows: 0 }, + ]) +}) + +test('a daemon with no gateway source, or an older daemon, yields an empty list', () => { + assert.deepEqual(recentEntrypointsFromSources(undefined), []) + assert.deepEqual(recentEntrypointsFromSources(/** @type {any} */ ([])), []) + assert.deepEqual( + recentEntrypointsFromSources(/** @type {any} */ ([ + { name: 'ai-gateway', plugin: '@hypaware/ai-gateway', state: 'started', details: { host: '127.0.0.1', port: 18521 } }, + ])), + [] + ) +}) + +test('hyp status surfaces Codex Desktop traffic from status.json, with no cache read', async () => { + const { hypHome, stateRoot } = await makeHome() + try { + writeDaemonStatus(stateRoot, { + host: '127.0.0.1', + port: 18521, + recent_entrypoints: [ + { entrypoint: 'Codex Desktop', client_name: 'codex', last_seen: new Date(Date.now() - 5 * 60_000).toISOString(), rows: 6 }, + { entrypoint: 'codex-tui', client_name: 'codex', last_seen: new Date(Date.now() - 3 * 3_600_000).toISOString(), rows: 40 }, + ], + }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + assert.deepEqual(report.recentEntrypoints.map((e) => e.entrypoint), ['Codex Desktop', 'codex-tui']) + + const stdout = buffer() + renderStatusText({ + report, + clientNames: [], + datasets: [], + cacheRoot: path.join(stateRoot, 'cache'), + stdout, + }) + const text = stdout.text() + assert.match(text, /recent clients:/) + assert.match(text, /- Codex Desktop {2}\(codex\) {2}last seen 5m ago, 6 rows/) + assert.match(text, /- codex-tui {2}\(codex\) {2}last seen 3h ago, 40 rows/) + + const json = renderStatusJson({ + report, + clientNames: [], + datasets: [], + cacheRoot: path.join(stateRoot, 'cache'), + }) + assert.equal(json.recent_entrypoints.length, 2) + assert.equal(json.recent_entrypoints[0].entrypoint, 'Codex Desktop') + assert.equal(json.recent_entrypoints[0].client_name, 'codex') + assert.equal(json.recent_entrypoints[0].rows, 6) + } finally { + await fs.rm(hypHome, { recursive: true, force: true }) + } +}) + +test('an install that has never captured keeps the V1 text surface unchanged', async () => { + const { hypHome, stateRoot } = await makeHome() + try { + writeDaemonStatus(stateRoot, { host: '127.0.0.1', port: 18521, recent_entrypoints: [] }) + const report = await collectHypAwareStatus(collectOpts(hypHome)) + assert.deepEqual(report.recentEntrypoints, []) + + const stdout = buffer() + renderStatusText({ + report, + clientNames: [], + datasets: [], + cacheRoot: path.join(stateRoot, 'cache'), + stdout, + }) + assert.doesNotMatch(stdout.text(), /recent clients/) + + const json = renderStatusJson({ + report, + clientNames: [], + datasets: [], + cacheRoot: path.join(stateRoot, 'cache'), + }) + assert.deepEqual(json.recent_entrypoints, []) + } finally { + await fs.rm(hypHome, { recursive: true, force: true }) + } +}) + +test('last-seen survives its daemon: a stopped daemon still reports what it saw', async () => { + const { hypHome, stateRoot } = await makeHome() + try { + // No pid file: nothing is running. Unlike a bound port, "last seen at T" + // does not become false when the daemon exits, and the rendered age + // carries the staleness. + writeDaemonStatus(stateRoot, { + recent_entrypoints: [ + { entrypoint: 'Codex Desktop', client_name: 'codex', last_seen: new Date(Date.now() - 2 * 86_400_000).toISOString(), rows: 6 }, + ], + }, { alive: false }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + assert.deepEqual(report.recentEntrypoints.map((e) => e.entrypoint), ['Codex Desktop']) + + const stdout = buffer() + renderStatusText({ + report, + clientNames: [], + datasets: [], + cacheRoot: path.join(stateRoot, 'cache'), + stdout, + }) + assert.match(stdout.text(), /- Codex Desktop {2}\(codex\) {2}last seen 2d ago, 6 rows/) + } finally { + await fs.rm(hypHome, { recursive: true, force: true }) + } +}) + +test('formatEntrypointAge is coarse, and never renders a negative age', () => { + const now = Date.parse('2026-07-30T12:00:00.000Z') + assert.equal(formatEntrypointAge('2026-07-30T11:59:30.000Z', now), 'just now') + assert.equal(formatEntrypointAge('2026-07-30T11:55:00.000Z', now), '5m ago') + assert.equal(formatEntrypointAge('2026-07-30T09:00:00.000Z', now), '3h ago') + assert.equal(formatEntrypointAge('2026-07-28T12:00:00.000Z', now), '2d ago') + // Clock went backwards after the status file was written. + assert.equal(formatEntrypointAge('2026-07-30T13:00:00.000Z', now), 'just now') + assert.equal(formatEntrypointAge('not a date', now), 'at an unreadable time') +}) + +test('a client with no client_name renders without inventing one', async () => { + const { hypHome, stateRoot } = await makeHome() + try { + writeDaemonStatus(stateRoot, { + recent_entrypoints: [ + { entrypoint: 'local-agent', last_seen: new Date().toISOString(), rows: 1 }, + ], + }) + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const stdout = buffer() + renderStatusText({ + report, + clientNames: [], + datasets: [], + cacheRoot: path.join(stateRoot, 'cache'), + stdout, + }) + assert.match(stdout.text(), /- local-agent {2}last seen just now, 1 row\n/) + } finally { + await fs.rm(hypHome, { recursive: true, force: true }) + } +}) diff --git a/test/plugins/ai-gateway-entrypoint-activity.test.js b/test/plugins/ai-gateway-entrypoint-activity.test.js new file mode 100644 index 00000000..ef4ece75 --- /dev/null +++ b/test/plugins/ai-gateway-entrypoint-activity.test.js @@ -0,0 +1,106 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { createEntrypointActivity } from '../../hypaware-core/plugins-workspace/ai-gateway/src/entrypoint_activity.js' + +// The gateway's half of "hyp status names recent client surfaces": count and +// timestamp whatever the projector wrote into `entrypoint`, interpret none of +// it, and never grow without bound on a client-supplied string. +// @ref LLP 0164#gateway-tracks-what-core-cannot-name [tests]: + +test('records one entry per entrypoint, counting rows and stamping last-seen', () => { + let clock = Date.parse('2026-07-30T10:00:00.000Z') + const activity = createEntrypointActivity({ now: () => clock }) + + activity.record([ + { entrypoint: 'codex-tui', client_name: 'codex' }, + { entrypoint: 'codex-tui', client_name: 'codex' }, + ]) + clock += 60_000 + activity.record([{ entrypoint: 'Codex Desktop', client_name: 'codex' }]) + + assert.deepEqual(activity.snapshot(), [ + { + entrypoint: 'Codex Desktop', + client_name: 'codex', + last_seen: '2026-07-30T10:01:00.000Z', + rows: 1, + }, + { + entrypoint: 'codex-tui', + client_name: 'codex', + last_seen: '2026-07-30T10:00:00.000Z', + rows: 2, + }, + ]) +}) + +test('the snapshot is most-recently-seen first, so a stale surface sinks', () => { + let clock = Date.parse('2026-07-30T10:00:00.000Z') + const activity = createEntrypointActivity({ now: () => clock }) + + activity.record([{ entrypoint: 'Codex Desktop', client_name: 'codex' }]) + clock += 3_600_000 + activity.record([{ entrypoint: 'codex-tui', client_name: 'codex' }]) + clock += 3_600_000 + activity.record([{ entrypoint: 'Codex Desktop', client_name: 'codex' }]) + + assert.deepEqual( + activity.snapshot().map((e) => e.entrypoint), + ['Codex Desktop', 'codex-tui'] + ) + assert.equal(activity.snapshot()[0].rows, 2) +}) + +test('rows with no entrypoint are ignored rather than bucketed under a placeholder', () => { + const activity = createEntrypointActivity() + activity.record([ + { client_name: 'codex' }, + { entrypoint: '', client_name: 'codex' }, + { entrypoint: 42, client_name: 'codex' }, + { entrypoint: 'codex-tui' }, + ]) + + const snapshot = activity.snapshot() + assert.equal(snapshot.length, 1) + assert.equal(snapshot[0].entrypoint, 'codex-tui') + // No client_name on the row means none is invented for the status line. + assert.equal(snapshot[0].client_name, null) +}) + +test('a client-supplied entrypoint cannot grow the map without bound', () => { + const activity = createEntrypointActivity({ max: 3 }) + for (let i = 0; i < 50; i++) { + activity.record([{ entrypoint: `surface-${i}`, client_name: 'codex' }]) + } + assert.equal(activity.size(), 3) + // The survivors are the most recently seen, which is what a "recent + // clients" readout would have shown anyway. + assert.deepEqual( + activity.snapshot().map((e) => e.entrypoint).sort(), + ['surface-47', 'surface-48', 'surface-49'] + ) +}) + +test('seeing an old entrypoint again rescues it from eviction', () => { + const activity = createEntrypointActivity({ max: 2 }) + activity.record([{ entrypoint: 'Codex Desktop', client_name: 'codex' }]) + activity.record([{ entrypoint: 'codex-tui', client_name: 'codex' }]) + activity.record([{ entrypoint: 'Codex Desktop', client_name: 'codex' }]) + activity.record([{ entrypoint: 'local-agent', client_name: 'claude' }]) + + assert.deepEqual( + activity.snapshot().map((e) => e.entrypoint).sort(), + ['Codex Desktop', 'local-agent'] + ) +}) + +test('an empty or malformed batch is a no-op, not a throw', () => { + const activity = createEntrypointActivity() + activity.record([]) + activity.record(/** @type {any} */ (undefined)) + activity.record(/** @type {any} */ ([null, 'nope', 7])) + assert.deepEqual(activity.snapshot(), []) +}) From be9ce7ff52cd781b38712ade84869e372762d2c5 Mon Sep 17 00:00:00 2001 From: neutral Date: Fri, 31 Jul 2026 02:07:49 +0000 Subject: [PATCH 2/3] Bound and sanitize recent-client labels before they reach disk or a terminal `entrypoint` reaches the tracker by three routes, not the two the design assumed. Codex's `originator` header and the User-Agent product are both bounded by Node's HTTP parser (no C0 control bytes, ~16KB). The third is not: for Claude the *live* projector copies `entrypoint` off a transcript `.jsonl` line on disk (`applyTranscriptMatch` -> `assignTranscriptIdentity`, transcripts.js:517), and that is an ordinary JSON string of any length holding any byte, which then wins over the exchange-level value at message_projector.js:715. That value was stored verbatim, written to `status.json` on every tick, and printed verbatim by `hyp status`. Executed against the real binary: a 30KB transcript entrypoint carrying ESC and newline produced a 30375-byte status.json and terminal output containing a raw escape sequence and a forged `daemon: FORGED ALL GOOD` line. The 32-entry cap bounds the number of entries, not their size, so it did not prevent this. Adds `sanitizeLabel` to core util (strip C0/C1 and the Unicode line separators, clamp to 120 chars) and applies it in two places: - at the gateway tracker's `record`, so the map key itself is clean and the eviction cap cannot be diluted by values differing only in invisible bytes - at core's `recentEntrypointsFromSources`, because `status.json` is a file and core must not assume the daemon that wrote it was this build; this is the last point before render Same input after the fix: status.json 444 bytes, output 1139 bytes, no escape byte, no forged line, surface still named. Also corrects LLP 0164's "Bounded" rationale, which named only the header route while its own motivating example (`local-agent`) is the transcript one, and pins the default cap in a test (raising it to 100000 previously went uncaught, since every eviction test passes an explicit `max`). Co-Authored-By: Claude --- .../ai-gateway/src/entrypoint_activity.js | 33 +++++--- ...ients-from-gateway-entrypoints.decision.md | 30 +++++-- src/core/daemon/status.js | 17 ++-- src/core/util/index.js | 2 + src/core/util/json_util.js | 40 +++++++++ test/core/status-recent-entrypoints.test.js | 81 +++++++++++++++++++ .../ai-gateway-entrypoint-activity.test.js | 45 +++++++++++ 7 files changed, 226 insertions(+), 22 deletions(-) diff --git a/hypaware-core/plugins-workspace/ai-gateway/src/entrypoint_activity.js b/hypaware-core/plugins-workspace/ai-gateway/src/entrypoint_activity.js index 94f5030d..2b97bff1 100644 --- a/hypaware-core/plugins-workspace/ai-gateway/src/entrypoint_activity.js +++ b/hypaware-core/plugins-workspace/ai-gateway/src/entrypoint_activity.js @@ -1,14 +1,19 @@ // @ts-check +import { sanitizeLabel } from 'hypaware/core/util' + /** * How many distinct `entrypoint` values the tracker keeps. The set is * naturally tiny (one per client surface on the machine: `codex-tui`, * whatever Codex Desktop reports, `local-agent`, ...), but `entrypoint` - * is a client-supplied string that rides in from request headers and - * rollout metadata, so an odd or hostile client must not be able to grow - * a daemon-lifetime map without bound. On overflow the least recently - * seen entry is evicted, which is exactly the entry a "recent clients" - * readout would drop anyway. + * is a captured string nothing on the way in constrains, so an odd or + * hostile client must not be able to grow a daemon-lifetime map without + * bound. On overflow the least recently seen entry is evicted, which is + * exactly the entry a "recent clients" readout would drop anyway. + * + * A count cap alone is not a bound: 32 entries of unbounded length is + * still unbounded, and `status.json` is rewritten on every daemon tick. + * `sanitizeLabel` supplies the other half (see `record`). */ const MAX_TRACKED_ENTRYPOINTS = 32 @@ -39,6 +44,16 @@ export function createEntrypointActivity(options = {}) { * placeholder: "unknown" is not a client surface, and inventing one * would put a name in `hyp status` that no query can reproduce. * + * `entrypoint` and `client_name` are sanitized before they are stored, + * because this map is the source for a file on disk and for text + * printed to a terminal. The values are captured verbatim from the + * wire (`originator`) or, for Claude, copied off a transcript `.jsonl` + * line on disk - and that second route has no HTTP parser bounding its + * length or rejecting control bytes. Sanitizing at the point of record + * (rather than at render) also keeps the map key itself clean, so the + * eviction cap above cannot be diluted by values that differ only in + * bytes no reader will ever see. + * * @param {readonly Record[]} rows */ record(rows) { @@ -46,11 +61,9 @@ export function createEntrypointActivity(options = {}) { const at = now() for (const row of rows) { if (!row || typeof row !== 'object') continue - const entrypoint = row.entrypoint - if (typeof entrypoint !== 'string' || entrypoint.length === 0) continue - const clientName = typeof row.client_name === 'string' && row.client_name.length > 0 - ? row.client_name - : null + const entrypoint = sanitizeLabel(row.entrypoint) + if (entrypoint === undefined) continue + const clientName = sanitizeLabel(row.client_name) ?? null const existing = seen.get(entrypoint) if (existing) { existing.lastSeenMs = at diff --git a/llp/0164-status-names-recent-clients-from-gateway-entrypoints.decision.md b/llp/0164-status-names-recent-clients-from-gateway-entrypoints.decision.md index d7dad1e7..c7091595 100644 --- a/llp/0164-status-names-recent-clients-from-gateway-entrypoints.decision.md +++ b/llp/0164-status-names-recent-clients-from-gateway-entrypoints.decision.md @@ -61,15 +61,33 @@ version of option 2: `local-agent` are all just strings that arrived in a column. The vendor owns them ([LLP 0141](./0141-codex-desktop-rides-the-codex-adapter.decision.md) pins none of them), and the value printed is byte-identical to the one a - follow-up `ai_gateway_messages` query filters on. + follow-up `ai_gateway_messages` query filters on - for every value a real + client surface produces. The one exception is deliberate: a value carrying + control bytes or running past 120 characters is cleaned before it is + displayed (see **Bounded** below), so it is the row, not the readout, that + stays authoritative for a pathological name. - **No placeholder.** A row with no `entrypoint` is skipped, not bucketed under "unknown". A name in `hyp status` that no query can reproduce would be worse than a short list. -- **Bounded.** `entrypoint` is a client-supplied string riding in from - request headers, so the map is capped (32) with least-recently-seen - eviction. An odd or hostile client cannot grow daemon-lifetime state, and - the entry an overflow drops is the one a "recent" readout would have - dropped anyway. +- **Bounded, in both dimensions.** The map is capped (32) with + least-recently-seen eviction, so an odd or hostile client cannot grow + daemon-lifetime state, and the entry an overflow drops is the one a + "recent" readout would have dropped anyway. + + A count cap is only half a bound, because nothing on the way in + constrains the *value*. Two of the three routes into the column are + bounded by the HTTP parser (Codex's `originator` header and the + User-Agent product, which cannot carry C0 control bytes and cap around + 16KB), but the third is not: for Claude the live projector copies + `entrypoint` off a transcript `.jsonl` line on disk + (`assignTranscriptIdentity`), and that is an ordinary JSON string of any + length containing any byte. Since this map is the source for a file on + disk and for text printed to a terminal, values are passed through + `sanitizeLabel` (control bytes stripped, clamped to 120 chars) at the + point of record, and again in core when read back. Without it, a + transcript value could repaint the operator's screen or forge a + plausible extra `hyp status` line, and 32 unbounded values would be + rewritten into `status.json` on every tick. This is an **activity signal, not a store**. It is in-memory and daemon-scoped: it dies with the process and is never written anywhere but the diff --git a/src/core/daemon/status.js b/src/core/daemon/status.js index 6afb8f27..596ac312 100644 --- a/src/core/daemon/status.js +++ b/src/core/daemon/status.js @@ -19,7 +19,7 @@ import { discoverBundledPlugins } from '../runtime/bundled.js' import { buildPluginCatalog } from '../plugin_catalog.js' import { classifyClientProvenance } from '../cli/wizard/provenance.js' import { atomicWriteJsonSync, readFileIfExistsSync } from '../util/fs_atomic.js' -import { getAtDottedPath, isPlainObject } from '../util/json_util.js' +import { getAtDottedPath, isPlainObject, sanitizeLabel } from '../util/json_util.js' import { localOnlyListPath, LocalOnlyListUnreadableError, readLocalOnlyDirs } from '../usage-policy/index.js' import { readFirstSyncDeadline } from '../usage-policy/first_sync_hold.js' import { resolveClientSettingsPath } from './client_settings_path.js' @@ -136,6 +136,13 @@ export function gatewaySourceDetails(sources) { * a name in `hyp status` that no query could reproduce would be worse than a * short list. * + * Labels are sanitized here as well as at the gateway that wrote them. This is + * not belt-and-braces: `status.json` is a file, and core must not assume the + * daemon that wrote it was this version, was this build, or was well behaved. + * Everything read here is about to be printed to a terminal, so control bytes + * (which can repaint the operator's screen or forge a plausible extra status + * line) and unbounded lengths are removed at the last point before render. + * * @param {SourceSnapshot[] | undefined} sources * @returns {RecentEntrypoint[]} * @ref LLP 0164#status-reads-it-from-the-status-file [implements]: hyp status answers from status.json, with no dataset registry and no cache read @@ -153,15 +160,13 @@ export function recentEntrypointsFromSources(sources) { const out = [] for (const item of raw) { if (!isPlainObject(item)) continue - const entrypoint = item.entrypoint + const entrypoint = sanitizeLabel(item.entrypoint) const lastSeen = item.last_seen - if (typeof entrypoint !== 'string' || entrypoint.length === 0) continue + if (entrypoint === undefined) continue if (typeof lastSeen !== 'string' || Number.isNaN(Date.parse(lastSeen))) continue out.push({ entrypoint, - clientName: typeof item.client_name === 'string' && item.client_name.length > 0 - ? item.client_name - : null, + clientName: sanitizeLabel(item.client_name) ?? null, lastSeen, rows: typeof item.rows === 'number' && Number.isFinite(item.rows) ? item.rows : 0, }) diff --git a/src/core/util/index.js b/src/core/util/index.js index 1fe02bcd..f2e24c6b 100644 --- a/src/core/util/index.js +++ b/src/core/util/index.js @@ -16,11 +16,13 @@ export { } from './fs_atomic.js' export { copyDir } from './fs_copy.js' export { + MAX_LABEL_CHARS, VOLATILE_BLOCK_FIELDS, canonicalJson, errCode, isPlainObject, parseMaybeJson, + sanitizeLabel, sha256Hex, sortKeys, stringValue, diff --git a/src/core/util/json_util.js b/src/core/util/json_util.js index 04d5a92a..d48f90c4 100644 --- a/src/core/util/json_util.js +++ b/src/core/util/json_util.js @@ -26,6 +26,46 @@ export function stringValue(value) { return typeof value === 'string' && value.length > 0 ? value : undefined } +/** + * Default ceiling for a display label lifted off captured data. Long + * enough that no real client surface is truncated (`local-agent`, + * `codex-tui`, `Codex Desktop` are all well under it), short enough that + * a status file holding a bounded number of them stays small. + */ +export const MAX_LABEL_CHARS = 120 + +// C0 and C1 control characters, plus the Unicode line/paragraph +// separators. Kept as one class so a label can never move the cursor, +// erase a line, open an escape sequence, or split into a second line. +const CONTROL_CHARS = /[\u0000-\u001F\u007F-\u009F\u2028\u2029]/g + +/** + * Make a captured string safe to write into a status file and print to a + * terminal: strip control characters and clamp the length. + * + * Values like `entrypoint` are captured verbatim from whatever the client + * put on the wire or wrote into a transcript file on disk, so they carry + * no guarantee of being short, printable, or single-line. A raw value + * reaching a TTY lets a client repaint the operator's screen (an `ESC` + * sequence, or a newline that forges a plausible extra status line), and + * an arbitrarily long one bloats every file the label lands in. Neither + * is a hypothetical: transcript-sourced values are ordinary JSON strings + * with no parser bounding them. + * + * The truncation marker is a plain ASCII ellipsis so the result stays + * single-byte-safe in a terminal. + * + * @param {unknown} value + * @param {number} [max] + * @returns {string | undefined} Cleaned non-empty string, else `undefined`. + */ +export function sanitizeLabel(value, max = MAX_LABEL_CHARS) { + if (typeof value !== 'string' || value.length === 0) return undefined + const stripped = value.replace(CONTROL_CHARS, '') + if (stripped.length === 0) return undefined + return stripped.length > max ? `${stripped.slice(0, max)}...` : stripped +} + /** * Parse `value` as JSON when it is a string, falling back to the * original value when it is not a string or does not parse. Projectors diff --git a/test/core/status-recent-entrypoints.test.js b/test/core/status-recent-entrypoints.test.js index 05ee1b83..28904f2b 100644 --- a/test/core/status-recent-entrypoints.test.js +++ b/test/core/status-recent-entrypoints.test.js @@ -256,3 +256,84 @@ test('a client with no client_name renders without inventing one', async () => { await fs.rm(hypHome, { recursive: true, force: true }) } }) + +// `status.json` is a file. Core must not assume the daemon that wrote it was +// this version or was well behaved, and everything read here is about to be +// printed to a terminal. +test('recentEntrypointsFromSources cleans labels a foreign status file supplies', () => { + const ESC = String.fromCharCode(27) + const LF = String.fromCharCode(10) + const recent = recentEntrypointsFromSources([ + { + name: 'ai-gateway', + plugin: '@hypaware/ai-gateway', + state: 'started', + details: { + recent_entrypoints: [ + { + entrypoint: `local-agent${ESC}[2K${LF} daemon: FORGED ALL GOOD`, + client_name: `claude${LF} x`, + last_seen: '2026-07-30T09:55:00.000Z', + rows: 3, + }, + { + entrypoint: 'C'.repeat(40000), + client_name: null, + last_seen: '2026-07-30T09:50:00.000Z', + rows: 1, + }, + ], + }, + }, + ]) + + assert.equal(recent.length, 2) + for (const e of recent) { + assert.equal(e.entrypoint.includes(ESC), false, 'no escape byte survives') + assert.equal(e.entrypoint.includes(LF), false, 'no newline survives') + assert.ok(e.entrypoint.length <= 128, `length ${e.entrypoint.length}`) + assert.equal((e.clientName ?? '').includes(LF), false) + } + assert.match(recent[0].entrypoint, /^local-agent/) +}) + +test('a rendered recent-clients block cannot be forged by a hostile entrypoint', async () => { + const ESC = String.fromCharCode(27) + const LF = String.fromCharCode(10) + const { hypHome, stateRoot } = await makeHome() + writeDaemonStatus(stateRoot, { + host: '127.0.0.1', + port: 18521, + recent_entrypoints: [ + { + entrypoint: `x${LF} daemon: FORGED${LF} cache: /nowhere${ESC}[2K`, + client_name: 'claude', + last_seen: new Date().toISOString(), + rows: 1, + }, + ], + }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const stdout = buffer() + renderStatusText({ + report, + clientNames: [], + datasets: [], + cacheRoot: path.join(stateRoot, 'cache'), + stdout, + }) + const text = stdout.text() + + // The surface is still named, on exactly one line, and nothing it contained + // became a line of its own or an escape sequence. + assert.match(text, /recent clients:/) + assert.equal(text.includes(ESC), false, 'no escape byte reaches the terminal') + assert.equal(text.includes(`${LF} daemon: FORGED`), false, 'no forged daemon line') + // The whole payload collapses onto the single entry line, where it is inert + // text inside the `- () last seen ...` shape rather + // than a line of its own. + const block = text.slice(text.indexOf(' recent clients:')).split(LF) + assert.match(block[1], /^ {4}- \S.* {2}\(claude\) {2}last seen just now, 1 row$/) + assert.match(block[2], /^ {2}cache:/, 'the next line is the real one, not a forged one') +}) diff --git a/test/plugins/ai-gateway-entrypoint-activity.test.js b/test/plugins/ai-gateway-entrypoint-activity.test.js index ef4ece75..2bbe26df 100644 --- a/test/plugins/ai-gateway-entrypoint-activity.test.js +++ b/test/plugins/ai-gateway-entrypoint-activity.test.js @@ -104,3 +104,48 @@ test('an empty or malformed batch is a no-op, not a throw', () => { activity.record(/** @type {any} */ ([null, 'nope', 7])) assert.deepEqual(activity.snapshot(), []) }) + +// The tracker feeds a file on disk and a terminal, and one of the three routes +// into `entrypoint` (Claude's transcript `.jsonl`, via `assignTranscriptIdentity`) +// is not bounded by an HTTP parser: it is a JSON string of any length holding any +// byte. A count cap alone does not bound that. +test('a control-character entrypoint cannot forge lines or move the cursor', () => { + const ESC = String.fromCharCode(27) + const LF = String.fromCharCode(10) + const activity = createEntrypointActivity() + activity.record([ + { entrypoint: `local-agent${ESC}[2K${LF} daemon: FORGED`, client_name: `cl${ESC}[31maude` }, + ]) + + const [entry] = activity.snapshot() + assert.equal(entry.entrypoint.includes(ESC), false) + assert.equal(entry.entrypoint.includes(LF), false) + assert.equal(entry.client_name?.includes(ESC), false) + // Stripped, not dropped: the surface is still named. + assert.match(entry.entrypoint, /^local-agent/) +}) + +test('an over-long entrypoint is clamped, so status.json stays small', () => { + const activity = createEntrypointActivity() + activity.record([{ entrypoint: 'A'.repeat(50000), client_name: 'B'.repeat(50000) }]) + + const [entry] = activity.snapshot() + assert.ok(entry.entrypoint.length <= 128, `entrypoint length ${entry.entrypoint.length}`) + assert.ok((entry.client_name ?? '').length <= 128) + assert.ok(JSON.stringify(activity.snapshot()).length < 1024) +}) + +test('an entrypoint of nothing but control bytes is skipped, not stored blank', () => { + const activity = createEntrypointActivity() + activity.record([{ entrypoint: String.fromCharCode(27) + String.fromCharCode(7) }]) + assert.deepEqual(activity.snapshot(), []) +}) + +// Pins the shipped default. The eviction tests above all pass an explicit +// `max`, so without this the constant could be raised to any value and every +// test would still pass. +test('the default cap is 32 distinct entrypoints', () => { + const activity = createEntrypointActivity() + for (let i = 0; i < 200; i++) activity.record([{ entrypoint: `surface-${i}` }]) + assert.equal(activity.size(), 32) +}) From 2487861d346566da1b79d45838d92833f6fb6ff7 Mon Sep 17 00:00:00 2001 From: test Date: Fri, 31 Jul 2026 03:37:51 +0000 Subject: [PATCH 3/3] Finish the label bound, and stop a plugin probe from freezing the tick loop Round-2 review of #502. Three gaps, all executed against the real tracker, the real core reader, and a real daemon. 1. `sanitizeLabel` stripped control bytes but not the two other classes that make a label render as something other than what it stores. Bidi overrides and isolates survived to the terminal, and an unterminated one reorders past the end of the label into the rest of the status line. Zero-width and default-ignorable characters survived to the map *key*, which is exactly what the tracker's own comment claimed could not happen: 500 variants of `codex-tui` that differ only in zero-width spaces filled all 32 slots and evicted every real surface. Both classes are now stripped, so those 500 variants collapse to one entry. Confusables stay out of scope on purpose. Also: the clamp appended its marker past the ceiling (120 chars in, 123 out) and could cut between the halves of an astral character, putting a lone surrogate into `status.json`. The marker is now inside the ceiling and a stranded high surrogate is dropped. 2. Core sanitized each label it read back but capped only their length, not their number, though the comment justifying the read-side sanitize is that this build may not have written the file. A status file with 5000 entries printed 5000 lines. Capped at 32 after the sort, matching the writer. 3. The per-tick refresh put plugin code on the tick loop's critical path with no bound. A source whose `status()` boots fine and then hangs froze `persist()`, so *every* field in `status.json` stopped updating while the daemon went on reporting itself healthy, and it hung `hyp daemon stop` outright (shutdown refreshes too). Probes now run under a timeout, a source with an outstanding probe is skipped rather than piling up an unclosed span per tick, and failures are logged once per transition (`daemon.source_status_failed` / `_recovered`) rather than never, which was the old `safeStatus` behaviour, or every tick forever. Verified by execution before and after: status.json keeps being written and the daemon still stops in ms with a permanently hung probe, and ~500 ticks produce at most 3 log lines, not 500. Co-Authored-By: Claude --- ...ients-from-gateway-entrypoints.decision.md | 62 ++++++++-- src/core/daemon/runtime.js | 100 +++++++++++++++- src/core/daemon/status.js | 28 +++-- src/core/util/json_util.js | 60 +++++++--- .../daemon-source-details-refresh.test.js | 109 ++++++++++++++++++ test/core/status-recent-entrypoints.test.js | 26 +++++ .../ai-gateway-entrypoint-activity.test.js | 42 +++++++ 7 files changed, 392 insertions(+), 35 deletions(-) diff --git a/llp/0164-status-names-recent-clients-from-gateway-entrypoints.decision.md b/llp/0164-status-names-recent-clients-from-gateway-entrypoints.decision.md index c7091595..2bfd3059 100644 --- a/llp/0164-status-names-recent-clients-from-gateway-entrypoints.decision.md +++ b/llp/0164-status-names-recent-clients-from-gateway-entrypoints.decision.md @@ -63,9 +63,10 @@ version of option 2: pins none of them), and the value printed is byte-identical to the one a follow-up `ai_gateway_messages` query filters on - for every value a real client surface produces. The one exception is deliberate: a value carrying - control bytes or running past 120 characters is cleaned before it is - displayed (see **Bounded** below), so it is the row, not the readout, that - stays authoritative for a pathological name. + control, invisible, or display-reordering characters, or running past + 120 characters, is cleaned before it is displayed (see **Bounded** + below), so it is the row, not the readout, that stays authoritative for + a pathological name. - **No placeholder.** A row with no `entrypoint` is skipped, not bucketed under "unknown". A name in `hyp status` that no query can reproduce would be worse than a short list. @@ -83,11 +84,22 @@ version of option 2: (`assignTranscriptIdentity`), and that is an ordinary JSON string of any length containing any byte. Since this map is the source for a file on disk and for text printed to a terminal, values are passed through - `sanitizeLabel` (control bytes stripped, clamped to 120 chars) at the - point of record, and again in core when read back. Without it, a - transcript value could repaint the operator's screen or forge a - plausible extra `hyp status` line, and 32 unbounded values would be - rewritten into `status.json` on every tick. + `sanitizeLabel` at the point of record, and again in core when read + back. Without it, a transcript value could repaint the operator's + screen or forge a plausible extra `hyp status` line, and 32 unbounded + values would be rewritten into `status.json` on every tick. + + `sanitizeLabel` clamps to 120 characters (marker included) and strips + every character that either drives the terminal or occupies no width + on it: C0/DEL/C1 and the line separators, bidi formatting, and the + zero-width and default-ignorable formatting characters. The last group + is not cosmetic. Stripping at the point of record is what keeps the + map *key* clean, and a key differing only in characters that render as + nothing would mint unlimited entries an operator cannot tell apart - + filling all 32 slots with one surface and evicting every real one. What + is deliberately *not* attempted is confusables: this bounds what a + label does, not what it looks like, so two labels built from different + but similar-looking real letters stay distinct, as they must. This is an **activity signal, not a store**. It is in-memory and daemon-scoped: it dies with the process and is never written anywhere but the @@ -102,12 +114,40 @@ that accrues. So the daemon re-reads started sources' details on every sink tick, and once more at shutdown before the sources are torn down (a daemon that stopped between ticks must not leave a file claiming it saw nothing). Name, plugin, and state are left alone: liveness is the lifecycle's business, -not a status probe's, and the refresh is best-effort per source like -`safeStatus` itself. +not a status probe's, and the refresh is best-effort per source. + +Putting a plugin call on the tick path is the widest thing this decision +does, so the refresh bounds it rather than trusting it. `status()` is plugin +code and the kernel contract puts no limit on what it may do. Awaited only at +boot, a probe that never settles is at least loud: the daemon does not start. +Awaited every tick it would be silent and total, because `persist()` is +downstream of the refresh: *every* field in `status.json` would freeze, not +just that source's, while the daemon went on reporting itself healthy, and +the shutdown refresh would hang `hyp daemon stop` with it. So each probe runs +under a timeout, a source with a probe still outstanding is skipped rather +than piling up a fresh call (and a fresh unclosed span) every tick, and a +probe that throws or times out leaves the details it already had. + +A failure is reported **once per transition, not once per tick**. The old +boot-only path swallowed a throwing `status()` in silence, which was +survivable when it happened once; at tick cadence, silence would hide a +permanently broken plugin behind details that simply never change, and +logging unconditionally would trade that for a line every tick for the +daemon's life. Neither is observable in the sense LLP 0033 means. So the +daemon logs `daemon.source_status_failed` when a source's failure changes, +and `daemon.source_status_recovered` when it stops. Core's whole share of the feature is `recentEntrypointsFromSources`: find the gateway source's snapshot, validate the shape, order by time, drop what is -malformed. `hyp status` then renders a `recent clients:` block, and +malformed, sanitize each label, and cap the list. The sanitizing and the cap +are deliberately duplicated from the gateway rather than delegated to it: +`status.json` is a *file*, and core cannot assume the daemon that wrote it +was this version, this build, or well behaved, while everything read here is +about to be printed to a terminal. All three ways a label can be hostile are +answered at that last point before render - control and invisible bytes, +unbounded length, and unbounded count. + +`hyp status` then renders a `recent clients:` block, and `--json` a `recent_entrypoints` array. The block appears only when the daemon recorded something, so an install that has never captured keeps the V1 text surface unchanged. diff --git a/src/core/daemon/runtime.js b/src/core/daemon/runtime.js index a713b420..2f0a3d47 100644 --- a/src/core/daemon/runtime.js +++ b/src/core/daemon/runtime.js @@ -471,6 +471,96 @@ export async function runDaemon(opts = {}) { } // ----- Tick loop ----- + /** + * How long a source's `status()` may take before the tick gives up on it. + * `status()` is plugin code and the kernel contract puts no bound on it. It + * used to be awaited only at boot, where a probe that never settles is at + * least loud: the daemon does not start. On the tick path a hang is silent + * and total - `persist()` is downstream of the refresh, so *every* field in + * `status.json` freezes, not just that source's, while the daemon goes on + * reporting itself healthy; and the shutdown refresh would hang + * `hyp daemon stop` with it. Well under the tick interval floor so a slow + * probe cannot overlap itself into the next tick. + */ + const SOURCE_STATUS_TIMEOUT_MS = 5000 + + /** + * Last failure message logged per source, so a persistently broken probe + * says so once instead of once per tick forever. + * + * @type {Map} + */ + const sourceProbeFailures = new Map() + + /** @type {Set} */ + const sourceProbesInFlight = new Set() + + /** + * Probe one source's `status()` details under a timeout, and never let the + * plugin's promise outlive our interest in it. A timed-out probe cannot be + * cancelled, so the source is skipped until its previous call settles. + * Otherwise a permanently hung probe would start (and hold open) a fresh + * `source.status` span on every tick for the daemon's life. + * + * @param {string} name + * @returns {Promise<{ details: object | undefined, failure: string | undefined }>} + */ + async function probeSourceDetails(name) { + if (sourceProbesInFlight.has(name)) { + return { details: undefined, failure: 'previous status probe has not settled' } + } + sourceProbesInFlight.add(name) + const settle = () => sourceProbesInFlight.delete(name) + const probe = boot.runtime.sources.status(name).then((s) => s?.details ?? undefined) + probe.then(settle, settle) + /** @type {NodeJS.Timeout | undefined} */ + let timer + try { + const details = await Promise.race([ + probe, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error(`status probe exceeded ${SOURCE_STATUS_TIMEOUT_MS}ms`)), + SOURCE_STATUS_TIMEOUT_MS + ) + if (typeof timer.unref === 'function') timer.unref() + }), + ]) + return { details: /** @type {object | undefined} */ (details), failure: undefined } + } catch (err) { + return { details: undefined, failure: err instanceof Error ? err.message : String(err) } + } finally { + if (timer) clearTimeout(timer) + } + } + + /** + * Report a probe outcome at most once per transition. The refresh runs every + * tick, so logging each failure unconditionally would turn one broken plugin + * into a log line every tick for the daemon's life; logging none at all - the + * old `safeStatus` behaviour - leaves an operator with a source whose details + * silently never change and nothing anywhere saying why. + * + * @param {string} name + * @param {string | undefined} failure + */ + function noteProbeOutcome(name, failure) { + const previous = sourceProbeFailures.get(name) + if (failure === undefined) { + if (previous === undefined) return + sourceProbeFailures.delete(name) + fileLog.info('daemon.source_status_recovered', { hyp_source: name }) + return + } + if (previous === failure) return + sourceProbeFailures.set(name, failure) + fileLog.warn('daemon.source_status_failed', { + hyp_source: name, + message: failure, + error_kind: 'source_status_probe', + }) + } + /** * Re-read every started source's `status()` details into the snapshot * list. Boot writes the details once (`startConfiguredSources`), which @@ -481,17 +571,19 @@ export async function runDaemon(opts = {}) { * this file. Name, plugin, and state are left alone - liveness is the * lifecycle's business, not a status probe's. * - * Best-effort per source, like `safeStatus` itself: a source whose - * probe throws or returns nothing keeps the details it already had - * rather than losing them. + * Best-effort per source: a source whose probe throws, times out, or + * returns nothing keeps the details it already had rather than losing + * them, and one bad source never blocks the next one or the persist + * below. * * @ref LLP 0164#status-reads-it-from-the-status-file [implements]: the tick refreshes source details so accruing details reach status.json */ async function refreshSourceDetails() { for (const snap of status.sources) { if (snap.state !== 'started') continue - const details = await safeStatus(boot.runtime, snap.name) + const { details, failure } = await probeSourceDetails(snap.name) if (details !== undefined) snap.details = details + noteProbeOutcome(snap.name, failure) } } diff --git a/src/core/daemon/status.js b/src/core/daemon/status.js index 596ac312..3ba9369e 100644 --- a/src/core/daemon/status.js +++ b/src/core/daemon/status.js @@ -124,6 +124,15 @@ export function gatewaySourceDetails(sources) { return { host, port, listenFallback, ...(listenFallbackFrom ? { listenFallbackFrom } : {}) } } +/** + * How many recent client surfaces `hyp status` will report. The gateway keeps + * its own, deliberately equal, cap on the writing side; this one exists + * because core reads a *file* and a file can have been written by an older + * build, a different build, or something that is not this daemon at all. It + * bounds the terminal output, not the tracker. + */ +const MAX_RECENT_ENTRYPOINTS = 32 + /** * Lift the gateway source's `recent_entrypoints` detail out of a status-file * source-snapshot list, most recently seen first. @@ -136,12 +145,15 @@ export function gatewaySourceDetails(sources) { * a name in `hyp status` that no query could reproduce would be worse than a * short list. * - * Labels are sanitized here as well as at the gateway that wrote them. This is - * not belt-and-braces: `status.json` is a file, and core must not assume the - * daemon that wrote it was this version, was this build, or was well behaved. - * Everything read here is about to be printed to a terminal, so control bytes - * (which can repaint the operator's screen or forge a plausible extra status - * line) and unbounded lengths are removed at the last point before render. + * Labels are sanitized here as well as at the gateway that wrote them, and the + * list is capped here as well as there. This is not belt-and-braces: + * `status.json` is a file, and core must not assume the daemon that wrote it + * was this version, was this build, or was well behaved. Everything read here + * is about to be printed to a terminal, so all three ways a label can be + * hostile are answered at the last point before render - control and invisible + * bytes (`sanitizeLabel`), unbounded length (`sanitizeLabel`), and unbounded + * *count*, which the writer's cap does not cover for a file this build did not + * write. * * @param {SourceSnapshot[] | undefined} sources * @returns {RecentEntrypoint[]} @@ -172,7 +184,9 @@ export function recentEntrypointsFromSources(sources) { }) } out.sort((a, b) => (a.lastSeen < b.lastSeen ? 1 : a.lastSeen > b.lastSeen ? -1 : 0)) - return out + // Sorted before the cap so the entries kept are the most recently seen ones, + // which is the same entry a "recent clients" readout would keep anyway. + return out.slice(0, MAX_RECENT_ENTRYPOINTS) } /** diff --git a/src/core/util/json_util.js b/src/core/util/json_util.js index d48f90c4..e0eb2603 100644 --- a/src/core/util/json_util.js +++ b/src/core/util/json_util.js @@ -34,26 +34,56 @@ export function stringValue(value) { */ export const MAX_LABEL_CHARS = 120 -// C0 and C1 control characters, plus the Unicode line/paragraph -// separators. Kept as one class so a label can never move the cursor, -// erase a line, open an escape sequence, or split into a second line. -const CONTROL_CHARS = /[\u0000-\u001F\u007F-\u009F\u2028\u2029]/g +// Everything a label may contain that either drives the terminal or +// occupies no width on it. Three groups, one class: +// +// - C0/DEL/C1 and the Unicode line/paragraph separators, so a label can +// never move the cursor, erase a line, open an escape sequence, or +// split into a second line. +// - Bidirectional formatting (embeddings, overrides, isolates, marks). +// These print nothing but reorder what follows, and an unterminated +// one keeps reordering past the end of the label into the rest of the +// status line. A label that renders as a different string than the one +// it stores defeats the point of naming a surface at all. +// - Zero-width and default-ignorable formatting (ZWSP/ZWNJ/ZWJ, word +// joiner, BOM, soft hyphen, variation selectors). These render as +// nothing, so keeping them lets two labels be distinct map keys while +// being indistinguishable on screen, which is what would otherwise +// dilute the tracker's eviction cap. +// +// Confusables are deliberately out of scope: this bounds what a label +// *does*, not what it looks like. Two labels built from different but +// similar-looking real letters stay distinct, as they must. +const UNSAFE_LABEL_CHARS = + /[\u0000-\u001F\u007F-\u009F\u00AD\u061C\u180E\u200B-\u200F\u2028-\u2029\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFE00-\uFE0F\uFEFF]/g + +// A high surrogate left stranded by the clamp below. Slicing counts UTF-16 +// code units, so a cut can land between the halves of an astral character +// and leave behind a string that is not well-formed. +const TRAILING_HIGH_SURROGATE = /[\uD800-\uDBFF]$/ + +const TRUNCATION_MARKER = '...' /** * Make a captured string safe to write into a status file and print to a - * terminal: strip control characters and clamp the length. + * terminal: strip control and invisible formatting characters, and clamp + * the length. * * Values like `entrypoint` are captured verbatim from whatever the client * put on the wire or wrote into a transcript file on disk, so they carry * no guarantee of being short, printable, or single-line. A raw value * reaching a TTY lets a client repaint the operator's screen (an `ESC` - * sequence, or a newline that forges a plausible extra status line), and - * an arbitrarily long one bloats every file the label lands in. Neither - * is a hypothetical: transcript-sourced values are ordinary JSON strings - * with no parser bounding them. + * sequence, or a newline that forges a plausible extra status line), + * reorder it (a bidi override), or hide inside it (a zero-width run), and + * an arbitrarily long one bloats every file the label lands in. None of + * this is hypothetical: transcript-sourced values are ordinary JSON + * strings with no parser bounding them. * - * The truncation marker is a plain ASCII ellipsis so the result stays - * single-byte-safe in a terminal. + * The result is at most `max` characters *including* the truncation + * marker, which is a plain ASCII ellipsis so the result stays + * single-byte-safe in a terminal. `max` counts UTF-16 code units, not + * bytes: a label of astral characters is still roughly 4x `max` bytes + * once encoded, which is why callers cap the count of labels too. * * @param {unknown} value * @param {number} [max] @@ -61,9 +91,13 @@ const CONTROL_CHARS = /[\u0000-\u001F\u007F-\u009F\u2028\u2029]/g */ export function sanitizeLabel(value, max = MAX_LABEL_CHARS) { if (typeof value !== 'string' || value.length === 0) return undefined - const stripped = value.replace(CONTROL_CHARS, '') + const stripped = value.replace(UNSAFE_LABEL_CHARS, '') if (stripped.length === 0) return undefined - return stripped.length > max ? `${stripped.slice(0, max)}...` : stripped + if (stripped.length <= max) return stripped + const head = stripped + .slice(0, Math.max(0, max - TRUNCATION_MARKER.length)) + .replace(TRAILING_HIGH_SURROGATE, '') + return head.length === 0 ? undefined : `${head}${TRUNCATION_MARKER}` } /** diff --git a/test/core/daemon-source-details-refresh.test.js b/test/core/daemon-source-details-refresh.test.js index 0a490209..d54ddb36 100644 --- a/test/core/daemon-source-details-refresh.test.js +++ b/test/core/daemon-source-details-refresh.test.js @@ -164,3 +164,112 @@ test('a daemon that never reached a tick still refreshes source details before i await fs.rm(hypHome, { recursive: true, force: true }) } }) + +// The refresh put plugin code on the tick loop's critical path, and the +// kernel contract puts no bound on `status()`. A probe that never settles +// used to be able to hang only boot, which is at least loud. On the tick path +// it froze `persist()` - so *every* field in `status.json` went stale, not +// just that source's - and hung the shutdown refresh with it, all while the +// daemon went on reporting itself healthy. +// @ref LLP 0164#status-reads-it-from-the-status-file [tests]: + +/** + * Stage a plugin whose source answers `status()` once, at boot, and then + * never again. + * + * @param {string} hypHome + * @returns {Promise} + */ +async function stageHangingPlugin(hypHome) { + const installDir = path.join(hypHome, 'hypaware', 'plugins', PLUGIN) + await fs.mkdir(installDir, { recursive: true }) + await fs.writeFile(path.join(installDir, 'hypaware.plugin.json'), JSON.stringify({ + schema_version: 1, + name: PLUGIN, + version: '0.1.0', + hypaware_api: '^1.0.0', + runtime: 'node', + entrypoint: './index.js', + })) + await fs.writeFile( + path.join(installDir, 'index.js'), + ` +export async function activate(ctx) { + ctx.sources.register({ + name: 'accruing-fixture', + plugin: '${PLUGIN}', + async start() { + let probes = 0 + return { + async status() { + probes += 1 + if (probes > 1) await new Promise(() => {}) + return { state: 'ready', details: { probes } } + }, + async stop() {}, + } + }, + }) +} +` + ) + return installDir +} + +test('a source whose status() never settles cannot freeze the status file or the shutdown', async () => { + const hypHome = await fs.mkdtemp(path.join(os.tmpdir(), 'hypaware-source-details-hang-')) + const stateRoot = path.join(hypHome, 'hypaware') + let handle + try { + const configPath = await writeInstall(hypHome, await stageHangingPlugin(hypHome)) + handle = await runDaemon({ + hypHome, + configPath, + env: { ...process.env, HYP_HOME: hypHome }, + runId: 'source-details-hang', + tickIntervalMs: 1, + installSignalHandlers: false, + }) + + const statusPath = path.join(stateRoot, 'run', 'status.json') + const firstWrite = (await fs.stat(statusPath)).mtimeMs + + // The probe is hung from the very first tick. The tick loop must still be + // rewriting the file: a frozen mtime here is the whole bug. + const deadline = Date.now() + 20_000 + let advanced = false + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)) + if ((await fs.stat(statusPath)).mtimeMs > firstWrite) { + advanced = true + break + } + } + assert.ok(advanced, 'status.json stopped being written while a probe hung') + + // Details keep their last good value rather than being lost. + const snapshot = readStatusFile(stateRoot) + assert.deepEqual(snapshot?.sources?.[0]?.details, { probes: 1 }) + + // ... and the daemon can still be stopped. + const stopped = await Promise.race([ + handle.stop().then(() => handle.done).then(() => 'stopped'), + new Promise((resolve) => setTimeout(() => resolve('hung'), 15_000)), + ]) + handle = undefined + assert.equal(stopped, 'stopped', 'shutdown hung on the source status probe') + + // Silence was the other half of the bug: say it, but say it once, not + // once per tick for the daemon's life. + const log = await fs.readFile(path.join(stateRoot, 'logs', 'daemon.log'), 'utf8') + const failures = log.split(String.fromCharCode(10)).filter((l) => l.includes('daemon.source_status_failed')) + assert.ok(failures.length > 0, 'a stuck status probe was never reported') + assert.ok(failures.length <= 4, `status probe failure logged ${failures.length} times`) + } finally { + if (handle) { + await handle.stop() + await handle.done + } + await fs.rm(hypHome, { recursive: true, force: true }) + } +}) diff --git a/test/core/status-recent-entrypoints.test.js b/test/core/status-recent-entrypoints.test.js index 28904f2b..07c79b98 100644 --- a/test/core/status-recent-entrypoints.test.js +++ b/test/core/status-recent-entrypoints.test.js @@ -337,3 +337,29 @@ test('a rendered recent-clients block cannot be forged by a hostile entrypoint', assert.match(block[1], /^ {4}- \S.* {2}\(claude\) {2}last seen just now, 1 row$/) assert.match(block[2], /^ {2}cache:/, 'the next line is the real one, not a forged one') }) + +// `sanitizeLabel` bounds a label's bytes; nothing bounded how many of them +// core would read back. The gateway caps its own map, but core reads a *file*, +// and the same sentence that justifies sanitizing on read - this build did not +// necessarily write it - applies to the count. +test('a status file with an absurd number of entrypoints is capped on read', () => { + const many = Array.from({ length: 5000 }, (_, i) => ({ + entrypoint: `surface-${i}`, + client_name: null, + last_seen: new Date(Date.now() - i * 1000).toISOString(), + rows: 1, + })) + + const recent = recentEntrypointsFromSources([ + { + name: 'ai-gateway', + plugin: '@hypaware/ai-gateway', + state: 'started', + details: { recent_entrypoints: many }, + }, + ]) + + assert.equal(recent.length, 32) + // Capped after the sort, so what survives is the most recently seen. + assert.equal(recent[0].entrypoint, 'surface-0') +}) diff --git a/test/plugins/ai-gateway-entrypoint-activity.test.js b/test/plugins/ai-gateway-entrypoint-activity.test.js index 2bbe26df..5362fece 100644 --- a/test/plugins/ai-gateway-entrypoint-activity.test.js +++ b/test/plugins/ai-gateway-entrypoint-activity.test.js @@ -149,3 +149,45 @@ test('the default cap is 32 distinct entrypoints', () => { for (let i = 0; i < 200; i++) activity.record([{ entrypoint: `surface-${i}` }]) assert.equal(activity.size(), 32) }) + +// A control byte is not the only way a label can render as something other +// than what it stores. Bidi overrides print nothing and reverse what follows, +// and an unterminated one runs past the end of the label into the rest of the +// status line. +test('a bidi override cannot reorder the rendered status line', () => { + const RLO = String.fromCharCode(0x202e) + const LRI = String.fromCharCode(0x2066) + const activity = createEntrypointActivity() + activity.record([{ entrypoint: `local-agent${RLO}tnega-lacol`, client_name: `c${LRI}laude` }]) + + const [entry] = activity.snapshot() + assert.equal(entry.entrypoint.includes(RLO), false) + assert.equal(entry.client_name?.includes(LRI), false) + assert.match(entry.entrypoint, /^local-agent/) +}) + +// The whole reason the tracker sanitizes at `record` rather than at render is +// to keep the map *key* clean. Characters with no rendered width would +// otherwise mint unlimited distinct keys that an operator cannot tell apart, +// so 32 slots of one surface would evict every real one. +test('invisible characters cannot dilute the eviction cap', () => { + const ZWSP = String.fromCharCode(0x200b) + const activity = createEntrypointActivity() + for (let i = 0; i < 500; i++) activity.record([{ entrypoint: 'codex-tui' + ZWSP.repeat(i) }]) + + assert.equal(activity.size(), 1) + assert.deepEqual(activity.snapshot().map((e) => e.entrypoint), ['codex-tui']) +}) + +// The clamp counts UTF-16 code units, so a cut can land between the halves of +// an astral character. A lone surrogate is not a well-formed string and would +// ride into `status.json` as one. +test('clamping an astral entrypoint leaves a well-formed string', () => { + const activity = createEntrypointActivity() + activity.record([{ entrypoint: 'A'.repeat(119) + String.fromCodePoint(0x1f600).repeat(50) }]) + + const [entry] = activity.snapshot() + assert.equal(entry.entrypoint.isWellFormed(), true) + // The marker is inside the ceiling, not bolted on past it. + assert.ok(entry.entrypoint.length <= 120, `length ${entry.entrypoint.length}`) +})