diff --git a/hypaware-core/plugins-workspace/ai-gateway/src/source.js b/hypaware-core/plugins-workspace/ai-gateway/src/source.js index 94746945..154490aa 100644 --- a/hypaware-core/plugins-workspace/ai-gateway/src/source.js +++ b/hypaware-core/plugins-workspace/ai-gateway/src/source.js @@ -432,9 +432,14 @@ async function prepareInterception(ctx, config, upstreams, liveState) { if (!config.proxyMode) { // Proxy mode is off, but a CA on disk means a client was attached in proxy // mode at some point and may still have `HTTPS_PROXY` pointing here. Serve - // blind tunnels so its egress keeps working, and say so loudly: the repair - // is a re-attach (or a detach), and nothing else on the machine will - // volunteer that. + // blind tunnels so its egress keeps working, and say so loudly: nothing + // else on the machine will volunteer that this install is in the degraded + // state. A plain re-attach is NOT the remedy: attach leaves the CA where + // it is on purpose (the trust is offered back, never taken), so the file + // that puts the install in this state survives the re-attach. The + // remedies that land are removing the CA (`hyp detach claude --purge`, + // then re-attach) or turning `proxy_mode` back on. + // @ref LLP 0262#migration [constrained-by]: attach offers the CA back rather than removing it, so it cannot clear this state on its own const stale = await readLocalCaInfo({ stateRoot: defaultStateRoot(ctx.env) }) if (stale) { liveState.interceptionError = 'proxy_mode is off but a local CA is installed' @@ -442,7 +447,8 @@ async function prepareInterception(ctx, config, upstreams, liveState) { [Attr.PLUGIN]: PLUGIN_NAME, ca_cert_path: stale.certPath, reason: 'serving blind tunnels so an already-attached client keeps working; ' + - 'run `hyp attach claude` to move it back to base-URL mode, or `hyp detach claude`', + 're-attaching leaves this CA on disk, so run `hyp detach claude --purge` ' + + 'and re-attach to clear the proxy residue, or turn proxy_mode back on', }) return { tunnelOnly: true } } diff --git a/test/plugins/ai-gateway-proxy-mode.test.js b/test/plugins/ai-gateway-proxy-mode.test.js index 32839fa8..79b5b7e8 100644 --- a/test/plugins/ai-gateway-proxy-mode.test.js +++ b/test/plugins/ai-gateway-proxy-mode.test.js @@ -555,10 +555,13 @@ test('proxy mode turned off with a CA still installed serves blind tunnels', asy const echoPort = echoAddress && typeof echoAddress === 'object' ? echoAddress.port : 0 t.after(() => new Promise((resolve) => echo.close(() => resolve(undefined)))) - /** @type {{ level: string, event: string }[]} */ + /** @type {{ level: string, event: string, attrs: Record }[]} */ const logged = [] /** @param {string} level */ - const record = (level) => (/** @type {string} */ event) => logged.push({ level, event }) + const record = (level) => ( + /** @type {string} */ event, + /** @type {Record | undefined} */ attrs + ) => logged.push({ level, event, attrs: attrs ?? {} }) const ctx = /** @type {any} */ ({ // proxy_mode deliberately absent, as if the operator turned it back off. config: { @@ -576,7 +579,17 @@ test('proxy mode turned off with a CA still installed serves blind tunnels', asy const details = /** @type {any} */ ((await source.status()).details) assert.equal(details.proxy_mode, false) assert.match(details.proxy_mode_error, /a local CA is installed/) - assert.equal(logged.some((l) => l.event === 'aigw.proxy_mode_stale_ca'), true) + const staleWarn = logged.find((l) => l.event === 'aigw.proxy_mode_stale_ca') + assert.ok(staleWarn, 'the stale-CA warning is emitted') + + // The warning has to name a remedy that actually works. Attach deliberately + // leaves the CA on disk (it offers the trust back rather than taking it), so + // telling the operator to re-attach leaves the install exactly as degraded + // as it was, every time, until the CA is gone. + // @ref LLP 0262#migration [tests]: the stale-CA remedy cannot be a plain re-attach + const reason = String(staleWarn.attrs.reason ?? '') + assert.match(reason, /hyp detach claude --purge/) + assert.doesNotMatch(reason, /run `hyp attach claude` to move it back/) // The tunnel is still served, so an already-attached client keeps its egress // instead of losing all HTTPS. (The byte-level round trip is covered by