diff --git a/llp/0186-reconciler-refused-marker.design.md b/llp/0186-reconciler-refused-marker.design.md index 11cfce5ec..022eea3d3 100644 --- a/llp/0186-reconciler-refused-marker.design.md +++ b/llp/0186-reconciler-refused-marker.design.md @@ -5,7 +5,7 @@ **Systems:** Config, Daemon **Generated-by:** neutral **Related:** LLP 0036, LLP 0041, LLP 0086, LLP 0109, LLP 0184 -**Extended-by:** LLP 0250 (#how-the-reconciler-distinguishes-it-from-done: the reverse gap's drop also reads a `prior_done` bit, so a settings-only attach rewritten to `refused` is reversed rather than dropped) +**Extended-by:** LLP 0250 (#how-the-reconciler-distinguishes-it-from-done: the reverse gap's drop also reads a `prior_done` bit, so a settings-only attach rewritten to `refused` is reversed rather than dropped), LLP 0295 (#re-arm-explicit-hyp-attach-re-run-only: the re-arm fires at every success exit of the explicit re-run, including the daemon-managed one that calls no adapter `attach()`) > [LLP 0184](./0184-reconciler-retries-permanent-failures.issue.md) reports > that the action reconciler ([LLP 0036](./0036-central-config-driven-client-actions.decision.md) diff --git a/llp/0295-rearm-fires-at-both-attach-success-exits.decision.md b/llp/0295-rearm-fires-at-both-attach-success-exits.decision.md new file mode 100644 index 000000000..f333b8fc6 --- /dev/null +++ b/llp/0295-rearm-fires-at-both-attach-success-exits.decision.md @@ -0,0 +1,118 @@ +# LLP 0295: The refused re-arm fires at both `hyp client attach` success exits + +**Type:** Decision +**Status:** Accepted +**Systems:** Config, Daemon +**Generated-by:** neutral +**Date:** 2026-08-19 +**Related:** LLP 0086, LLP 0107, LLP 0138, LLP 0174, LLP 0184, LLP 0186, LLP 0250 + +> Extends [LLP 0186](./0186-reconciler-refused-marker.design.md) +> `#re-arm-explicit-hyp-attach-re-run-only`. LLP 0186 is Active and does not +> change: this document does not touch its rule (re-arm is the explicit +> `hyp client attach` re-run only, and only for a `refused` marker), it settles which +> *exits* of that command count as the re-run, because `hyp client attach` grew a +> second success exit after LLP 0186 was written. Raised as residual 3 of +> [hyparam/hypaware#887](https://github.com/hyparam/hypaware/issues/887) and +> as a review finding on +> [#900](https://github.com/hyparam/hypaware/pull/900). + +## Context + +LLP 0186 `#re-arm-explicit-hyp-attach-re-run-only` names the manual re-run as +the one and only trigger that clears a terminal `refused` marker, and locates +the call by the shape the command path had at the time: + +> after `registration.attach(...)` (or the equivalent adapter call the command +> path uses) resolves without throwing, and the run was not a `--dry-run` + +When that was written, `runClientLifecycle('attach', ...)` had exactly one +success exit, the one that calls the adapter's `attach()` hook, so "after +`attach()` resolves" and "this explicit re-run succeeded" named the same +moment. They no longer do. The daemon-managed install grew a second success +exit: the client is already attached at the live port, so there is nothing to +rewrite, the command reports success and returns without calling `attach()` +at all. + +That exit is the one an operator on a daemon-managed install most often +reaches, and it is precisely the exit a user reaches after fixing by hand the +precondition the reconciler refused on. Reading LLP 0186's sentence as a +mechanical requirement (the re-arm is attached to the `attach()` call, not to +the command) makes the re-arm unreachable on exactly the path that needs it, +for a marker whose entire design is that nothing else will ever clear it. + +## Decision + + + +### Both success exits + +**The re-arm fires at every success exit of an explicit, non-`--dry-run` +`hyp client attach `, including the daemon-managed exit that calls no +adapter `attach()` hook.** Everything LLP 0186 settled about the re-arm is +unchanged and carries to the new exit verbatim: it is gated on the marker's +prior status being `refused` (a `done` marker is never cleared, LLP 0138 +`#marker-undo`); a `refused` marker carrying `installed_assets` is rewritten +to `failed` rather than dropped; `--dry-run` re-arms nothing; and no +automatic, `isCurrent`-style re-arm is introduced here either (still the +follow-up candidate LLP 0186 names and does not build). + +Reaching the re-arm with the settings already correct rather than freshly +written satisfies the same precondition. LLP 0186's own reasoning for why a +manual attach may re-arm is that the adapter's write is idempotent over its +previous output, so the manual write and the reconciler's next `perform()` +"briefly do the same work twice, which is free". An adapter that is +idempotent over its own output makes "already correct" and "just written" the +same fact about disk; the daemon-managed exit is the case where the first +write already happened, not a case where no write is required. + +### Ordered ahead of the asset tail + +**At both exits the re-arm runs before `materializeAttachAssets`.** The two +tails are independent, so their order is only visible on failure, and there it +matters in one direction only. `materializeAttachAssets` swallows a per-copy +failure, but not everything it does is guarded: the plan read, the prune pass, +and the digest of an installed asset can all throw, and a throw there reaches +the command loop's outer catch. With the re-arm second, an asset-tail failure +would leave the `refused` marker short-circuiting the reconciler forever, +after exactly the explicit re-run that is its only trigger. With the re-arm +first, that failure costs the assets and nothing else. It cannot cost the +assets in the other direction, because the re-arm logs and swallows its own +marker error (`client.attach.marker_retract_failed`) rather than throwing. + +## Consequences + +- `src/core/commands/clients.js` calls one shared `rearmRefusedAttachMarker` + helper from both success exits, so a future third exit is a call site to + add rather than logic to re-derive. +- LLP 0186's `#re-arm-explicit-hyp-attach-re-run-only` keeps its text; its + `Extended-by:` line names this document. +- No change to `action_reconciler.js`, to `ActionMarkerStatus`, or to any + handler: this settles a call site, not the marker seam. + +## Test strategy + +Extends LLP 0186's own "Re-arm" cases to the second exit, in +`test/core/attach-daemon-managed-tails.test.js`: + +- A `refused` marker is re-armed when `hyp client attach` takes the daemon-managed + already-attached exit. +- A `done` marker and its `installed_assets` still survive that exit + untouched (LLP 0138 `#marker-undo` holds at both exits). +- The re-arm still fires when the asset tail after it fails, proving the + ordering above. + +## References + +- [LLP 0186](./0186-reconciler-refused-marker.design.md): the `refused` + marker state and the re-arm rule this document scopes to both exits +- [LLP 0138](./0138-client-assets-one-install.decision.md): `#marker-undo`, + why a `done` marker is never collateral of the re-arm +- [LLP 0107](./0107-skills-ride-attach.decision.md): the asset tail + the re-arm is now ordered ahead of +- [LLP 0174](./0174-attach-prompts-to-enable.design.md): the backfill offer, + the other tail this same exit was skipping +- [LLP 0250](./0250-marker-records-the-effect-it-overwrites.decision.md): the + other extension of LLP 0186's marker rules +- `src/core/commands/clients.js`: both success exits and the shared + `rearmRefusedAttachMarker` helper diff --git a/src/core/commands/clients.js b/src/core/commands/clients.js index 3148b3873..a3184eb64 100644 --- a/src/core/commands/clients.js +++ b/src/core/commands/clients.js @@ -204,7 +204,19 @@ async function runClientLifecycle(action, argv, ctx) { error: message, }) + '\n' ) - } else { + } else if (!promptResult.reported) { + // The prompt already said what happened, in terms of the state it + // left behind ("the config change already persists ... re-running + // resumes from the new state"). `enablement.message` was computed + // before that write and reads "not enabled ... add to + // ": printing it under the first line contradicts it and + // instructs an edit that has already been made. The guided message + // is for the paths that never wrote anything, which includes a + // write that itself failed: there the guided remedy is still true + // and is the only line naming the config path. + // @ref LLP 0174#prompt [implements]: each step reports its own + // failure, so a step that reported one is not re-reported as the + // pre-write refusal ctx.stderr.write(`error: ${message}\n`) } }, @@ -288,7 +300,14 @@ async function runClientLifecycle(action, argv, ctx) { } if (!client) { if (enablement.state !== 'unknown') { - reportAttachEnablement({ name, enablement, parsed, ctx }) + // Same rule as the capability gate above: a prompt that already + // reported its own step's failure owns the *human* message. Only + // that line is suppressed, exactly as the capability gate + // suppresses only its `stderr.write`: the structured warn and the + // `--json` payload are the machine record of a failed attach and + // no prompt writes either, so dropping them here would lose the + // failure from the log and from a scripted caller's output. + reportAttachEnablement({ name, enablement, parsed, ctx, quiet: promptResult.reported }) exitCode = 1 continue } @@ -387,16 +406,54 @@ async function runClientLifecycle(action, argv, ctx) { `the daemon manages attach for this install, so only its assets are refreshed.\n` ) } + // The three tails below `client.attach()` are reached from here + // too, because this branch is a successful attach: it is the + // exit an explicit `hyp client attach ` takes on a + // daemon-managed install, the shape an operator most often runs + // it on. Skipping them made this the one path where the re-arm + // LLP 0186 specifies never happens and where LLP 0174's step-4 + // offer, the whole point of the accept path that just ran, is + // never made. + // + // Reached with the settings already in place rather than freshly + // written, which changes nothing any tail depends on: the + // re-arm's precondition is an explicit `hyp client attach` that + // succeeded (LLP 0295 scopes it to the manual re-run, not to a + // write having happened), and the offer's is that this + // invocation enabled the adapter. With a live endpoint the + // marker was validated against it, so "already correct" is the + // same fact as "just written"; with no live endpoint to compare + // against, a present marker is a no-op success by the pre-#277 + // rule above, and this is still the explicit re-run the re-arm + // is scoped to. + // + // Ordered re-arm before materialize, the same order the + // freshly-wired exit below uses. `materializeAttachAssets` + // swallows a per-copy failure, but not everything it does is + // guarded (the plan read, the prune pass, the digest of an + // installed asset), and a throw there lands in the loop's outer + // catch: with the re-arm second, an asset-tail failure would + // leave the `refused` marker short-circuiting the reconciler + // forever after exactly the explicit re-run that is its only + // trigger. The re-arm cannot fail the other way round, since it + // logs and swallows its own marker error. + // @ref LLP 0295#both-success-exits [implements]: the re-arm runs at whichever success exit the explicit re-run takes, ahead of the asset tail so an asset failure cannot swallow it + rearmRefusedAttachMarker({ name, ctx, dryRun: false }) // The settings are already wired, but attach means settings *and* // assets, and this branch is the one an operator on a // daemon-managed install actually reaches. Short-circuiting past - // the materialization below would make `hyp attach` install - // nothing on exactly the install shape it is most often run on, + // the materialization would make `hyp client attach` install nothing on + // exactly the install shape it is most often run on, // reintroducing the split this change removes. Idempotent and // cheap, so running it on a no-op attach costs a stat pass. // @ref LLP 0107#every-attach [implements]: every attach path // materializes, including the one with nothing left to wire await materializeAttachAssets({ name, descriptorMap, ctx, dryRun: false, json: parsed.json }) + // @ref LLP 0174#prompt [implements]: step 4's backfill consent + // follows the accept path to whichever attach exit it reaches + if (activatedViaPrompt) { + await maybeBackfillAfterEnable({ name, ctx }) + } continue } if (liveEndpoint) { @@ -456,44 +513,7 @@ async function runClientLifecycle(action, argv, ctx) { dryRun: parsed.dryRun, json: parsed.json, }) - // A successful manual attach is the only re-arm a `refused` marker gets - // in this pass: after it, the next reconcile pass must stop - // short-circuiting on the marker and re-`perform()` the request key. - // - // Scoped to a `refused` marker, and skipped on `--dry-run`, on purpose. - // A `done` marker is the only record naming the files an org-driven - // attach installed, so clearing it would strand them past any later - // `hyp detach`, which reads exactly this marker to know what to remove - // (LLP 0138#marker-undo). A `failed` marker needs no help: nothing - // short-circuits it, so the next pass already retries it. And a dry run - // must leave the marker store exactly as it found it, the same way the - // detach path returns before its own clear under `--dry-run`. - // - // The re-arm itself is a drop only when the marker records no - // `installed_assets`. One that carries them is the same undo record a - // `done` marker is (a refusal on a re-`perform()` carries the earlier - // successful attach's copies forward), so it is rewritten to `failed` - // rather than dropped: same re-arm, record intact. That branch lives in - // `rearmRefusedActionMarker` beside the store it rewrites. - // - // Best-effort: a marker-store I/O failure must never fail the attach that - // just succeeded. - // @ref LLP 0186#re-arm-explicit-hyp-attach-re-run-only [implements]: an explicit hyp attach re-arms a refused marker, and only that; the reconciler never re-arms one on its own - if (parsed.dryRun !== true) { - try { - rearmRefusedActionMarker({ - stateRoot: readObservabilityEnv(ctx.env).stateDir, - kind: 'attach', - requestKey: name, - }) - } catch (markerErr) { - getLogger('cmd-attach').warn('client.attach.marker_retract_failed', { - hyp_client: name, - error_kind: 'marker_retract_failed', - detail: markerErr instanceof Error ? markerErr.message : String(markerErr), - }) - } - } + rearmRefusedAttachMarker({ name, ctx, dryRun: parsed.dryRun === true }) // Attach wires a client into HypAware, and its registered skills and // subagents are part of that wiring: manual attach skipping them was the // inconsistency, not the norm (the wizard has always treated @@ -525,6 +545,55 @@ async function runClientLifecycle(action, argv, ctx) { return exitCode } +/** + * Re-arm a `refused` attach marker after a successful manual attach, the only + * re-arm one gets in this pass: after it, the next reconcile pass must stop + * short-circuiting on the marker and re-`perform()` the request key. + * + * Scoped to a `refused` marker, and skipped on `--dry-run`, on purpose. A + * `done` marker is the only record naming the files an org-driven attach + * installed, so clearing it would strand them past any later `hyp client detach`, + * which reads exactly this marker to know what to remove (LLP 0138#marker-undo). + * A `failed` marker needs no help: nothing short-circuits it, so the next pass + * already retries it. And a dry run must leave the marker store exactly as it + * found it, the same way the detach path returns before its own clear under + * `--dry-run`. + * + * The re-arm itself is a drop only when the marker records no + * `installed_assets`. One that carries them is the same undo record a `done` + * marker is (a refusal on a re-`perform()` carries the earlier successful + * attach's copies forward), so it is rewritten to `failed` rather than + * dropped: same re-arm, record intact. That branch lives in + * `rearmRefusedActionMarker` beside the store it rewrites. + * + * Best-effort: a marker-store I/O failure must never fail the attach that just + * succeeded. + * + * A function rather than an inline block because attach has two success + * exits, the freshly-wired one and the daemon-managed already-current one, and + * the second silently had no re-arm at all while this lived in the first. + * + * @ref LLP 0186#re-arm-explicit-hyp-attach-re-run-only [implements]: an explicit hyp client attach re-arms a refused marker, and only that; the reconciler never re-arms one on its own + * @param {{ name: string, ctx: CommandRunContext, dryRun: boolean }} args + * @returns {void} + */ +function rearmRefusedAttachMarker({ name, ctx, dryRun }) { + if (dryRun) return + try { + rearmRefusedActionMarker({ + stateRoot: readObservabilityEnv(ctx.env).stateDir, + kind: 'attach', + requestKey: name, + }) + } catch (markerErr) { + getLogger('cmd-attach').warn('client.attach.marker_retract_failed', { + hyp_client: name, + error_kind: 'marker_retract_failed', + detail: markerErr instanceof Error ? markerErr.message : String(markerErr), + }) + } +} + /** * Resolve *why* `name` cannot be attached right now, so the failure names the * enablement layer instead of dead-ending on "unknown client". @@ -614,15 +683,21 @@ async function resolveAttachEnablementState({ name, ctx }) { * attach failure in this file uses (the capability gate renders its own * because it also owns the failure span). * + * `quiet` drops only the human stderr line, for the caller whose enable + * prompt already printed a truer one; the structured warn and the `--json` + * payload always run, because no prompt writes either and they are the only + * machine-readable record that this attach failed. + * * @param {{ * name: string, * enablement: { state: 'not_enabled' | 'disabled_central', errorKind: string, message: string }, * parsed: { dryRun: boolean, json: boolean }, * ctx: CommandRunContext, + * quiet?: boolean, * }} args * @returns {void} */ -function reportAttachEnablement({ name, enablement, parsed, ctx }) { +function reportAttachEnablement({ name, enablement, parsed, ctx, quiet = false }) { getLogger('cmd-attach').warn('client.attach.adapter_inactive', { [Attr.COMPONENT]: 'cmd-attach', [Attr.OPERATION]: 'client.attach', @@ -643,6 +718,7 @@ function reportAttachEnablement({ name, enablement, parsed, ctx }) { ) return } + if (quiet) return ctx.stderr.write(`error: ${enablement.message}\n`) } @@ -658,6 +734,15 @@ function reportAttachEnablement({ name, enablement, parsed, ctx }) { * of them reach {@link enableClientAdapter}, so there is no write, no backup, * and no restart to undo. * + * `reported` distinguishes the two ways this can answer "not activated". + * Every early return, the decline, and a failed config write leave the disk + * exactly as the caller's pre-write guided error describes it, so that error + * is still the right thing to print: `reported` stays false. The failures + * *after* the write landed (a failed `restart`/`wait` step, and an + * incomplete in-process activation) have already described the state they + * left on disk, which that same guided error would deny: `reported` is true + * there, and the caller prints nothing more. + * * @ref LLP 0174#bootstrap-floor [implements]: no local config file at all * skips the prompt outright and falls through to the caller's existing * `not_enabled` refusal (which already names `hyp init`) rather than asking @@ -672,7 +757,7 @@ function reportAttachEnablement({ name, enablement, parsed, ctx }) { * parsed: { client: string, json: boolean, dryRun: boolean }, * enablement: { state: 'unknown' } | { state: 'not_enabled' | 'disabled_central', errorKind: string, message: string }, * }} args - * @returns {Promise<{ activated: boolean }>} + * @returns {Promise<{ activated: boolean, reported?: boolean }>} */ async function maybeInteractiveEnableAttach({ name, ctx, parsed, enablement }) { // `disabled_central` never reaches this prompt (LLP 0174 #detection): a @@ -756,7 +841,15 @@ async function maybeInteractiveEnableAttach({ name, ctx, parsed, enablement }) { }) if (!result.ok) { reportEnableFailure({ name, result, ctx }) - return { activated: false } + // Only a failure *below* the write owns the caller's message. Its report + // describes state that now exists on disk ("the config change already + // persists"), which the caller's pre-write guided error would deny. A + // failed write is the other shape {@link reportEnableFailure} documents: + // it changed nothing, so that guided error is still true, and it is the + // only line that names the config path and the manual remedy. Suppressing + // it there would leave "the config write failed; nothing changed" with no + // next step. + return { activated: false, reported: (result.failedStep ?? 'write') !== 'write' } } // The write and (if a daemon is installed) the restart already landed; what @@ -779,7 +872,7 @@ async function maybeInteractiveEnableAttach({ name, ctx, parsed, enablement }) { status: 'failed', [Attr.ERROR_KIND]: 'activation_incomplete', }) - return { activated: false } + return { activated: false, reported: true } } getLogger('cmd-attach').info('client.attach.enable_prompt', { diff --git a/src/core/daemon/service_ops.js b/src/core/daemon/service_ops.js index 5b3fd319c..8f3d478a3 100644 --- a/src/core/daemon/service_ops.js +++ b/src/core/daemon/service_ops.js @@ -141,7 +141,8 @@ function serviceManagerSpawnRefusal(bin, args) { * killed and the promise rejects with {@link ServiceCommandTimeoutError}; * `SIGKILL` rather than `SIGTERM` because the case worth bounding is a * process blocked on a GUI keychain prompt, which is exactly the state that - * ignores a polite signal. + * ignores a polite signal. Only a bounded caller is ever killed, so that + * signal can never interrupt a half-applied mutation. * * @param {string} bin * @param {string[]} args @@ -165,7 +166,15 @@ export function runServiceCommand(bin, args, opts = {}) { if (timeoutMs !== undefined) { timer = setTimeout(function() { timedOut = true + // Settle on the deadline itself, not on the kill's own `close`. A + // child that leaves a grandchild holding the inherited pipe never + // emits one, and waiting for it would reinstate the unbounded wait + // this timer exists to remove. Detaching the three handles is what + // lets the calling process exit while such a child winds down. proc.kill('SIGKILL') + proc.stdout.destroy() + proc.stderr.destroy() + proc.unref() reject(new ServiceCommandTimeoutError( `'${[bin, ...args].join(' ')}' did not finish within ${timeoutMs}ms and was killed` )) diff --git a/src/core/daemon/status.js b/src/core/daemon/status.js index 0246b07df..b058014d0 100644 --- a/src/core/daemon/status.js +++ b/src/core/daemon/status.js @@ -1957,12 +1957,19 @@ const TRUST_PROBE_TIMEOUT_MS = 5_000 * "non-macOS platforms skip this entirely"), and with no CA on disk proxy * mode was never on, so there is nothing to be trusted or untrusted. * - * The two probes shell out, so each is caught independently: a probe that + * The two probes shell out, so each is settled independently: a probe that * could not run reports `null` (unknown), never `false`, because "the * dialog was cancelled" and "`security` did not run" are different answers - * and only the first is actionable. The fingerprint is computed locally from - * the DER and is `[0-9A-F:]` by construction, and probe stderr is deliberately - * not surfaced, so neither needs bounding. + * and only the first is actionable. "Did not run" covers one case a try/catch + * cannot reach on its own: a probe that never returns. Both probes therefore + * spawn on a deadline (`TRUST_PROBE_TIMEOUT_MS`) and reject when it passes, + * and they are started concurrently so the worst case is one deadline rather + * than the sum of both. An offline or captive-portal host, where macOS trust + * evaluation can sit on a revocation fetch indefinitely, then still gets a + * rendered report with these lines unknown instead of a `hyp status` that + * never prints. The fingerprint is computed locally from the DER and is + * `[0-9A-F:]` by construction, and probe stderr is deliberately not surfaced, + * so neither of those needs sanitizing. * * The permitted host set travels with the fingerprint because the grant is * wider than any one install uses: the CA is constrained to the whole static @@ -2002,21 +2009,21 @@ async function collectProxyTrust({ platform, stateRoot, isCaTrustedFn, isLaunchd } if (!ca) return null + // Started together, not one after the other: the two probes read + // unrelated system state (the login keychain, the launchd environment) and + // neither reads the other's answer, so serializing them only adds their + // deadlines. On the wedged host this bound exists for that is the + // difference between the report stalling for one probe timeout and for + // two. `allSettled` keeps the per-probe independence the catches gave: one + // rejection reports its own line unknown and leaves the other's answer. + const [trustedResult, launchdResult] = await Promise.allSettled([ + isCaTrustedFn({ certPath: ca.certPath }), + isLaunchdEnvSetFn(), + ]) /** @type {boolean | null} */ - let trusted = null - try { - trusted = await isCaTrustedFn({ certPath: ca.certPath }) - } catch { - trusted = null - } - + const trusted = trustedResult.status === 'fulfilled' ? trustedResult.value : null /** @type {boolean | null} */ - let launchdEnvSet = null - try { - launchdEnvSet = await isLaunchdEnvSetFn() - } catch { - launchdEnvSet = null - } + const launchdEnvSet = launchdResult.status === 'fulfilled' ? launchdResult.value : null return { caFingerprint: ca.fingerprint, hosts: displayableCaHosts(ca.hosts), trusted, launchdEnvSet } } diff --git a/src/core/tls/darwin_trust.js b/src/core/tls/darwin_trust.js index 40abc2ae5..24fcd36a0 100644 --- a/src/core/tls/darwin_trust.js +++ b/src/core/tls/darwin_trust.js @@ -53,9 +53,13 @@ export function loginKeychainPath(homeDir = os.homedir()) { * * `timeoutMs` bounds the spawn for callers that cannot afford to block on it. * Silent is the expectation, not a guarantee: a locked login keychain can put - * `security` in front of a GUI prompt, and a caller nobody is watching (`hyp - * status`) would then wait on an answer forever. Left unset the wait is - * unbounded, which is what an interactive attach wants. + * `security` in front of a GUI prompt, and macOS trust evaluation can reach + * the network for revocation, so on an offline or captive-portal host this is + * not a slow command but one that may never return. A caller nobody is + * watching (`hyp status`) would then wait on an answer forever, and reads the + * rejection as unknown instead. Left unset the wait is unbounded, which is + * what an interactive attach wants. + * @ref LLP 0237#consequences [constrained-by]: hyp status has to be able to state the trust line, so the probe behind it must be able to give up * * @param {object} args * @param {string} args.certPath diff --git a/test/core/attach-daemon-managed-tails.test.js b/test/core/attach-daemon-managed-tails.test.js new file mode 100644 index 000000000..517dfc6aa --- /dev/null +++ b/test/core/attach-daemon-managed-tails.test.js @@ -0,0 +1,419 @@ +// @ts-check + +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { PassThrough } from 'node:stream' +import test from 'node:test' + +import { runAttach } from '../../src/core/commands/clients.js' +import { readClientActionStatus } from '../../src/core/config/action_reconciler.js' + +/** + * The daemon-managed "already attached at the live port" branch is a success + * exit of `hyp client attach`, and it is the one an operator on a default install + * actually reaches: the gateway is not bound in this CLI process, no `listen` + * is configured, and the marker in the client's settings already names the + * daemon's live port. It used to `continue` straight after materializing + * assets, so it was the one attach path that reached neither tail below + * `client.attach()`: + * + * - the `refused`-marker re-arm, which LLP 0186 makes the ONLY re-arm a + * refused marker gets, and whose sole trigger is exactly this explicit + * re-run; + * - LLP 0174 step 4's backfill consent, the offer the accept path exists to + * reach, which the accept branch had just earned by enabling the adapter in + * this same invocation. + * + * @import { CommandRunContext } from '../../hypaware-plugin-kernel-types.js' + * + * @ref LLP 0295#both-success-exits [tests]: the explicit re-run re-arms a + * refused marker on the daemon-managed exit too, and does so ahead of the + * asset tail that can throw + * @ref LLP 0174#prompt [tests]: step 4's backfill consent is reached from the + * attach exit the accept path actually lands on here + */ + +/** + * @param {{ onWrite?: (chunk: unknown) => void }} [opts] + * @returns {{ write(chunk: unknown): boolean, text(): string }} + */ +function makeBuf(opts = {}) { + let value = '' + return { + write(chunk) { + value += String(chunk) + opts.onWrite?.(chunk) + return true + }, + text() { + return value + }, + } +} + +/** + * A TTY stdin that feeds queued answers, the first pre-buffered and each later + * one on demand. Same shape (and same reason) as + * test/core/attach-enable-backfill.test.js's fixture: an answer written before + * its own question's `readline.Interface` exists can be eaten by the previous + * one. + * + * @param {string[]} answers + * @returns {{ stream: PassThrough, feedNext(): void }} + */ +function makeAnswerStdin(answers) { + const stream = new PassThrough() + Object.defineProperty(stream, 'isTTY', { value: true }) + const queue = [...answers] + const first = queue.shift() + if (first !== undefined) stream.write(`${first}\n`) + return { + stream, + feedNext() { + const next = queue.shift() + if (next !== undefined) stream.write(`${next}\n`) + }, + } +} + +/** @param {string} home */ +function stateRoot(home) { + return path.join(home, '.hyp', 'hypaware') +} + +/** @param {string} home */ +function writeLocalConfig(home) { + const configPath = path.join(home, '.hyp', 'hypaware-config.json') + mkdirSync(path.dirname(configPath), { recursive: true }) + writeFileSync(configPath, JSON.stringify({ version: 2, plugins: [] })) +} + +/** + * Seed the daemon run dir with a live pid file (this process, guaranteed + * alive) and a status.json naming the gateway's bound port, plus the client + * settings marker already at that same port. Together these are the + * "daemon-managed, already current" state. + * + * @param {string} home + * @param {number} port + */ +function seedDaemonManagedAttach(home, port) { + const runDir = path.join(stateRoot(home), 'run') + mkdirSync(runDir, { recursive: true }) + writeFileSync( + path.join(runDir, 'hypaware.pid'), + JSON.stringify({ pid: process.pid, runId: 'test-run', mode: 'foreground' }) + ) + writeFileSync( + path.join(runDir, 'status.json'), + JSON.stringify({ + state: 'healthy', + pid: process.pid, + startedAt: new Date().toISOString(), + uptimeMs: 0, + runId: 'test-run', + mode: 'foreground', + sources: [ + { + name: 'ai-gateway', + plugin: '@hypaware/ai-gateway', + state: 'started', + details: { host: '127.0.0.1', port, upstreams: ['anthropic'] }, + }, + ], + sinks: [], + }) + ) + mkdirSync(path.join(home, '.claude'), { recursive: true }) + writeFileSync( + path.join(home, '.claude', 'settings.json'), + JSON.stringify({ _hypaware: { version: '2.0.0', port } }) + ) +} + +/** + * Seed a `refused` attach marker, as `action_attach.js`'s `perform()` would + * have recorded a permanent precondition refusal (LLP 0186): no `attempts`. + * + * @param {string} home + */ +function seedRefusedMarker(home) { + const dir = path.join(stateRoot(home), 'config-control') + mkdirSync(dir, { recursive: true }) + writeFileSync( + path.join(dir, 'client-actions.json'), + JSON.stringify({ + attach: { + claude: { + status: 'refused', + request_key: 'claude', + reason: '~/.claude/settings.json appears to be JSONC; refuse to modify', + at: '2026-08-01T00:00:00.000Z', + }, + }, + }) + '\n' + ) +} + +/** + * A minimal `BackfillContribution`-shaped provider that yields nothing, so + * only the fact that it ran matters. + * + * @param {string} name + * @param {() => void} onRun + */ +function makeProvider(name, onRun) { + return { + name, + plugin: `@hypaware/${name}`, + datasets: ['ai_gateway_messages'], + async *run() { + onRun() + }, + } +} + +/** + * A daemon-managed context: the gateway capability is present (or becomes + * present via the enable prompt) but unbound in this process, so + * `localEndpoint()` throws and attach falls through to the status.json port + * discovery. `preEnabled` picks which of the two shapes this is: an adapter + * already enabled coming in, or one the enable prompt turns on here. + * + * `brokenAssets` makes the asset tail throw rather than warn: the plan is read + * through `skills.list()`, so a registry that cannot answer propagates out of + * `materializeClientAssets` into attach's outer catch, the same way the + * unguarded halves of the prune and digest passes would. + * + * @param {{ + * home: string, + * preEnabled: boolean, + * answers?: string[], + * backfillProvider?: ReturnType, + * brokenAssets?: boolean, + * }} opts + */ +function makeDaemonManagedCtx({ home, preEnabled, answers = [], backfillProvider, brokenAssets = false }) { + /** @type {string[]} */ + const registered = preEnabled ? ['claude'] : [] + /** @type {{ name: string }[]} */ + const plugins = [] + /** @type {string[]} */ + const attachCalls = [] + const gateway = { + localEndpoint() { + throw new Error('ai-gateway: localEndpoint() called before the gateway started') + }, + /** @param {string} name */ + getClient(name) { + if (!registered.includes(name)) return undefined + return { + name, + async attach() { + attachCalls.push(name) + }, + } + }, + listClients() { + return registered.map((name) => ({ name })) + }, + } + const stdin = makeAnswerStdin(answers) + const stdout = makeBuf({ + onWrite: (chunk) => { + if (String(chunk).includes('[Y/n]: ')) stdin.feedNext() + }, + }) + const stderr = makeBuf({ + onWrite: (chunk) => { + // The LLP 0244 proxy-migration question sits between the enable prompt + // and the backfill consent; decline it directly so the queued answers + // keep meaning [enable, backfill]. + if (String(chunk).includes('Switch this install to proxy mode now? [y/N] ')) { + stdin.stream.write('n\n') + } + }, + }) + const ctx = /** @type {CommandRunContext} */ (/** @type {any} */ ({ + stdout, + stderr, + stdin: stdin.stream, + cwd: home, + env: { HOME: home, HYP_HOME: path.join(home, '.hyp') }, + config: { version: 2 }, + ...(brokenAssets + ? { + skills: { + list() { + throw new Error('asset registry unavailable') + }, + }, + } + : {}), + storage: { cacheRoot: home }, + query: {}, + backfillMaterializers: { get: () => undefined }, + backfills: { + /** @param {string} name */ + get: (name) => (backfillProvider && backfillProvider.name === name ? backfillProvider : undefined), + list: () => (backfillProvider ? [backfillProvider] : []), + }, + plugins, + capabilities: { + /** @param {string} id */ + has: (id) => (id === 'hypaware.ai-gateway' ? registered.length > 0 : false), + require: () => gateway, + }, + /** @param {string[]} names */ + activatePluginClosure: async (names) => { + for (const name of names) { + if (!plugins.some((p) => p.name === name)) plugins.push({ name }) + } + if (names.includes('@hypaware/claude')) registered.push('claude') + return { activated: names, failed: [] } + }, + })) + return { ctx, stdout, stderr, attachCalls } +} + +/** @param {(home: string) => Promise | void} fn */ +async function withTempHome(fn) { + const dir = mkdtempSync(path.join(tmpdir(), 'hyp-attach-daemon-tails-')) + try { + await fn(dir) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +test('an explicit attach on a daemon-managed install re-arms a refused marker', async () => { + await withTempHome(async (home) => { + writeLocalConfig(home) + seedDaemonManagedAttach(home, 55555) + seedRefusedMarker(home) + + const { ctx, stdout, stderr, attachCalls } = makeDaemonManagedCtx({ home, preEnabled: true }) + const code = await runAttach(['claude'], ctx) + assert.equal(code, 0, stderr.text()) + + // The branch under test, not some other exit: nothing was re-wired. + assert.match(stdout.text(), /already attached/) + assert.deepEqual(attachCalls, [], 'a marker already at the live port is a settings no-op') + + assert.equal( + readClientActionStatus({ stateRoot: stateRoot(home) }).byKind.attach?.claude, + undefined, + 'the explicit re-run is the only re-arm a refused marker gets, and this is the install shape it is run on' + ) + }) +}) + +test('the re-arm survives an asset tail that throws after it', async () => { + await withTempHome(async (home) => { + writeLocalConfig(home) + seedDaemonManagedAttach(home, 55555) + seedRefusedMarker(home) + + const { ctx, stderr } = makeDaemonManagedCtx({ home, preEnabled: true, brokenAssets: true }) + // The asset tail's throw is reported by attach's outer catch, so this run + // is a failure overall. That is exactly the case the ordering is for. + assert.equal(await runAttach(['claude'], ctx), 1) + assert.match(stderr.text(), /asset registry unavailable/) + + // Ordered ahead of the asset tail: a failure there costs the assets, not + // the one re-arm a refused marker will ever be offered. + assert.equal( + readClientActionStatus({ stateRoot: stateRoot(home) }).byKind.attach?.claude, + undefined, + 'the re-arm must already have landed before the tail that failed' + ) + }) +}) + +test('a done marker still survives that same daemon-managed attach', async () => { + await withTempHome(async (home) => { + writeLocalConfig(home) + seedDaemonManagedAttach(home, 55555) + const dir = path.join(stateRoot(home), 'config-control') + mkdirSync(dir, { recursive: true }) + const installed = [path.join(home, '.claude', 'skills', 'org-helper')] + writeFileSync( + path.join(dir, 'client-actions.json'), + JSON.stringify({ + attach: { + claude: { + status: 'done', + request_key: 'claude', + at: '2026-07-01T00:00:00.000Z', + installed_assets: installed, + }, + }, + }) + '\n' + ) + + const { ctx, stderr } = makeDaemonManagedCtx({ home, preEnabled: true }) + assert.equal(await runAttach(['claude'], ctx), 0, stderr.text()) + + // The re-arm is scoped to `refused`: a `done` marker is the only record + // naming the files an org-driven attach installed (LLP 0138#marker-undo). + const marker = readClientActionStatus({ stateRoot: stateRoot(home) }) + .byKind.attach?.claude + assert.equal(marker?.status, 'done') + assert.deepEqual(marker?.installed_assets, installed) + }) +}) + +test('the post-enable backfill offer is reached on the daemon-managed already-attached exit', async () => { + await withTempHome(async (home) => { + writeLocalConfig(home) + // The concrete state: the config lost the Claude adapter, but the settings + // marker still names the daemon's current port. The enable prompt turns + // the adapter on, the probe then reports `alreadyCurrent`, and step 4's + // offer must still be made for the client this invocation just enabled. + seedDaemonManagedAttach(home, 55555) + let ran = false + const provider = makeProvider('claude', () => { ran = true }) + const { ctx, stdout, stderr } = makeDaemonManagedCtx({ + home, + preEnabled: false, + answers: ['y', 'y'], + backfillProvider: provider, + }) + const code = await runAttach(['claude'], ctx) + assert.equal(code, 0, stderr.text()) + + assert.match(stdout.text(), /already attached/, 'the exit under test is the daemon-managed one') + assert.equal(ran, true, 'the accept path must reach its own backfill offer on this exit too') + assert.match(stdout.text(), /backfill claude: ok/) + + // The write really did land, so the enable half of the flow is genuine. + const written = JSON.parse(readFileSync(path.join(home, '.hyp', 'hypaware-config.json'), 'utf8')) + assert.ok( + written.plugins.some((/** @type {{ name: string }} */ p) => p.name === '@hypaware/claude'), + 'the enable prompt wrote the adapter entry' + ) + }) +}) + +test('an adapter that was already enabled reaches no backfill offer on that exit', async () => { + await withTempHome(async (home) => { + writeLocalConfig(home) + seedDaemonManagedAttach(home, 55555) + let ran = false + const provider = makeProvider('claude', () => { ran = true }) + // No enable prompt ran, so `activatedViaPrompt` is false and step 4 stays + // confined to the accept branch it is scoped to. + const { ctx, stdout, stderr } = makeDaemonManagedCtx({ + home, + preEnabled: true, + backfillProvider: provider, + }) + assert.equal(await runAttach(['claude'], ctx), 0, stderr.text()) + + assert.equal(ran, false) + assert.doesNotMatch(stdout.text(), /backfill claude:/) + }) +}) diff --git a/test/core/attach-enable-resume.test.js b/test/core/attach-enable-resume.test.js index 1f9964b8a..2213e70e6 100644 --- a/test/core/attach-enable-resume.test.js +++ b/test/core/attach-enable-resume.test.js @@ -1,7 +1,7 @@ // @ts-check import assert from 'node:assert/strict' -import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { PassThrough } from 'node:stream' @@ -31,6 +31,9 @@ import { installFakeDaemonService } from '../helpers/daemon_service_fixture.js' // @ref LLP 0174#prompt [tests]: a config write that already landed means the // next invocation never re-prompts, it falls through to the registered/ // endpoint-unreachable path +// @ref LLP 0174#prompt [tests]: "each step reports its own failure" means one +// report, so the pre-write guided error is not printed under a step report +// that already described the state the write left behind /** @returns {{ write(chunk: unknown): boolean, text(): string }} */ function makeBuf() { @@ -82,9 +85,12 @@ function backupsIn(dir) { * pre-loaded with `answer`, and an `activatePluginClosure` stub mimicking * real in-process activation. * - * @param {{ home: string, answer?: string }} opts + * `activationFails` models the second partial-failure exit: the config write + * lands, but this process's own kernel never brings the plugin up. + * + * @param {{ home: string, answer?: string, activationFails?: boolean }} opts */ -function makeNotEnabledCtx({ home, answer }) { +function makeNotEnabledCtx({ home, answer, activationFails }) { /** @type {string[]} */ const registered = [] /** @type {{ name: string }[]} */ @@ -119,6 +125,7 @@ function makeNotEnabledCtx({ home, answer }) { }, /** @param {string[]} names */ activatePluginClosure: async (names) => { + if (activationFails) return { activated: [], failed: names } for (const name of names) { if (!plugins.some((p) => p.name === name)) plugins.push({ name }) } @@ -209,6 +216,75 @@ test('accept, write succeeds, restart fails: names the restart step, the backup // Exactly one backup: enableClientAdapter ran (and failed) exactly once, // not retried within this invocation. assert.equal(backupsIn(path.dirname(localConfigPath(home))).length, 1) + + // And nothing contradicts it. The caller's guided refusal was computed + // before the write and says the adapter "is not enabled ... add + // @hypaware/claude to and run 'hyp daemon restart'": under the + // line above, it denies the write that just landed and instructs an edit + // already made. One failure, one report. + assert.doesNotMatch(message, /error: the claude adapter is not enabled on this install/) + assert.doesNotMatch(message, /enable it with 'hyp setup'/) + assert.equal( + (message.match(/error: /g) ?? []).length, + 1, + `expected exactly one error line, got:\n${message}` + ) + }) +}) + +test('accept, write succeeds, in-process activation fails: the activation report is not contradicted either', async () => { + await withTempHome(async (home) => { + writeLocalConfig(home) + // The other partial-failure exit below `enableClientAdapter`: the write + // (and, with no daemon marker on disk, no restart at all) succeeded, and + // this process could not activate what the config now names. + const { ctx, stderr } = makeNotEnabledCtx({ home, answer: 'y', activationFails: true }) + const code = await runAttach(['claude'], ctx) + assert.equal(code, 1) + + const message = stderr.text() + assert.match(message, /could not activate it in this process/) + assert.match(message, /re-run 'hyp client attach claude' to finish/) + assert.doesNotMatch(message, /error: the claude adapter is not enabled on this install/) + assert.equal( + (message.match(/error: /g) ?? []).length, + 1, + `expected exactly one error line, got:\n${message}` + ) + }) +}) + +test('accept, the write itself fails: the guided remedy still prints, because nothing was written to contradict', async () => { + await withTempHome(async (home) => { + writeLocalConfig(home) + // The third exit below `enableClientAdapter`, and the one that is NOT a + // partial failure: an unwritable config dir means neither the `.bak-` + // backup nor the rewrite can land, so the run ends exactly where it + // started. `reportEnableFailure`'s own message for this shape says only + // that the write failed and that nothing changed; it names no path and no + // next step, so the caller's pre-write guided error is the only line that + // does, and it is still true. Suppressing it here would be the reverse of + // the contradiction the two cases above pin. + const configDir = path.dirname(localConfigPath(home)) + chmodSync(configDir, 0o500) + try { + const { ctx, stderr } = makeNotEnabledCtx({ home, answer: 'y' }) + const code = await runAttach(['claude'], ctx) + assert.equal(code, 1) + + const message = stderr.text() + assert.match(message, /the config write failed/) + assert.match(message, /nothing changed/) + assert.match(message, /the claude adapter is not enabled on this install/) + assert.match(message, /add @hypaware\/claude to /) + } finally { + chmodSync(configDir, 0o700) + } + + // And the disk really is untouched: no entry, no backup. + const written = JSON.parse(readFileSync(localConfigPath(home), 'utf8')) + assert.deepEqual(written.plugins, []) + assert.equal(backupsIn(path.dirname(localConfigPath(home))).length, 0) }) }) diff --git a/test/core/service-command-timeout.test.js b/test/core/service-command-timeout.test.js new file mode 100644 index 000000000..2e3fefdb4 --- /dev/null +++ b/test/core/service-command-timeout.test.js @@ -0,0 +1,188 @@ +// @ts-check + +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import process from 'node:process' +import test from 'node:test' +import { fileURLToPath, pathToFileURL } from 'node:url' + +// `hyp status` on darwin with a CA on disk shells out twice: `security +// verify-cert` and `launchctl getenv`. Neither had a deadline, and +// `runServiceCommand` only ever settled on the child's `close`, so a probe +// that never returned was not a caught error, it was a `hyp status` that +// printed nothing and never exited. macOS trust evaluation can reach the +// network for revocation, so an offline or captive-portal host on a +// proxy-mode install is the realistic trigger. +// +// Both facts under test are about a real spawned child that really does not +// return, so both are proved in a child process: the guard of LLP 0181 refuses +// every spawn inside the test runner, and a fake child would prove nothing +// about killing a real one. The scripts below are named `.mjs` and run with no +// `--test`, with `NODE_TEST_CONTEXT` deleted, so the guard is legitimately +// inactive in them rather than switched off. +// +// @ref LLP 0237#consequences [tests]: the trust line is reported, or reported +// unknown, but never at the cost of the report +// @ref LLP 0181#the-guard [constrained-by]: the spawn seam refuses inside the +// test runner, so a real hanging child has to be driven from outside it + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') + +/** @param {string} rel */ +function moduleUrl(rel) { + return pathToFileURL(path.join(REPO_ROOT, rel)).href +} + +/** @param {(dir: string) => void} fn */ +function withTempDir(fn) { + const dir = mkdtempSync(path.join(tmpdir(), 'hyp-svc-timeout-')) + try { + fn(dir) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +/** + * Write an executable shell stub that never returns, the shape of the failure + * this file exists for. `sleep` is a grandchild holding the pipe the wrapper + * inherited, so a kill of the stub alone does not close it: exactly the case + * that makes waiting for `close` after the kill the wrong move. + * + * @param {string} binDir + * @param {string} name + */ +function writeHangingStub(binDir, name) { + const stub = path.join(binDir, name) + writeFileSync(stub, '#!/bin/sh\nsleep 600\n') + chmodSync(stub, 0o755) + return stub +} + +/** + * Run a script outside the test runner and report how it went. `spawnSync`'s + * own timeout is the backstop that turns "the code under test hangs" into a + * failed assertion instead of a wedged suite. + * + * @param {string} script + * @param {{ pathPrefix?: string, timeout?: number }} [opts] + */ +function runOutsideTestRunner(script, opts = {}) { + /** @type {NodeJS.ProcessEnv} */ + const env = { ...process.env } + delete env.NODE_TEST_CONTEXT + delete env.HYP_ALLOW_REAL_SERVICE_MANAGER + if (opts.pathPrefix) env.PATH = `${opts.pathPrefix}${path.delimiter}${env.PATH ?? ''}` + const run = spawnSync(process.execPath, [script], { + cwd: REPO_ROOT, + encoding: 'utf8', + env, + timeout: opts.timeout ?? 60_000, + }) + return { + status: run.status, + signal: run.signal, + stdout: run.stdout ?? '', + output: `${run.stdout ?? ''}${run.stderr ?? ''}`, + } +} + +test('a service command that never returns is killed at its deadline, and the caller still exits', () => { + withTempDir((dir) => { + const binDir = path.join(dir, 'bin') + mkdirSync(binDir, { recursive: true }) + const hanging = writeHangingStub(binDir, 'hangs') + + const script = path.join(dir, 'timeout-probe.mjs') + writeFileSync(script, [ + `import { runServiceCommand } from ${JSON.stringify(moduleUrl('src/core/daemon/service_ops.js'))}`, + 'const startedAt = Date.now()', + 'let name = null', + 'try {', + ` await runServiceCommand(${JSON.stringify(hanging)}, [], { timeoutMs: 250 })`, + ' name = "resolved"', + '} catch (err) {', + ' name = err instanceof Error ? err.name : String(err)', + '}', + 'process.stdout.write(JSON.stringify({ name, elapsedMs: Date.now() - startedAt }))', + // No process.exit: the run only ends here if the killed child and its + // pipes stopped holding the event loop open, which is half the fix. + '', + ].join('\n')) + + const run = runOutsideTestRunner(script, { timeout: 20_000 }) + assert.equal( + run.signal, + null, + 'the probe never returned and nothing bounded it: hyp status would hang exactly like this', + ) + assert.equal(run.status, 0, run.output) + const result = JSON.parse(run.stdout) + assert.equal(result.name, 'ServiceCommandTimeoutError', run.output) + assert.ok(result.elapsedMs < 10_000, `expected the deadline to settle it, waited ${result.elapsedMs}ms`) + }) +}) + +// The end-to-end shape of the report: real `collectHypAwareStatus`, real +// probes, real spawn, against a `security` that never answers. The trust line +// reads unknown, and every other line is still rendered. +test('hyp status still renders when the darwin trust probe never returns', () => { + withTempDir((dir) => { + const binDir = path.join(dir, 'bin') + mkdirSync(binDir, { recursive: true }) + writeHangingStub(binDir, 'security') + // Only the trust probe hangs: `launchctl` answers at once, so its own line + // stays a real answer and the assertion below is about the hanging one. + const launchctl = path.join(binDir, 'launchctl') + writeFileSync(launchctl, '#!/bin/sh\nexit 1\n') + chmodSync(launchctl, 0o755) + + const script = path.join(dir, 'status-probe.mjs') + writeFileSync(script, [ + "import fs from 'node:fs'", + "import path from 'node:path'", + `import { collectHypAwareStatus } from ${JSON.stringify(moduleUrl('src/core/daemon/status.js'))}`, + `import { ensureLocalCa } from ${JSON.stringify(moduleUrl('src/core/tls/ca.js'))}`, + `import { defaultConfigPath } from ${JSON.stringify(moduleUrl('src/core/config/schema.js'))}`, + `const hypHome = ${JSON.stringify(path.join(dir, 'home'))}`, + "const stateRoot = path.join(hypHome, 'hypaware')", + "fs.mkdirSync(path.join(stateRoot, 'run'), { recursive: true })", + 'fs.writeFileSync(defaultConfigPath(hypHome), JSON.stringify({ version: 2, plugins: [] }) + "\\n")', + "const ca = await ensureLocalCa({ stateRoot, hosts: ['api.anthropic.com'] })", + 'const startedAt = Date.now()', + 'const report = await collectHypAwareStatus({', + " env: { ...process.env, HYP_HOME: hypHome, HYP_CONFIG: '' },", + " platform: 'darwin',", + ' isLaunchAgentInstalled: () => false,', + ' isSystemdUnitInstalled: () => false,', + '})', + 'process.stdout.write(JSON.stringify({', + ' elapsedMs: Date.now() - startedAt,', + ' fingerprintMatches: report.proxyTrust?.caFingerprint === ca.fingerprint,', + ' trusted: report.proxyTrust?.trusted ?? null,', + ' launchdEnvSet: report.proxyTrust?.launchdEnvSet ?? null,', + ' overall: report.overall,', + '}))', + // The report is what is under test, so end the run once it is printed + // rather than make this case depend on every handle the collector opens. + 'process.exit(0)', + '', + ].join('\n')) + + const run = runOutsideTestRunner(script, { pathPrefix: binDir, timeout: 60_000 }) + assert.equal( + run.signal, + null, + 'hyp status hung on an unbounded macOS trust probe instead of rendering a report', + ) + assert.equal(run.status, 0, run.output) + const result = JSON.parse(run.stdout) + assert.equal(result.trusted, null, 'a probe that never answered is unknown, never "not trusted"') + assert.equal(result.launchdEnvSet, false, 'the probe that did answer still reports its answer') + assert.equal(result.fingerprintMatches, true, 'the rest of the proxy-trust line is still rendered') + assert.equal(result.overall, 'healthy', 'an unknown trust line is not an outage') + }) +})