diff --git a/llp/0232-claude-attaches-by-proxy.decision.md b/llp/0232-claude-attaches-by-proxy.decision.md index 921d4215..ba706f18 100644 --- a/llp/0232-claude-attaches-by-proxy.decision.md +++ b/llp/0232-claude-attaches-by-proxy.decision.md @@ -12,7 +12,11 @@ environment, because the two-key attach alone breaks Remote Control inbound; see LLP 0236); LLP 0247 (#attach-writes-https_proxy-not-a-base-url: the gateway now serves absolute-form request-targets to registered hosts on forward-proxy listeners, so the case against `HTTP_PROXY` rests on the -no-plaintext-traffic-worth-capturing rationale alone) +no-plaintext-traffic-worth-capturing rationale alone); LLP 0259 +(#proxy-attach-preflight: this section says both "a missing CA is a refusal" +and "base-URL mode otherwise"; 0259 settles it in favour of the fallback, +which is what was built, and requires the CLI caller to name the downgrade +whenever config asked for a proxy) **Superseded-by (in part):** LLP 0262, LLP 0258 (accepted 2026-08-17; attaching the `claude` client writes a telemetry `env` block instead of `HTTPS_PROXY` and `NODE_EXTRA_CA_CERTS`; #mode-migration and the `prev_env` undo record are what the third mode reuses unchanged) diff --git a/llp/0244-attach-migrates-to-proxy-mode.decision.md b/llp/0244-attach-migrates-to-proxy-mode.decision.md index 4af4c5fb..30b7492b 100644 --- a/llp/0244-attach-migrates-to-proxy-mode.decision.md +++ b/llp/0244-attach-migrates-to-proxy-mode.decision.md @@ -7,7 +7,10 @@ **Date:** 2026-08-17 **Related:** LLP 0031, LLP 0174, LLP 0181, LLP 0232, LLP 0233, LLP 0242, LLP 0243 -**Extended-by:** LLP 0262 (accepted 2026-08-17; the migration machinery +**Extended-by:** LLP 0259 (#attach-offers: the gate reads the CA as well as +the config, so `proxy_mode: true` with no CA reaches a repair instead of +returning; #central-managed does not apply to that repair, which writes +nothing); LLP 0262 (accepted 2026-08-17; the migration machinery here is retargeted, not reverted: `hyp attach claude` migrates a proxy-mode install to the OTEL mode, unwinding the launchd environment and offering `detach --purge` for the CA trust) diff --git a/llp/0259-attach-repairs-a-stranded-proxy-install.decision.md b/llp/0259-attach-repairs-a-stranded-proxy-install.decision.md new file mode 100644 index 00000000..8f94cc8c --- /dev/null +++ b/llp/0259-attach-repairs-a-stranded-proxy-install.decision.md @@ -0,0 +1,157 @@ +# LLP 0259: attach repairs a stranded proxy install, and never downgrades in silence + +**Type:** Decision +**Status:** Accepted +**Systems:** Config, Plugins, Gateway, Daemon +**Author:** Phil / Claude +**Date:** 2026-08-17 +**Related:** LLP 0031, LLP 0232, LLP 0235, LLP 0237, LLP 0238, LLP 0242, +LLP 0243, LLP 0244 + +> `proxy_mode: true` with no local CA on disk is a real state, not an +> impossible one: `hyp detach --purge` produces it by design. Attach +> stops treating it as "nothing to do". It names the base-URL downgrade on +> every attach shape, offers the one repair that fixes it (a daemon restart, +> which re-mints the CA and writes no config), and `hyp status` reports the +> state as a warning instead of rendering nothing at all. + +## Context + +Three settled behaviours, each right on its own, compose into a dead end +(found on a real macOS install, 2026-08-17): + +1. Attach reads the mode off the CA file, never off config (LLP 0232 + #proxy-attach-preflight). The adapter cannot see config and must not + promise a transport the gateway is not serving. +2. `--purge` deletes the CA and leaves `proxy_mode` alone. Both halves are + deliberate: purge is the zero-residue exit (LLP 0238 #ca-survives-detach), + and nothing on the detach path has ever written config. +3. The LLP 0244 migration offer returns early on `proxy_mode: true`, because + its own question is about *writing* that key. + +So after `hyp detach claude --purge; hyp attach claude` the config asks for a +proxy, no CA exists, the daemon holds its already-loaded CA in memory and +never re-mints, attach writes a base-URL marker, and every surface reports +success. Remote Control inbound is dead and nothing says why. + +The repair already exists and already works: `maybeOfferProxyModeMigration` +runs before the endpoint is resolved precisely so an accepted switch can +restart the daemon and let `attach()` find the fresh CA. This is not a missing +capability; it is an existing repair that declines to fire in the one state +that needs it. + +### LLP 0232's preflight said two things + +`#proxy-attach-preflight` states both "a missing CA is a refusal +(`markActionRefused`), not a warning, and nothing is written" and, a paragraph +later, "attach uses proxy mode when a CA exists and base-URL mode otherwise". +Only the second was ever built; the only `CA_MISSING` refusal in the adapter +sits behind proxy mode having already been selected, so it cannot fire here. + +This decision settles the contradiction in favour of the fallback, and repairs +what made the fallback unsafe. Refusing outright would break the ordinary +base-URL install, which is most installs, and LLP 0243/0244 already have the +migration story. What was actually wrong was never the fallback; it was the +silence. + +## Decision + +### The gate reads config and the CA, not config alone {#gate-reads-both} + +`proxy_mode: true` covers two states and only one is settled: + +| Config | CA on disk | Meaning | +|---|---|---| +| `proxy_mode: true` | present | genuinely in proxy mode, nothing to do | +| `proxy_mode: true` | absent | config and machine disagree, repair needed | + +The attach-time gate returns early only for the first. The second reaches the +branch below. This extends LLP 0244 #attach-offers, whose gate is keyed on the +config alone: that remains correct for the *migration* question (which is about +a config write), and the CA is what decides the second question (which is not). + +### The downgrade is named on every attach shape {#never-silent} + +Whatever else happens, an attach that is about to write base-URL mode on an +install whose config asks for a proxy says so first, on stderr, before any +question. Non-TTY, `--json` and `hyp attach all` included: those shapes may +not *act*, but LLP 0244 #non-interactive already owes every non-migrating +attach a line naming what was skipped, and this state is worse than the one +that rule was written for. A dry run stays the single silent shape; it changes +nothing and promises nothing. + +The line names the repair. A user who reads only that line can fix the machine +by hand (`hyp daemon restart`, then re-attach). + +### The repair is a restart, and it writes nothing {#repair-is-a-restart} + +An interactive, single-client, wet-run attach offers to restart the daemon, +which makes the gateway re-mint the CA (`prepareInterception` mints whenever +`proxy_mode` is on), after which the attach below takes the proxy branch it +already has. The restart-and-wait steps are LLP 0244's own; only the config +write is skipped, because the key it would set is already there. + +Three properties follow from writing nothing: + +- **Fleet hosts are repaired too.** LLP 0244 #central-managed declines locally + because a local write would be dropped by the LLP 0031 merge. A restart has + nothing to collide with, so the central branch does not apply here. +- **It fixes the state however it was reached**, not only via `--purge`. +- **It is idempotent and cheap to decline**: nothing is backed up, nothing is + rewritten, and the base-URL attach proceeds as the working fallback. + +A repair is not finished until the attach it enables rewrites the client. On a +daemon-managed install (no configured `listen`) attach short-circuits at +"already attached" whenever the client's marker names the live port, and that +port survives a restart: the marker records the *mode* too, and after a +re-mint the recorded one is wrong. A landed repair therefore disqualifies that +no-op, so the run that printed the success line is the run that makes it true. +An offer that changed nothing (declined, or reported with no daemon to +restart) leaves the no-op exactly as it was. + +Consent is still asked, and automation is still never restarted mid-run: a +daemon restart is exactly the consequential side effect LLP 0244 +#non-interactive keeps out of scripts, and on macOS the attach that follows +raises a keychain dialog that wants a human anyway. Where no daemon service is +installed a restart cannot help, and the flow names the existing +install-and-start ladder instead of claiming a repair. + +Declining `--purge` as the fix site is deliberate. Re-minting during purge +contradicts what purge promises (a zero-residue exit), and clearing +`proxy_mode` during purge cannot work where it is most needed, because on a +fleet host that key is the central layer's (LLP 0031). + +### `hyp status` names the state {#status-names-it} + +`proxy_mode: true` with no certificate rendered as nothing at all: the trust +block is built from the CA, so with no CA the whole block disappears and the +install reports healthy. Status gains a `proxy_mode_ca_missing` warning +diagnostic naming the state and the repair. + +This is the missing half of a pair. The inverse (proxy mode off, CA present) +has always been warned about by the gateway itself as +`aigw.proxy_mode_stale_ca`. Unlike the trust block it is platform-independent: +the keychain and launchd halves exist only on macOS, but a config asking for a +transport the machine cannot serve is the same defect everywhere. + +Warning, not error, and not degrading: a freshly-configured install whose +daemon has not started yet is legitimately in this state for a few seconds, +and the repair is the same either way. + +A gateway entry switched off (`enabled: false`) is read as not asking at all, +the same reading every other plugin lookup in `hyp status` gives that key. A +disabled source never launches and so never mints, which would make the named +repair unable to clear its own warning however often it ran. + +## Consequences + +- `enableGatewayProxyMode` gains one outcome, `remint`: the config was already + right, no write happened, and only the restart and the two waits ran. Its + `already` outcome now means "config right *and* CA present". +- A machine can no longer report healthy while every attach it accepts lands + in a mode the user did not choose. +- The adapter seam is unchanged. Attach still reads the mode off the CA and + still cannot see config; the comparison between the two lives in the CLI + caller, which can see both. +- The darwin half (keychain trust, the real dialog) is unprovable on Linux, so + a release touching this path wants a `docs/ACCEPTANCE.md` pass on a real Mac. diff --git a/src/core/commands/clients.js b/src/core/commands/clients.js index 171bf88e..d355b698 100644 --- a/src/core/commands/clients.js +++ b/src/core/commands/clients.js @@ -15,7 +15,7 @@ import { clientAssetStateRoot } from '../runtime/client_asset_ledger.js' import { buildPluginCatalog } from '../plugin_catalog.js' import { detachClientFromDisk } from '../config/client_detach_disk.js' import { removeLaunchdEnv } from '../daemon/launchd_env.js' -import { defaultStateRoot, deleteLocalCa } from '../tls/ca.js' +import { defaultStateRoot, deleteLocalCa, readLocalCaInfo } from '../tls/ca.js' import { removeCaTrust } from '../tls/darwin_trust.js' import { clientAssetBaseDirs, removeClientAssets } from '../runtime/client_assets.js' import { @@ -303,9 +303,13 @@ async function runClientLifecycle(action, argv, ctx) { // the attach: base-URL attach is exactly what this install already // does, so it stays the fallback. // @ref LLP 0244#attach-offers [implements]: attach is the migration verb for a base-URL install whose client attaches by proxy + // What it returns matters below: a migration or repair that actually + // landed changed the mode the adapter is about to write, so a marker + // this client already holds is stale whatever port it names. + let proxyStateChanged = false if (action === 'attach') { try { - await maybeOfferProxyModeMigration({ name, ctx, parsed }) + proxyStateChanged = await maybeOfferProxyModeMigration({ name, ctx, parsed }) } catch (migrationErr) { ctx.stderr.write( `warning: proxy-mode migration failed (${migrationErr instanceof Error ? migrationErr.message : String(migrationErr)}); ` + @@ -355,9 +359,21 @@ async function runClientLifecycle(action, argv, ctx) { // not running) keep the pre-#277 behavior - a present marker is a // no-op success, an absent one the actionable error. // @ref LLP 0086#already-attached-validates-the-live-port [implements]: the already-attached branch compares recorded vs live port; a stale-port marker re-attaches + // A migration or repair that just landed is not finished until + // the client is rewritten: the marker's port can match the live + // port and still record the wrong *mode*, and the adapter's + // keychain-trust grant only runs inside `attach()`. Reporting + // "already attached" there would print a repair's success line + // over a client the repair never reached, which is the silent + // wrong outcome this whole path exists to remove. Only when a + // live endpoint was discovered, though: with none there is + // nothing to re-attach at, and a present marker still beats the + // give-up error below. + // @ref LLP 0259#repair-is-a-restart [implements]: the repair's success line is only true if the attach it enables actually runs const livePort = portFromEndpoint(liveEndpoint) const alreadyCurrent = probe.attached === true && + !(proxyStateChanged && liveEndpoint !== undefined) && (liveEndpoint === undefined || (probe.port !== undefined && probe.port === livePort)) if (alreadyCurrent) { @@ -803,10 +819,13 @@ async function maybeInteractiveEnableAttach({ name, ctx, parsed, enablement }) { * migration is a one-time human decision, not something automation acquires * (LLP 0233's "never by inference, by upgrade, or as a side effect"). * - * The offer is keyed on the *config*, not the CA: a stale CA with + * The *migration* offer is keyed on the config, not the CA: a stale CA with * `proxy_mode` off means an earlier proxy install was half-unwound, and the * config write is still the repair the gateway's own stale-CA warning asks - * for. + * for. The inverse disagreement - `proxy_mode` on with no CA on disk - is not + * a migration at all (intent is already recorded) but it is the state that + * makes attach write a base-URL marker against the user's stated intent, so + * this function owns it too and never lets it pass silently (LLP 0259). * * Never throws into the attach: the caller downgrades any escape to a * warning, because base-URL attach is what this install already does and @@ -815,8 +834,11 @@ async function maybeInteractiveEnableAttach({ name, ctx, parsed, enablement }) { * @ref LLP 0244#attach-offers [implements]: one consented question, default no, naming the config write, the restart, and the coming trust dialog * @ref LLP 0244#central-managed [implements]: a fleet-owned gateway block reports instead of prompting * @ref LLP 0244#non-interactive [implements]: non-TTY and --json attaches never migrate; they emit the one-line pointer + * @ref LLP 0259#gate-reads-both [implements]: the gate reads config AND the CA, so proxy_mode with no CA reaches the repair instead of returning * @param {{ name: string, ctx: CommandRunContext, parsed: { client: string, dryRun: boolean, json: boolean } }} args - * @returns {Promise} + * @returns {Promise} true when the proxy state actually moved under + * this call (a write or a re-mint landed), which makes any marker the client + * already holds stale: the caller must re-attach rather than no-op on it. */ async function maybeOfferProxyModeMigration({ name, ctx, parsed }) { // A dry run changes nothing and promises nothing, so it says nothing. @@ -825,11 +847,11 @@ async function maybeOfferProxyModeMigration({ name, ctx, parsed }) { // falls through to the one-line pointer below, because LLP 0244 // #non-interactive owes every non-migrating attach shape the line naming // what was skipped and the command that migrates. - if (parsed.dryRun) return + if (parsed.dryRun) return false const catalog = await buildAttachPluginCatalog(ctx) const descriptor = catalog.pickerDescriptors.get(name) - if (descriptor?.compose?.gateway_proxy_mode !== true) return + if (descriptor?.compose?.gateway_proxy_mode !== true) return false // The effective config this process booted with decides whether there is // anything to offer. An entry the LLP 0174 enable path appended seconds ago @@ -839,10 +861,22 @@ async function maybeOfferProxyModeMigration({ name, ctx, parsed }) { const effectiveGateway = (ctx.config?.plugins ?? []).find( (entry) => entry.name === '@hypaware/ai-gateway' ) - if (effectiveGateway?.config?.proxy_mode === true) return const log = getLogger('cmd-attach') + // `proxy_mode: true` used to mean "nothing to do here". It covers two + // states, and only one of them is settled: with a CA on disk the install is + // genuinely in proxy mode, and with none the config and the machine + // disagree - `hyp detach --purge` deletes the CA and leaves the key on, and + // the running daemon has no reason to re-mint. Attaching from there writes + // a base-URL marker for an install that asked for a proxy, which is the + // downgrade this branch exists to stop being silent. + // @ref LLP 0259#gate-reads-both [implements]: proxy_mode plus a CA returns; proxy_mode without one is the stranded state + if (effectiveGateway?.config?.proxy_mode === true) { + if (await hasLocalCa(ctx)) return false + return await repairStrandedProxyMode({ name, ctx, parsed, catalog, log }) + } + // A fleet-owned gateway block has no local remedy, so report instead of // asking a question whose yes cannot land (the same reason // `disabled_central` never reaches the enable prompt). Ownership is @@ -880,7 +914,7 @@ async function maybeOfferProxyModeMigration({ name, ctx, parsed }) { `note: this install attaches ${name} by base URL, and its gateway config is ` + `centrally managed; enable proxy_mode in the fleet config to switch it\n` ) - return + return false } if (parsed.client === 'all' || parsed.json || !isTty(ctx.stdin)) { @@ -888,7 +922,7 @@ async function maybeOfferProxyModeMigration({ name, ctx, parsed }) { `note: this install attaches ${name} by base URL; run 'hyp attach ${name}' in an ` + `interactive terminal to switch it to proxy mode\n` ) - return + return false } const localLoaded = await loadConfigFile(configPath) @@ -897,7 +931,7 @@ async function maybeOfferProxyModeMigration({ name, ctx, parsed }) { : undefined if (!localGateway) { // No gateway in any layer: the attach ladder below owns that error. - return + return false } const accepted = await askYesNo( @@ -918,7 +952,7 @@ async function maybeOfferProxyModeMigration({ name, ctx, parsed }) { status: 'ok', accepted: false, }) - return + return false } const result = await enableGatewayProxyMode({ @@ -944,15 +978,198 @@ async function maybeOfferProxyModeMigration({ name, ctx, parsed }) { `start one (hyp daemon install, hyp daemon start) and re-run 'hyp attach ${name}'\n` ) } - return + // Only the restarted case moved the mode: `ok` with no daemon means the + // key is on disk and no CA was minted, so the attach below still writes + // base URL and the client's marker is not stale after all. + return result.daemonInstalled === true + } + if (result.outcome === 'already') return false + if (result.ok && result.outcome === 'remint') { + // The layers moved between this offer and the write: the key was already + // there, so only the CA was missing and only the restart ran. With no + // service to restart nothing ran at all, so the ladder gets named rather + // than a repair claimed - the same reading the `enabled` branch above and + // the stranded-install repair below both give `daemonInstalled: false`. + if (result.daemonInstalled) { + ctx.stdout.write(`✓ proxy mode restored (daemon restarted, local CA re-minted)\n`) + } else { + ctx.stderr.write( + `warning: proxy_mode is already set, so nothing was written, and no daemon service is ` + + `installed, so nothing can re-mint the CA; start one (hyp daemon install, ` + + `hyp daemon start) and re-run 'hyp attach ${name}'\n` + ) + } + return result.daemonInstalled === true } - if (result.outcome === 'already') return ctx.stderr.write( `warning: could not switch to proxy mode` + `${result.message ? ` (${result.message})` : ''}` + `${result.backupPath ? `; the previous config was backed up at ${result.backupPath}` : ''}; ` + `attaching by base URL instead\n` ) + return false +} + +/** + * Does this machine hold a local interception CA? The one fact that decides + * which mode the client adapters actually write (LLP 0232 + * #proxy-attach-preflight), read here so the CLI can compare it against what + * the config asks for - the adapter itself never sees config and cannot make + * that comparison. + * + * A missing or malformed CA counts as absent, which is exactly what + * `readLocalCaInfo` reports to the adapters, so both surfaces agree about + * what the attach is about to do. The symmetry stops there: `readLocalCaInfo` + * *throws* on any other read failure (EACCES on the cert, say), and the + * adapters do not catch it. Catching it here and reading it as absent errs on + * the side this whole path exists for - a warning plus the repair offer, not + * silence - and the adapter's own throw still surfaces below. + * + * @param {CommandRunContext} ctx + * @returns {Promise} + */ +async function hasLocalCa(ctx) { + const homeDir = ctx.env.HOME ?? os.homedir() + try { + return (await readLocalCaInfo({ stateRoot: defaultStateRoot(ctx.env, homeDir) })) !== undefined + } catch { + return false + } +} + +/** + * The stranded proxy install: `proxy_mode: true` in the effective config and + * no CA on disk. `hyp detach claude --purge` produces it (it deletes the CA + * by design, LLP 0238 #ca-survives-detach, and never touches config), and the + * running daemon holds its loaded CA in memory so nothing re-mints. + * + * Two obligations, in this order. + * + * 1. Say so, on every attach shape. The downgrade to base URL is the defect; + * doing it quietly is what made it undiagnosable, so the warning is + * unconditional and precedes any question (LLP 0259 #never-silent). + * 2. Offer the repair, on the shapes allowed to take consequential action. + * The repair is a daemon restart, which re-mints the CA and needs no + * config write - so it is equally available on a fleet-managed host, where + * LLP 0244's migration deliberately declines. Interactive, single-client, + * wet-run only: a restart mid-script is exactly the side effect LLP 0244 + * #non-interactive keeps out of automation, and on macOS the attach that + * follows raises a trust dialog that wants a human anyway. + * + * Never throws into the attach, for the same reason the migration does not: + * a base-URL attach is worse than a proxy one but better than no attach. + * + * @ref LLP 0259#never-silent [implements]: the downgrade is named on every attach shape before anything else happens + * @ref LLP 0259#repair-is-a-restart [implements]: the consented repair is a restart, config untouched, fleet hosts included + * @param {{ + * name: string, + * ctx: CommandRunContext, + * parsed: { client: string, dryRun: boolean, json: boolean }, + * catalog: Awaited>, + * log: ReturnType, + * }} args + * @returns {Promise} true when the repair actually landed and the CA + * now exists, so the caller must let the attach rewrite the client instead + * of no-opping on a marker that records the pre-repair mode. + */ +async function repairStrandedProxyMode({ name, ctx, parsed, catalog, log }) { + ctx.stderr.write( + `warning: this install is configured for proxy mode but has no local interception CA, ` + + `so attaching ${name} now writes a base-URL attach instead (which breaks Remote Control ` + + `inbound); the gateway re-mints the CA when the daemon restarts\n` + ) + log.warn('client.attach.proxy_mode_ca_missing', { + [Attr.COMPONENT]: 'cmd-attach', + [Attr.OPERATION]: 'client.attach', + [Attr.ERROR_KIND]: 'proxy_mode_ca_missing', + hyp_client: name, + }) + + if (parsed.client === 'all' || parsed.json || !isTty(ctx.stdin)) { + ctx.stderr.write( + `note: run 'hyp daemon restart', then 'hyp attach ${name}' in an interactive terminal, ` + + `to restore the proxy attach\n` + ) + return false + } + + const accepted = await askYesNo( + ctx, + `${capitalizeClientLabel(name)} can be put back on HypAware's local HTTPS proxy by ` + + `restarting the daemon, which re-mints the CA; nothing in your config changes, and macOS ` + + `will then ask to trust the HypAware Local CA. Restart the daemon and restore proxy mode ` + + `now? [y/N] ` + ) + if (!accepted) { + ctx.stderr.write( + `keeping the base-URL attach; run 'hyp daemon restart' then 'hyp attach ${name}' to ` + + `restore proxy mode later\n` + ) + log.info('client.attach.proxy_remint', { + [Attr.COMPONENT]: 'cmd-attach', + [Attr.OPERATION]: 'client.attach', + hyp_client: name, + status: 'ok', + accepted: false, + }) + return false + } + + const result = await enableGatewayProxyMode({ + ctx, + knownPlugins: catalog.pluginMetadata, + knownDatasets: catalog.knownDatasets, + }) + log.info('client.attach.proxy_remint', { + [Attr.COMPONENT]: 'cmd-attach', + [Attr.OPERATION]: 'client.attach', + hyp_client: name, + status: result.ok ? 'ok' : 'failed', + accepted: true, + outcome: result.outcome, + ...(result.failedStep ? { failed_step: result.failedStep } : {}), + }) + if (result.ok && result.outcome === 'already') { + // The CA appeared between the gate above and this call (a daemon reload + // landed mid-run). Nothing to restart; the attach below sees it. + ctx.stdout.write(`✓ the local CA is present again; attaching in proxy mode\n`) + return true + } + if (result.ok && result.outcome === 'remint') { + if (result.daemonInstalled) { + ctx.stdout.write(`✓ proxy mode restored (daemon restarted, local CA re-minted)\n`) + } else { + // A restart cannot help where there is no service to restart. Same + // ladder the migration names in the same situation. + ctx.stderr.write( + `warning: no daemon service is installed, so nothing can re-mint the CA; start one ` + + `(hyp daemon install, hyp daemon start) and re-run 'hyp attach ${name}'\n` + ) + } + return result.daemonInstalled === true + } + if (result.ok && result.outcome === 'enabled') { + // The two layered reads can disagree: this function's gate read + // `ctx.config`, while `enableGatewayProxyMode` treats a layer it cannot + // resolve as proving nothing and falls back to the local file, so a + // repair can come back as a write. It still succeeded, and reporting it + // as a failure would send the user chasing a machine that is now right. + if (result.daemonInstalled) { + ctx.stdout.write(`✓ proxy mode restored (config updated, daemon restarted)\n`) + } else { + ctx.stdout.write( + `✓ proxy_mode written to ${result.configPath}; no daemon service is installed, so ` + + `start one (hyp daemon install, hyp daemon start) and re-run 'hyp attach ${name}'\n` + ) + } + return result.daemonInstalled === true + } + ctx.stderr.write( + `warning: could not restore proxy mode` + + `${result.message ? ` (${result.message})` : ''}; ` + + `attaching by base URL instead\n` + ) + return false } /** diff --git a/src/core/config/gateway_proxy_enable.js b/src/core/config/gateway_proxy_enable.js index b3d6f206..a8d753d1 100644 --- a/src/core/config/gateway_proxy_enable.js +++ b/src/core/config/gateway_proxy_enable.js @@ -8,7 +8,7 @@ import { Attr, getLogger, withSpan } from '../observability/index.js' import { readObservabilityEnv } from '../observability/env.js' import { resolveConfigPath, resolveLayeredConfigFromDisk } from '../runtime/boot.js' import { loadConfigFile, prepareLocalConfigWrite } from './schema.js' -import { defaultStateRoot, waitForLocalCa } from '../tls/ca.js' +import { defaultStateRoot, readLocalCaInfo, waitForLocalCa } from '../tls/ca.js' /** * @import { CommandRunContext, HypAwareV2Config, PluginConfigInstance, PluginName } from '../../../hypaware-plugin-kernel-types.js' @@ -41,6 +41,12 @@ const CA_WAIT_DEFAULT_MS = 15_000 * in any layer has a bigger problem than proxy mode. * @ref LLP 0244#enable-write [implements]: the consented switch reuses the enable steps with one new write shape, then waits for the CA * + * One entry state skips the write and runs the rest: `proxy_mode` already on + * with no CA on disk. That is not "nothing to do" but a stranded install + * (`hyp detach --purge` removes the CA and leaves the key set), and the + * restart-and-wait half of this function is exactly its repair. + * @ref LLP 0259#repair-is-a-restart [implements]: `remint` reuses steps 2 and 3 with no config write + * * @param {{ * ctx: CommandRunContext, * knownPlugins?: Map, @@ -134,12 +140,32 @@ export async function enableGatewayProxyMode({ // still decides what this function may write. } - if (effectiveGateway?.config?.proxy_mode === true) { - result.ok = true - result.outcome = 'already' - span.setAttribute('outcome', 'already') - span.setAttribute('status', 'ok') - return result + // `proxy_mode` already on splits in two. With a CA on disk the install + // is genuinely in proxy mode and there is nothing to do. With none, the + // config and the machine disagree: `hyp detach --purge` deleted the CA + // and the running daemon has no reason to re-mint, so an attach from + // here silently writes base-URL mode. That second state is a repair, + // not a migration - the intent is already recorded, so the write is + // skipped and only the restart runs. + // @ref LLP 0259#repair-is-a-restart [implements]: the CA-missing half of `already` restarts instead of returning, and writes nothing + const remint = effectiveGateway?.config?.proxy_mode === true + if (remint) { + /** @type {boolean} */ + let caPresent + try { + caPresent = (await readLocalCaInfo({ stateRoot: defaultStateRoot(ctx.env, homeDir) })) !== undefined + } catch { + caPresent = false + } + if (caPresent) { + result.ok = true + result.outcome = 'already' + span.setAttribute('outcome', 'already') + span.setAttribute('status', 'ok') + return result + } + result.outcome = 'remint' + span.setAttribute('outcome', 'remint') } // Central ownership is decided by the central layer NAMING the plugin, @@ -149,8 +175,11 @@ export async function enableGatewayProxyMode({ // produces a daemon that restarts without proxy mode and a CA wait // that can only time out (found live, 2026-08-17). // @ref LLP 0244#central-managed [implements]: the local CLI never fights the central layer over the gateway block + // A re-mint writes nothing, so it has nothing for the central layer to + // collide with: the restart is purely local and repairs a fleet host + // exactly as it repairs a solo one. const localGateway = (base.plugins ?? []).find((entry) => entry.name === GATEWAY_PLUGIN) - if (centralGateway || !localGateway) { + if (!remint && (centralGateway || !localGateway)) { const outcome = centralGateway ? 'central_managed' : 'no_gateway' result.outcome = outcome result.message = centralGateway @@ -163,37 +192,42 @@ export async function enableGatewayProxyMode({ // ---- Step 1: the guarded local write ---------------------------------- - const nextPlugins = (base.plugins ?? []).map((entry) => - entry === localGateway - ? { ...entry, config: { ...(entry.config ?? {}), proxy_mode: true } } - : entry - ) - const nextConfig = { ...base, plugins: nextPlugins } + // Skipped entirely on a re-mint: the key the write would set is already + // there, and rewriting it would churn a config (and a backup copy) for + // no change, on a host whose gateway block may not even be ours. + if (!remint) { + const nextPlugins = (base.plugins ?? []).map((entry) => + entry === localGateway + ? { ...entry, config: { ...(entry.config ?? {}), proxy_mode: true } } + : entry + ) + const nextConfig = { ...base, plugins: nextPlugins } - try { - const guard = await prepareLocalConfigWrite({ - targetPath: configPath, - force: true, - ...(now ? { now } : {}), - }) - if (!guard.proceed) { - return fail(result, span, log, 'write', 'config_write_failed', guard.message ?? 'config write refused') + try { + const guard = await prepareLocalConfigWrite({ + targetPath: configPath, + force: true, + ...(now ? { now } : {}), + }) + if (!guard.proceed) { + return fail(result, span, log, 'write', 'config_write_failed', guard.message ?? 'config write refused') + } + if (guard.backupPath) result.backupPath = guard.backupPath + await fs.mkdir(path.dirname(configPath), { recursive: true }) + await fs.writeFile(configPath, JSON.stringify(nextConfig, null, 2) + '\n', 'utf8') + } catch (err) { + return fail(result, span, log, 'write', 'config_write_failed', describeError(err)) } - if (guard.backupPath) result.backupPath = guard.backupPath - await fs.mkdir(path.dirname(configPath), { recursive: true }) - await fs.writeFile(configPath, JSON.stringify(nextConfig, null, 2) + '\n', 'utf8') - } catch (err) { - return fail(result, span, log, 'write', 'config_write_failed', describeError(err)) - } - result.steps.write = 'ok' - result.outcome = 'enabled' - log.info('config.gateway_proxy_enable.write', { - [Attr.COMPONENT]: 'config', - config_path: configPath, - ...(result.backupPath ? { backup_path: result.backupPath } : {}), - status: 'ok', - }) + result.steps.write = 'ok' + result.outcome = 'enabled' + log.info('config.gateway_proxy_enable.write', { + [Attr.COMPONENT]: 'config', + config_path: configPath, + ...(result.backupPath ? { backup_path: result.backupPath } : {}), + status: 'ok', + }) + } // ---- Step 2: restart the daemon and wait for the gateway to bind ------ @@ -210,10 +244,13 @@ export async function enableGatewayProxyMode({ if (!svc.installed) { // The write is the whole job here; the daemon ladder in attach names // what to run next, same as adapter enablement (LLP 0174 - // #bootstrap-floor). + // #bootstrap-floor). On a re-mint there was no write and a restart is + // the whole job, so nothing at all happened: `ok` with + // `daemonInstalled: false` is what tells the caller to name the + // install-and-start ladder instead of claiming a repair. result.ok = true span.setAttribute('daemon_installed', false) - span.setAttribute('outcome', 'enabled') + span.setAttribute('outcome', result.outcome) span.setAttribute('status', 'ok') return result } @@ -250,7 +287,7 @@ export async function enableGatewayProxyMode({ // proxy-mode daemon and a base-URL attach. const waitCa = waitForCaFn ?? waitForLocalCa const caWait = await waitCa({ - stateRoot: defaultStateRoot(ctx.env), + stateRoot: defaultStateRoot(ctx.env, homeDir), timeoutMs: caTimeoutMs ?? CA_WAIT_DEFAULT_MS, ...(sleep ? { sleep } : {}), ...(now ? { now } : {}), @@ -265,7 +302,7 @@ export async function enableGatewayProxyMode({ } result.steps.ca = 'ok' result.ok = true - span.setAttribute('outcome', 'enabled') + span.setAttribute('outcome', result.outcome) span.setAttribute('status', 'ok') log.info('config.gateway_proxy_enable.ready', { [Attr.COMPONENT]: 'config', diff --git a/src/core/config/types.d.ts b/src/core/config/types.d.ts index b37fa14d..e5191409 100644 --- a/src/core/config/types.d.ts +++ b/src/core/config/types.d.ts @@ -837,14 +837,17 @@ export interface GatewayProxyEnableResult { ok: boolean /** * `enabled`: the key was written and every step that applied succeeded. - * `already`: the effective config has the key, nothing to do. + * `already`: the effective config has the key and the CA is on disk, + * nothing to do. `remint`: the effective config has the key but no CA + * exists, so no write was needed and only the restart-and-wait steps ran + * to make the gateway mint one (LLP 0259). * `central_managed`: the gateway block comes from the central layer, so * the local CLI declines to write. `no_gateway`: no layer has a gateway * entry. `failed`: a step broke (see `failedStep`/`message`); when * `steps.write` is `ok` the key nonetheless persisted on disk, with * `backupPath` naming the pre-write copy. */ - outcome: 'enabled' | 'already' | 'central_managed' | 'no_gateway' | 'failed' + outcome: 'enabled' | 'already' | 'remint' | 'central_managed' | 'no_gateway' | 'failed' /** The local config layer the write targeted. */ configPath: string /** Set when the guard copied the existing config aside before replacing it. */ diff --git a/src/core/daemon/status.js b/src/core/daemon/status.js index c4834f83..4b02e7e2 100644 --- a/src/core/daemon/status.js +++ b/src/core/daemon/status.js @@ -1467,6 +1467,41 @@ export async function collectHypAwareStatus(opts = {}) { isLaunchdEnvSetFn: opts.isLaunchdEnvSet ?? probeLaunchdEnvSet, }) + // ----- proxy mode configured with no CA on disk (LLP 0259) ----- + // The missing half of a pair. Proxy mode off with a CA present is already + // warned about by the gateway itself (`aigw.proxy_mode_stale_ca`); the + // inverse rendered as nothing at all, because `collectProxyTrust` returns + // null with no CA to describe and the whole `proxy trust` block vanishes. + // A machine in this state reports healthy while every attach it accepts + // quietly lands in base-URL mode, so status has to name it. Warning, not + // error: a freshly-configured install whose daemon has not started yet is + // legitimately here for a few seconds, and the repair is the same either + // way. + // @ref LLP 0259#status-names-it [implements]: proxy_mode with no CA is a named warning on both the text and --json surfaces + // `enabled: false` is read the same way every other plugin lookup in this + // file reads it (see `activePlugins` above): a switched-off gateway never + // launches its source, so it never mints, and the repair below could not + // clear the warning however often it ran. A stale `proxy_mode` inside a + // disabled block is not a fault, because nothing is asking for a transport. + const gatewayEntry = (config?.plugins ?? []).find((entry) => entry.name === '@hypaware/ai-gateway') + if ( + gatewayEntry?.enabled !== false && + gatewayEntry?.config?.proxy_mode === true && + !(await hasLocalCaOnDisk(stateRoot)) + ) { + diagnostics.push({ + severity: 'warning', + kind: 'proxy_mode_ca_missing', + message: + 'proxy_mode is on but no local interception CA exists - clients attached from here ' + + 'land in base-URL mode, which breaks Remote Control inbound', + repair: [ + 'hyp daemon restart (the gateway re-mints the CA on boot)', + 'then re-run hyp attach for each proxy-mode client', + ], + }) + } + // ----- recent errors ----- const recentErrorCount = await countRecentErrors(devTelemetryDir(stateRoot)) if (recentErrorCount > 0) { @@ -1583,6 +1618,30 @@ async function collectProxyTrust({ platform, stateRoot, isCaTrustedFn, isLaunchd return { caFingerprint: ca.fingerprint, trusted, launchdEnvSet } } +/** + * Is a local interception CA installed? Read on its own rather than off + * `collectProxyTrust`, which is darwin-only by design (it describes + * mechanisms that exist only there) while the config-versus-disk + * disagreement above it is platform-independent. + * + * A missing or malformed CA counts as absent: that is exactly the reading the + * attach preflight gives it (LLP 0232 #proxy-attach-preflight), so status + * describes the mode the next attach would actually pick. A CA that exists + * but cannot be read at all (EACCES) makes `readLocalCaInfo` throw rather + * than return, and is read as absent too: status must never fail on a probe, + * and a certificate this process cannot read is one it cannot vouch for. + * + * @param {string} stateRoot + * @returns {Promise} + */ +async function hasLocalCaOnDisk(stateRoot) { + try { + return (await readLocalCaInfo({ stateRoot })) !== undefined + } catch { + return false + } +} + /** * Build the client-action reconciler section for `hyp status` from the * persisted marker store and the effective config. Pure: it reads markers diff --git a/src/core/daemon/types.d.ts b/src/core/daemon/types.d.ts index c5ea98f7..3e22b57b 100644 --- a/src/core/daemon/types.d.ts +++ b/src/core/daemon/types.d.ts @@ -107,6 +107,7 @@ export type StatusDiagnosticKind = | 'remote_config_rolled_back' | 'local_only_list_unreadable' | 'client_sync_list_unreadable' + | 'proxy_mode_ca_missing' | 'capture_gap' /** diff --git a/test/core/attach-proxy-migration.test.js b/test/core/attach-proxy-migration.test.js index cdd5fd57..31db2480 100644 --- a/test/core/attach-proxy-migration.test.js +++ b/test/core/attach-proxy-migration.test.js @@ -4,11 +4,13 @@ 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 { centralSeedPath } from '../../src/core/config/apply.js' +import { deleteLocalCa, ensureLocalCa, readLocalCaInfo } from '../../src/core/tls/ca.js' /** * LLP 0244 #attach-offers: `hyp attach claude` on a base-URL install offers @@ -19,10 +21,15 @@ import { centralSeedPath } from '../../src/core/config/apply.js' * managers anyway), so an accepted switch lands the write and reports that a * daemon start is the next step. * + * LLP 0259 adds the other side of the same gate: `proxy_mode: true` with no + * CA on disk is the state `hyp detach claude --purge` leaves behind, and the + * attach that follows it must not downgrade to base URL in silence. + * * @import { CommandRunContext } from '../../hypaware-plugin-kernel-types.js' */ const MIGRATION_QUESTION = 'Switch this install to proxy mode now? [y/N] ' +const REMINT_QUESTION = 'Restart the daemon and restore proxy mode now? [y/N] ' /** * @param {{ onWrite?: (chunk: unknown) => void }} [opts] @@ -47,6 +54,33 @@ function localConfigPath(home) { return path.join(home, '.hyp', 'hypaware-config.json') } +/** @param {string} home */ +function stateRootOf(home) { + return path.join(home, '.hyp', 'hypaware') +} + +/** + * What `hyp detach claude --purge` leaves behind: the CA is deleted (LLP 0238 + * #ca-survives-detach makes that purge's job alone) and `proxy_mode` stays + * exactly where it was, because nothing on the detach path writes config. + * + * @param {string} home + */ +async function purgeTheCa(home) { + const stateRoot = stateRootOf(home) + await ensureLocalCa({ stateRoot, hosts: ['api.anthropic.com'] }) + const tls = path.join(stateRoot, 'tls') + /** @type {Array<[string, Buffer]>} */ + const saved = ['ca-cert.pem', 'ca-key.pem'].map((f) => [f, readFileSync(path.join(tls, f))]) + await deleteLocalCa({ stateRoot }) + // Handed back so a test can put the same bytes down again synchronously, + // which is how it stages the mint a daemon restart would do mid-run. + return () => { + mkdirSync(tls, { recursive: true }) + for (const [f, bytes] of saved) writeFileSync(path.join(tls, f), bytes) + } +} + /** * @param {string} home * @param {{ proxyMode?: boolean }} [opts] @@ -73,12 +107,26 @@ function writeGatewayConfig(home, opts) { * enable prompt never fires and the migration offer is the only question in * play. `answer` (when stdin is a TTY) is pre-buffered for it. * - * @param {{ home: string, answer?: string, tty?: boolean, json?: boolean }} opts + * `unbound` swaps in the daemon-managed shape instead: the gateway is not + * running in this CLI process, so `localEndpoint()` throws and the command + * falls through to the status.json live-port discovery (LLP 0086). That is + * the branch that owns the "already attached" no-op, so it is the only one + * that can short-circuit past a repair. `onStderr` observes stderr as it is + * written, which is how a test stages a mid-run change. + * + * @param {{ + * home: string, + * answer?: string, + * tty?: boolean, + * unbound?: boolean, + * onStderr?: (chunk: unknown) => void, + * }} opts */ -function makeCtx({ home, answer, tty = true }) { +function makeCtx({ home, answer, tty = true, unbound = false, onStderr }) { const registered = ['claude', 'codex'] const gateway = { localEndpoint() { + if (unbound) throw new Error('ai-gateway: localEndpoint() called before the gateway started') return 'http://127.0.0.1:60680' }, /** @param {string} name */ @@ -88,9 +136,18 @@ function makeCtx({ home, answer, tty = true }) { name, /** @param {{ endpoint: string, dryRun?: boolean }} args */ async attach(args) { + // The mode a real adapter picks, decided the way LLP 0232 + // #proxy-attach-preflight decides it: from the CA on disk, never + // from config. Recording it here is what makes a silent downgrade + // visible to the test. + const ca = await readLocalCaInfo({ stateRoot: stateRootOf(home) }) writeFileSync( path.join(home, `${name}-attached.json`), - JSON.stringify({ endpoint: args.endpoint, dryRun: args.dryRun === true }) + JSON.stringify({ + endpoint: args.endpoint, + dryRun: args.dryRun === true, + mode: ca ? 'proxy' : 'base_url', + }) ) }, } @@ -103,7 +160,7 @@ function makeCtx({ home, answer, tty = true }) { if (tty) Object.defineProperty(stdin, 'isTTY', { value: true }) if (answer !== undefined) stdin.write(`${answer}\n`) const stdoutBuf = makeBuf() - const stderrBuf = makeBuf() + const stderrBuf = makeBuf(onStderr ? { onWrite: onStderr } : undefined) // The effective config the process booted with mirrors the local file, the // shape runAttach reads it in. const config = JSON.parse(readFileSync(localConfigPath(home), 'utf8')) @@ -169,14 +226,175 @@ test('accept: proxy_mode lands in the local config; with no daemon service the n }) }) -test('proxy_mode already in the config: no question, no note', async () => { +// The migration offer decides from `ctx.config`, the effective config this +// process booted with, while `enableGatewayProxyMode` re-reads disk before it +// writes. So an accepted migration can come back `remint` when the key landed +// in between, and that outcome has to be reported the way every other +// no-daemon outcome is: nothing was written and nothing was restarted, so the +// install ladder is named instead of a repair claimed. +test('an accepted migration that turns out to be a re-mint never claims a restart that could not run', async () => { + await withTempHome(async (home) => { + writeGatewayConfig(home) + const { ctx, stdout, stderr } = makeCtx({ home, answer: 'y' }) + // The layers move between the offer and the write. + writeGatewayConfig(home, { proxyMode: true }) + const before = readFileSync(localConfigPath(home), 'utf8') + const code = await runAttach(['claude'], ctx) + assert.equal(code, 0, stderr.text()) + assert.doesNotMatch(stdout.text(), /proxy mode restored/, 'no restart ran, so none is claimed') + assert.match(stderr.text(), /no daemon service is installed, so nothing can re-mint the CA/) + assert.match(stderr.text(), /hyp daemon install/) + assert.equal(readFileSync(localConfigPath(home), 'utf8'), before, 'a re-mint writes no config') + }) +}) + +test('proxy_mode already in the config, with the CA on disk: no question, no note', async () => { await withTempHome(async (home) => { writeGatewayConfig(home, { proxyMode: true }) + await ensureLocalCa({ stateRoot: stateRootOf(home), hosts: ['api.anthropic.com'] }) const { ctx, stderr } = makeCtx({ home }) const code = await runAttach(['claude'], ctx) assert.equal(code, 0, stderr.text()) assert.ok(!stderr.text().includes(MIGRATION_QUESTION)) assert.doesNotMatch(stderr.text(), /proxy mode/) + const attached = JSON.parse(readFileSync(path.join(home, 'claude-attached.json'), 'utf8')) + assert.equal(attached.mode, 'proxy') + }) +}) + +/* ------------------- the purge-then-attach downgrade (#819) ---------------- */ + +// The live macOS repro: `hyp detach claude --purge` deletes the CA and leaves +// `proxy_mode: true` set, and the attach that follows wrote a base-URL marker +// while reporting success - no warning, and `hyp status` showed nothing +// either. The attach may still land in base-URL mode (that is what the +// machine can serve), but it may never do so quietly. +// @ref LLP 0259#never-silent [tests]: the downgrade is named before anything else happens +test('detach --purge then attach: the downgrade is named, not silent', async () => { + await withTempHome(async (home) => { + writeGatewayConfig(home, { proxyMode: true }) + await purgeTheCa(home) + const { ctx, stderr } = makeCtx({ home, answer: 'n' }) + const code = await runAttach(['claude'], ctx) + assert.equal(code, 0, stderr.text()) + assert.match(stderr.text(), /configured for proxy mode but has no local interception CA/) + assert.match(stderr.text(), /writes a base-URL attach instead/) + // And the base-URL attach it warned about is exactly what happened. + const attached = JSON.parse(readFileSync(path.join(home, 'claude-attached.json'), 'utf8')) + assert.equal(attached.mode, 'base_url') + }) +}) + +// @ref LLP 0259#repair-is-a-restart [tests]: the offer is the restart, and declining names the manual form +test('the repair is offered, and declining names the two commands that do it by hand', async () => { + await withTempHome(async (home) => { + writeGatewayConfig(home, { proxyMode: true }) + await purgeTheCa(home) + const before = readFileSync(localConfigPath(home), 'utf8') + const { ctx, stderr } = makeCtx({ home, answer: 'n' }) + const code = await runAttach(['claude'], ctx) + assert.equal(code, 0, stderr.text()) + assert.ok(stderr.text().includes(REMINT_QUESTION), stderr.text()) + assert.ok(!stderr.text().includes(MIGRATION_QUESTION), 'this is a repair, not a migration') + assert.match(stderr.text(), /hyp daemon restart/) + assert.equal(readFileSync(localConfigPath(home), 'utf8'), before, 'nothing is written either way') + }) +}) + +// The write is skipped on this path, so an accepted repair changes no config +// at all. Under the test runner no service manager is reachable (LLP 0181), +// which degrades to "no daemon installed" - the one state where a restart +// cannot help, and the ladder has to be named instead of a repair claimed. +test('accepting the repair writes no config, and with no daemon service names the start ladder', async () => { + await withTempHome(async (home) => { + writeGatewayConfig(home, { proxyMode: true }) + await purgeTheCa(home) + const before = readFileSync(localConfigPath(home), 'utf8') + const { ctx, stdout, stderr } = makeCtx({ home, answer: 'y' }) + const code = await runAttach(['claude'], ctx) + assert.equal(code, 0, stderr.text()) + assert.match(stderr.text(), /no daemon service is installed, so nothing can re-mint the CA/) + assert.match(stderr.text(), /hyp daemon install/) + assert.doesNotMatch(stdout.text(), /proxy_mode written/, 'a repair never rewrites the config') + assert.equal(readFileSync(localConfigPath(home), 'utf8'), before) + }) +}) + +// The mirror of the re-mint-in-the-migration case: this function's gate reads +// `ctx.config`, `enableGatewayProxyMode` re-reads disk, and the two can +// disagree, so an accepted repair can come back as a write. A write that +// worked is not a failure and must not be reported as one. +test('a repair that comes back as a config write reports success, not a failure', async () => { + await withTempHome(async (home) => { + writeGatewayConfig(home, { proxyMode: true }) + await purgeTheCa(home) + const { ctx, stdout, stderr } = makeCtx({ home, answer: 'y' }) + // The layers move between the gate and the write. + writeGatewayConfig(home) + const code = await runAttach(['claude'], ctx) + assert.equal(code, 0, stderr.text()) + assert.doesNotMatch(stderr.text(), /could not restore proxy mode/) + assert.match(stdout.text(), /proxy_mode written/) + assert.match(stdout.text(), /hyp daemon install/) + const after = JSON.parse(readFileSync(localConfigPath(home), 'utf8')) + assert.equal(after.plugins[0].config.proxy_mode, true) + }) +}) + +// @ref LLP 0259#never-silent [tests]: every attach shape says it, including the ones that may not act +test('non-TTY: the downgrade is still named, with the manual repair as the pointer', async () => { + await withTempHome(async (home) => { + writeGatewayConfig(home, { proxyMode: true }) + await purgeTheCa(home) + const { ctx, stderr } = makeCtx({ home, tty: false }) + const code = await runAttach(['claude'], ctx) + assert.equal(code, 0, stderr.text()) + assert.match(stderr.text(), /no local interception CA/) + assert.match(stderr.text(), /run 'hyp daemon restart', then 'hyp attach claude'/) + assert.ok(!stderr.text().includes(REMINT_QUESTION), 'automation is never asked and never restarted') + }) +}) + +// The one shape that keeps stdout machine-readable: the warning is on +// stderr, so naming the downgrade costs the JSON payload nothing. +// @ref LLP 0259#never-silent [tests]: --json is warned like every other non-acting shape +test('--json: the downgrade is named on stderr and stdout stays the machine payload', async () => { + await withTempHome(async (home) => { + writeGatewayConfig(home, { proxyMode: true }) + await purgeTheCa(home) + const { ctx, stdout, stderr } = makeCtx({ home, answer: 'y' }) + const code = await runAttach(['claude', '--json'], ctx) + assert.equal(code, 0, stderr.text()) + assert.match(stderr.text(), /no local interception CA/) + assert.match(stderr.text(), /run 'hyp daemon restart', then 'hyp attach claude'/) + assert.ok(!stderr.text().includes(REMINT_QUESTION), '--json is never asked and never restarted') + for (const line of stdout.text().split('\n')) { + if (line.trim() === '') continue + JSON.parse(line) + } + }) +}) + +test("'hyp attach all' names the downgrade once for claude and never asks", async () => { + await withTempHome(async (home) => { + writeGatewayConfig(home, { proxyMode: true }) + await purgeTheCa(home) + const { ctx, stderr } = makeCtx({ home, answer: 'y' }) + const code = await runAttach(['all'], ctx) + assert.equal(code, 0, stderr.text()) + assert.match(stderr.text(), /attaching claude now writes a base-URL attach instead/) + assert.ok(!stderr.text().includes(REMINT_QUESTION)) + }) +}) + +test('--dry-run stays silent: it changes nothing and promises nothing', async () => { + await withTempHome(async (home) => { + writeGatewayConfig(home, { proxyMode: true }) + await purgeTheCa(home) + const { ctx, stderr } = makeCtx({ home, answer: 'y' }) + const code = await runAttach(['claude', '--dry-run'], ctx) + assert.equal(code, 0, stderr.text()) + assert.equal(stderr.text(), '') }) }) @@ -284,3 +502,117 @@ test('a failed accepted migration warns and the attach still succeeds', async () assert.equal(readFileSync(localConfigPath(home), 'utf8'), before, 'the config on disk is untouched') }) }) + +/** + * The daemon-managed install: no `listen` in config, so only the running + * daemon knows the bound port and it publishes it to status.json (LLP 0086). + * Seeded with this process's own pid so the liveness gate passes. + * + * @param {string} home + * @param {number} port + */ +function seedDaemonRun(home, port) { + const runDir = path.join(home, '.hyp', 'hypaware', '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: [], + }) + ) +} + +/** + * A claude settings marker recorded at `port`, which is what makes the + * LLP 0086 "already attached at the live port" no-op fire. + * + * @param {string} home + * @param {number} port + */ +function seedClaudeMarker(home, port) { + mkdirSync(path.join(home, '.claude'), { recursive: true }) + writeFileSync( + path.join(home, '.claude', 'settings.json'), + JSON.stringify({ _hypaware: { version: '2.0.0', port } }) + ) +} + +// The repair has to reach the client, not just the machine. On a +// daemon-managed install the attach short-circuits at "already attached" +// whenever the marker names the live port - and a restart keeps that port, so +// the marker still matches while the *mode* it records has just gone stale. +// Short-circuiting there prints the repair's success line over a client the +// repair never touched, which is the silent wrong outcome LLP 0259 exists to +// remove. The CA is staged into place as the downgrade warning is written, +// the same race the `already` branch is written for. +test('a repair that lands re-attaches the client instead of no-opping on its pre-repair marker', async () => { + await withTempHome(async (home) => { + writeGatewayConfig(home, { proxyMode: true }) + const remintTheCa = await purgeTheCa(home) + seedDaemonRun(home, 55555) + seedClaudeMarker(home, 55555) + let staged = false + const { ctx, stdout, stderr } = makeCtx({ + home, + answer: 'y', + unbound: true, + onStderr: (chunk) => { + if (staged || !String(chunk).includes('no local interception CA')) return + staged = true + // The daemon's restart lands mid-run and the gateway mints again. + remintTheCa() + }, + }) + const code = await runAttach(['claude'], ctx) + assert.equal(code, 0, stderr.text()) + assert.ok(staged, 'the downgrade warning must be written before anything else') + assert.ok(stderr.text().includes(REMINT_QUESTION)) + assert.match(stdout.text(), /the local CA is present again/) + assert.doesNotMatch( + stdout.text(), + /already attached/, + 'a repair that landed must not be reported over a no-op' + ) + const attached = JSON.parse(readFileSync(path.join(home, 'claude-attached.json'), 'utf8')) + assert.equal(attached.mode, 'proxy', 'the client is rewritten in the mode the repair restored') + assert.equal(attached.endpoint, 'http://127.0.0.1:55555') + }) +}) + +// The other half of the same gate: nothing moved, so the LLP 0086 no-op is +// still the right answer and must survive untouched. +test('a declined repair leaves the already-attached no-op exactly as it was', async () => { + await withTempHome(async (home) => { + writeGatewayConfig(home, { proxyMode: true }) + await purgeTheCa(home) + seedDaemonRun(home, 55555) + seedClaudeMarker(home, 55555) + const { ctx, stdout, stderr } = makeCtx({ home, answer: 'n', unbound: true }) + const code = await runAttach(['claude'], ctx) + assert.equal(code, 0, stderr.text()) + assert.match(stderr.text(), /no local interception CA/) + assert.match(stdout.text(), /already attached/) + assert.throws( + () => readFileSync(path.join(home, 'claude-attached.json'), 'utf8'), + 'nothing changed, so the adapter is still left alone' + ) + }) +}) diff --git a/test/core/gateway-proxy-enable.test.js b/test/core/gateway-proxy-enable.test.js index 0eec2fcf..34ca23ad 100644 --- a/test/core/gateway-proxy-enable.test.js +++ b/test/core/gateway-proxy-enable.test.js @@ -9,6 +9,7 @@ import test from 'node:test' import { centralSeedPath } from '../../src/core/config/apply.js' import { enableGatewayProxyMode } from '../../src/core/config/gateway_proxy_enable.js' import { defaultConfigPath } from '../../src/core/config/schema.js' +import { ensureLocalCa } from '../../src/core/tls/ca.js' /** * The LLP 0244 #enable-write shape: `enableGatewayProxyMode` sets one key on @@ -16,6 +17,10 @@ import { defaultConfigPath } from '../../src/core/config/schema.js' * declines when the gateway block is centrally managed or absent, and after * the restart waits for the CA the proxy attach preflights on. * + * LLP 0259 adds one entry state to the same function: `proxy_mode` already + * on with no CA on disk is a stranded install, not "nothing to do", and its + * repair is the restart half with the write skipped. + * * @import { CommandRunContext } from '../../hypaware-plugin-kernel-types.js' */ @@ -111,13 +116,14 @@ test('a bare local gateway entry (no config block) gains one holding only proxy_ /* ------------------------------ the refusals ----------------------------- */ -test('already on in the effective config: no write, no backup', async () => { +test('already on in the effective config, with the CA on disk: no write, no backup', async () => { const { hypHome, configPath } = await stageHome() const raw = JSON.stringify({ version: 2, plugins: [{ name: '@hypaware/ai-gateway', config: { proxy_mode: true, upstreams: [ANTHROPIC] } }], }, null, 2) + '\n' await fs.writeFile(configPath, raw) + await ensureLocalCa({ stateRoot: path.join(hypHome, 'hypaware'), hosts: ['api.anthropic.com'] }) const result = await enableGatewayProxyMode({ ctx: makeCtx(hypHome), @@ -132,6 +138,113 @@ test('already on in the effective config: no write, no backup', async () => { assert.deepEqual(await backupsIn(hypHome), []) }) +/* ------------------------------- the re-mint ----------------------------- */ + +// The `hyp detach claude --purge` residue: the CA is gone by design and the +// key stays on, so `already` was answering "nothing to do" about a machine +// that could no longer serve what its config asked for. The repair is the +// restart half of this same function, with the write skipped. +// @ref LLP 0259#repair-is-a-restart [tests]: proxy_mode with no CA restarts and waits, and writes nothing +test('proxy_mode on with no CA: the restart runs, the config is never rewritten', async () => { + const { hypHome, configPath } = await stageHome() + const raw = JSON.stringify({ + version: 2, + plugins: [{ name: '@hypaware/ai-gateway', config: { proxy_mode: true, upstreams: [ANTHROPIC] } }], + }, null, 2) + '\n' + await fs.writeFile(configPath, raw) + let restarts = 0 + + const result = await enableGatewayProxyMode({ + ctx: makeCtx(hypHome), + daemonStatus: async () => ({ installed: true }), + restartDaemon: async () => { restarts += 1 }, + waitForBind: async () => ({ bound: true, endpoint: 'http://127.0.0.1:18521' }), + waitForCaFn: async () => ({ ready: true, certPath: '/state/tls/ca-cert.pem' }), + }) + + assert.equal(result.ok, true, result.message ?? '') + assert.equal(result.outcome, 'remint') + assert.equal(restarts, 1) + assert.deepEqual(result.steps, { write: 'n/a', restart: 'ok', wait: 'ok', ca: 'ok' }) + assert.equal(result.caReady, true) + assert.equal(await fs.readFile(configPath, 'utf8'), raw, 'the config is untouched') + assert.deepEqual(await backupsIn(hypHome), [], 'and no backup copy was made for a write that never happened') +}) + +// A re-mint writes nothing, so it has nothing for the LLP 0031 merge to drop: +// a fleet host is repaired exactly like a solo one, where the migration's own +// write would have to decline. +// @ref LLP 0259#repair-is-a-restart [tests]: central ownership does not block a repair that touches no config +test('a centrally-managed gateway is still re-minted: no write to collide', async () => { + const { hypHome, configPath } = await stageHome() + const seedPath = centralSeedPath(path.join(hypHome, 'hypaware')) + await fs.mkdir(path.dirname(seedPath), { recursive: true }) + await fs.writeFile(seedPath, JSON.stringify({ + version: 2, + plugins: [{ name: '@hypaware/ai-gateway', config: { proxy_mode: true, upstreams: [ANTHROPIC] } }], + }) + '\n') + const raw = JSON.stringify({ version: 2, plugins: [] }, null, 2) + '\n' + await fs.writeFile(configPath, raw) + let restarts = 0 + + const result = await enableGatewayProxyMode({ + ctx: makeCtx(hypHome), + daemonStatus: async () => ({ installed: true }), + restartDaemon: async () => { restarts += 1 }, + waitForBind: async () => ({ bound: true }), + waitForCaFn: async () => ({ ready: true, certPath: '/state/tls/ca-cert.pem' }), + }) + + assert.equal(result.ok, true, result.message ?? '') + assert.equal(result.outcome, 'remint') + assert.equal(restarts, 1) + assert.equal(await fs.readFile(configPath, 'utf8'), raw) +}) + +test('a re-mint with no daemon service reports ok with daemonInstalled false, so the caller names the ladder', async () => { + const { hypHome, configPath } = await stageHome() + await fs.writeFile(configPath, JSON.stringify({ + version: 2, + plugins: [{ name: '@hypaware/ai-gateway', config: { proxy_mode: true, upstreams: [ANTHROPIC] } }], + }, null, 2) + '\n') + + const result = await enableGatewayProxyMode({ + ctx: makeCtx(hypHome), + daemonStatus: async () => ({ installed: false }), + restartDaemon: async () => { + throw new Error('must not restart a service that does not exist') + }, + }) + + assert.equal(result.ok, true) + assert.equal(result.outcome, 'remint') + assert.equal(result.daemonInstalled, false) + assert.deepEqual(result.steps, { write: 'n/a', restart: 'n/a', wait: 'n/a', ca: 'n/a' }) +}) + +test('a re-mint whose CA never appears fails the ca step, still with no write', async () => { + const { hypHome, configPath } = await stageHome() + const raw = JSON.stringify({ + version: 2, + plugins: [{ name: '@hypaware/ai-gateway', config: { proxy_mode: true, upstreams: [ANTHROPIC] } }], + }, null, 2) + '\n' + await fs.writeFile(configPath, raw) + + const result = await enableGatewayProxyMode({ + ctx: makeCtx(hypHome), + daemonStatus: async () => ({ installed: true }), + restartDaemon: async () => {}, + waitForBind: async () => ({ bound: true }), + waitForCaFn: async () => ({ ready: false }), + }) + + assert.equal(result.ok, false) + assert.equal(result.outcome, 'failed') + assert.equal(result.failedStep, 'ca') + assert.deepEqual(result.steps, { write: 'n/a', restart: 'ok', wait: 'ok', ca: 'failed' }) + assert.equal(await fs.readFile(configPath, 'utf8'), raw) +}) + // @ref LLP 0244#central-managed [tests]: a fleet-owned gateway block is reported, never locally shadowed test('a centrally-managed gateway block declines the local write', async () => { const { hypHome, configPath } = await stageHome() diff --git a/test/core/status-proxy-mode-ca-missing.test.js b/test/core/status-proxy-mode-ca-missing.test.js new file mode 100644 index 00000000..b9f4be6b --- /dev/null +++ b/test/core/status-proxy-mode-ca-missing.test.js @@ -0,0 +1,162 @@ +// @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 } from '../../src/core/daemon/status.js' +import { renderStatusJson, renderStatusText } from '../../src/core/commands/status.js' +import { defaultConfigPath } from '../../src/core/config/schema.js' +import { ensureLocalCa } from '../../src/core/tls/ca.js' + +/** + * `proxy_mode: true` with no CA on disk used to render as nothing at all: + * `collectProxyTrust` returns null with no certificate to describe, so the + * whole `proxy trust` block disappeared and the install reported healthy + * while every attach it accepted quietly landed in base-URL mode. This is + * the missing half of a pair whose inverse (proxy mode off, CA present) the + * gateway has always warned about. + * + * @ref LLP 0259#status-names-it [tests]: the config-versus-disk disagreement is named on both surfaces, on every platform + * @import { CollectStatusOptions, HypAwareStatusReport } from '../../src/core/daemon/types.js' + */ + +/** + * @param {{ proxyMode?: boolean, enabled?: boolean }} [opts] + * @returns {Promise<{ hypHome: string, stateRoot: string }>} + */ +async function makeHome(opts) { + const hypHome = await fs.mkdtemp(path.join(os.tmpdir(), 'hyp-status-proxy-ca-missing-')) + 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: [ + { + name: '@hypaware/ai-gateway', + ...(opts?.enabled === false ? { enabled: false } : {}), + config: { + upstreams: [{ name: 'anthropic', base_url: 'https://api.anthropic.com', path_prefix: '/v1/messages' }], + ...(opts?.proxyMode === false ? {} : { proxy_mode: true }), + }, + }, + ], + }) + '\n') + return { hypHome, stateRoot } +} + +/** + * @param {string} hypHome + * @param {NodeJS.Platform} platform + * @returns {CollectStatusOptions} + */ +function collectOpts(hypHome, platform) { + return { + env: { ...process.env, HYP_HOME: hypHome, HYP_CONFIG: '' }, + platform, + isLaunchAgentInstalled: () => false, + isSystemdUnitInstalled: () => false, + isCaTrusted: async () => true, + isLaunchdEnvSet: async () => true, + } +} + +/** @returns {{ write(chunk: string): void, text(): string }} */ +function buffer() { + /** @type {string[]} */ + const chunks = [] + return { write: (chunk) => { chunks.push(chunk) }, text: () => chunks.join('') } +} + +/** + * @param {HypAwareStatusReport} report + * @param {string} cacheRoot + */ +function renderText(report, cacheRoot) { + const stdout = buffer() + renderStatusText({ report, clientNames: [], datasets: [], cacheRoot, stdout }) + return stdout.text() +} + +test('proxy_mode on with no CA is a named diagnostic on the text and --json surfaces', async () => { + const { hypHome, stateRoot } = await makeHome() + try { + const report = await collectHypAwareStatus(collectOpts(hypHome, 'darwin')) + + const found = report.diagnostics.find((d) => d.kind === 'proxy_mode_ca_missing') + assert.ok(found, 'the config-versus-disk disagreement is reported') + assert.equal(found.severity, 'warning') + assert.match(found.message, /base-URL mode/) + assert.ok( + found.repair.some((r) => r.includes('hyp daemon restart')), + 'the repair that re-mints the CA is named' + ) + assert.equal(report.proxyTrust, null, 'there is still no certificate to describe') + + const text = renderText(report, path.join(stateRoot, 'cache')) + assert.match(text, /\[WARN \] proxy_mode_ca_missing:/) + assert.match(text, /repair: hyp daemon restart/) + + const json = renderStatusJson({ + report, + clientNames: [], + datasets: [], + cacheRoot: path.join(stateRoot, 'cache'), + }) + assert.ok( + json.diagnostics.some((d) => d.kind === 'proxy_mode_ca_missing'), + 'the --json surface carries it too' + ) + } finally { + await fs.rm(hypHome, { recursive: true, force: true }) + } +}) + +// Not a darwin concern: the keychain and launchd halves are, but a config +// that asks for a transport the machine cannot serve is the same defect on +// every platform. +test('the diagnostic is platform-independent', async () => { + const { hypHome } = await makeHome() + try { + const report = await collectHypAwareStatus(collectOpts(hypHome, 'linux')) + assert.ok(report.diagnostics.some((d) => d.kind === 'proxy_mode_ca_missing')) + } finally { + await fs.rm(hypHome, { recursive: true, force: true }) + } +}) + +test('with the CA on disk there is nothing to report', async () => { + const { hypHome, stateRoot } = await makeHome() + try { + await ensureLocalCa({ stateRoot, hosts: ['api.anthropic.com'] }) + const report = await collectHypAwareStatus(collectOpts(hypHome, 'darwin')) + assert.ok(!report.diagnostics.some((d) => d.kind === 'proxy_mode_ca_missing')) + } finally { + await fs.rm(hypHome, { recursive: true, force: true }) + } +}) + +// A switched-off gateway never launches its source, so it never mints: the +// warning could not be cleared by the repair it names, and nothing is asking +// for a transport the machine cannot serve. +test('a disabled gateway carrying a stale proxy_mode is not a fault', async () => { + const { hypHome } = await makeHome({ enabled: false }) + try { + const report = await collectHypAwareStatus(collectOpts(hypHome, 'darwin')) + assert.ok(!report.diagnostics.some((d) => d.kind === 'proxy_mode_ca_missing')) + } finally { + await fs.rm(hypHome, { recursive: true, force: true }) + } +}) + +test('proxy_mode off and no CA is the ordinary base-URL install, not a fault', async () => { + const { hypHome } = await makeHome({ proxyMode: false }) + try { + const report = await collectHypAwareStatus(collectOpts(hypHome, 'darwin')) + assert.ok(!report.diagnostics.some((d) => d.kind === 'proxy_mode_ca_missing')) + } finally { + await fs.rm(hypHome, { recursive: true, force: true }) + } +})