From 0d200c925125127b5a39b13ded14887828760500 Mon Sep 17 00:00:00 2001 From: test Date: Wed, 19 Aug 2026 04:13:44 +0000 Subject: [PATCH 1/7] Stop offering a proxy migration and a CA repair the otel Claude attach cannot perform (#885) Two user-facing instructions left over from LLP 0262, where the `claude` client's attach became otel-only: one settings write, no repointed base URL, no CA, no keychain, no launchd env. 1. `hyp attach claude` on an install whose gateway config lacks `proxy_mode` offered the LLP 0244 migration, because `@hypaware/claude`'s picker row still declared `compose.gateway_proxy_mode`. Every clause of the question was false: the otel attach repoints no base URL, and a yes wrote `proxy_mode`, restarted the daemon, and blocked on a CA mint the following attach never consults. The scripted shapes carried the matching one-line pointer at a migration that does not exist. LLP 0243's own `Extended-by` line already settled the fix: the claude row stops declaring the flag, while the composition rule itself is unchanged. The `hyp init claude` preset, which writes literally what the fold composes, stops writing `proxy_mode: true` for the same reason: a claude-only install would mint a CA it never presents and then trip the gateway's own stale-CA warning. 2. `hyp status`'s proxy-trust block told a macOS host with a leftover CA that an untrusted keychain breaks Remote Control inbound and that `hyp attach claude` retries the trust, and that the same command sets `NODE_USE_SYSTEM_CA`. Nothing installs either any more (`installCaTrust` / `installLaunchdEnv` have no production callers since the otel switch), and Remote Control no longer rests on the trust at all, since the base URL is never repointed (LLP 0262 R6). Both lines now state the fact, and the block names the one command that acts on what it reports: `hyp detach claude --purge`. The migration machinery stays generic for any client still captured by the proxy, and keeps its coverage: the tests drive it through a staged installed plugin whose picker row declares the flag, instead of through `claude`. docs/PRIVACY.md carried the same stale claim ("attach installs the CA into your login keychain") and now describes it as residue of an earlier release. Co-Authored-By: Claude Opus 5 (1M context) --- docs/PRIVACY.md | 19 +- .../smoke/flows/claude_attach_detach.js | 8 +- .../walkthrough_picker_to_first_query.js | 4 + src/core/commands/clients.js | 7 + src/core/commands/status.js | 38 +- test/core/attach-all-notes.test.js | 3 + test/core/attach-enablement-state.test.js | 3 + test/core/attach-proxy-migration.test.js | 368 +++++++++++++++--- ...js => init-proxy-mode-composition.test.js} | 43 +- test/core/status-proxy-trust.test.js | 18 +- 10 files changed, 431 insertions(+), 80 deletions(-) rename test/core/{init-proxy-mode-default.test.js => init-proxy-mode-composition.test.js} (67%) diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 0a4acc4d8..dcda06ea7 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -69,17 +69,14 @@ outside the provider set HypAware's client adapters intercept (today `api.anthropic.com`, `api.openai.com`, `chatgpt.com`). All IP addresses are excluded. -**Where it is trusted.** On macOS, attach installs the CA into your **login -keychain** as a user-domain trusted root, because Claude Code's Remote -Control transport trusts only the keychain and nothing else. This does -change your account's certificate trust settings, which is why macOS itself -raises the password dialog: an application running as you that consults the -login keychain will accept certificates this CA signs, for those hosts. -Declining the dialog is supported and capture keeps working without it, with -only Remote Control's inbound channel lost. The change never needs admin -rights, and the machine-wide system keychain and other user accounts are -never modified. On other platforms the CA is trusted only by Claude Code, -through that client's own settings. +**Where it is trusted.** Nothing installs this CA into a system trust store +any more. A proxied client trusts it through that client's own settings, and +you decide anything wider yourself. Earlier releases attached Claude Code by +proxy and did add the CA to your macOS **login keychain** as a user-domain +trusted root (macOS raised its own password dialog for it); if you ran one of +those, that trust setting is still on your account until you remove it, and +`hyp status` says so. It never needed admin rights, and the machine-wide +system keychain and other user accounts were never modified. **What else macOS attach leaves behind.** The keychain root only takes effect if `NODE_USE_SYSTEM_CA=1` is in the environment before Claude Code diff --git a/hypaware-core/smoke/flows/claude_attach_detach.js b/hypaware-core/smoke/flows/claude_attach_detach.js index 83c819f8b..6a7b6139a 100644 --- a/hypaware-core/smoke/flows/claude_attach_detach.js +++ b/hypaware-core/smoke/flows/claude_attach_detach.js @@ -157,10 +157,14 @@ export async function run({ harness, expect }) { } ) expect.that('dispatch: hyp client attach claude exited 0', attachCode, (v) => v === 0) + // Silent stderr: the claude row stopped declaring proxy attach when the + // client went otel-only, so there is no migration to point a scripted + // attach at, and the LLP 0244 pointer is gone with it. + // @ref LLP 0262#migration [tests]: an otel attach offers no proxy-mode switch, in any shape expect.that( - 'stderr: hyp client attach had no errors', + 'stderr: hyp client attach had no errors and no proxy-mode note', attachStderr.text(), - (v) => typeof v === 'string' && v.length === 0 + (v) => v === '' ) expect.that( 'stdout: hyp client attach printed the settings path', diff --git a/hypaware-core/smoke/flows/walkthrough_picker_to_first_query.js b/hypaware-core/smoke/flows/walkthrough_picker_to_first_query.js index ad4f7c526..1da709ad6 100644 --- a/hypaware-core/smoke/flows/walkthrough_picker_to_first_query.js +++ b/hypaware-core/smoke/flows/walkthrough_picker_to_first_query.js @@ -648,6 +648,10 @@ async function goldenPickerConfig(hypHome) { { name: 'openai', base_url: 'https://api.openai.com', path_prefix: '/v1', provider: 'openai' }, { name: 'chatgpt', base_url: 'https://chatgpt.com', path_prefix: '/backend-api/codex', provider: 'chatgpt' }, ], + // No `proxy_mode`: no bundled picker row declares proxy attach since + // the claude client went otel-only, so the wizard composes a gateway + // that mints no CA. + // @ref LLP 0262#requirements [tests]: R5 - a composed claude install needs no CA and no keychain trust }, }, { diff --git a/src/core/commands/clients.js b/src/core/commands/clients.js index a3184eb64..355b5570a 100644 --- a/src/core/commands/clients.js +++ b/src/core/commands/clients.js @@ -901,6 +901,13 @@ async function maybeInteractiveEnableAttach({ name, ctx, parsed, enablement }) { * config write is still the repair the gateway's own stale-CA warning asks * for. * + * No bundled picker row declares `gateway_proxy_mode` since LLP 0262: the + * `claude` row was the only one, and dropped it when its attach became + * otel-only. The rule stays generic for any client still captured by the + * proxy (LLP 0243's composition rule is unchanged); what it must never do is + * fire for a client whose attach repoints no base URL, because every clause + * of the question below would then be false. + * * Never throws into the attach: the caller downgrades any escape to a * warning, because base-URL attach is what this install already does and * remains the working fallback. diff --git a/src/core/commands/status.js b/src/core/commands/status.js index 2be27eab8..bdbd9ad87 100644 --- a/src/core/commands/status.js +++ b/src/core/commands/status.js @@ -748,6 +748,19 @@ export function renderStatusText({ report, clientNames, datasets, cacheRoot, std stdout.write(` permitted: ${describePermittedHosts(report.proxyTrust.hosts)}\n`) stdout.write(` login keychain: ${describeCaTrust(report.proxyTrust.trusted)}\n`) stdout.write(` launchd env: ${describeLaunchdEnv(report.proxyTrust.launchdEnvSet)}\n`) + // The block only ever renders on a CA a past proxy attach minted, and + // since LLP 0262 the claude attach neither installs nor reads either + // mechanism. So the block names the one command that acts on what it + // reports instead of leaving three facts with no verb. Unconditional: + // the CA on disk is itself something the purge removes, whatever the two + // probes answered. + // @ref LLP 0262#migration [implements]: the CA and any trust it was granted outlive the migration and end at `detach --purge`, never at another attach + stdout.write( + ' note: only proxy_mode capture uses this CA, and the claude attach no longer does\n' + ) + stdout.write( + " 'hyp client detach claude --purge' removes the CA, its keychain trust, and the launchd env\n" + ) } stdout.write(` cache: ${cacheRoot}\n`) @@ -906,20 +919,23 @@ export function renderStatusText({ report, clientNames, datasets, cacheRoot, std } /** - * The keychain half of the `proxy trust` block. An untrusted CA carries the - * repair with it because the repair is one command and the state is otherwise - * silent: capture keeps working, so nothing else in the report will ever - * mention it (LLP 0237#attach-anyway-on-refusal). `null` is a probe that could - * not run, which is a different answer from "not trusted" and says so. + * The keychain half of the `proxy trust` block. A bare state, with no repair + * attached: the repair used to be `hyp client attach claude`, and since LLP 0262 that + * attach writes an OTEL settings block and touches neither the keychain nor + * the launchd environment, so naming it would point the user at a command that + * cannot change this line. Nor does an untrusted CA break Remote Control any + * more: the OTEL attach repoints no base URL, so the inbound channel the trust + * grant existed for never meets the proxy. What does act on the CA is named + * once, by the block itself. `null` is a probe that could not run, which is a + * different answer from "not trusted" and says so. * + * @ref LLP 0262#requirements [constrained-by]: R6 - the base URL is never repointed, so Remote Control no longer rests on this trust * @param {boolean | null} trusted * @returns {string} */ function describeCaTrust(trusted) { if (trusted === true) return 'trusted' - if (trusted === false) { - return 'not trusted - Remote Control inbound will not work, run `hyp client attach claude` to retry' - } + if (trusted === false) return 'not trusted' return 'unknown - the keychain probe could not run' } @@ -945,14 +961,16 @@ function describePermittedHosts(hosts) { } /** - * The launchd half of the `proxy trust` block. Same tri-state, same reason. + * The launchd half of the `proxy trust` block. Same tri-state, and the same + * reason for stating rather than prescribing: nothing sets `NODE_USE_SYSTEM_CA` + * on this machine any more, so an unset variable has no repair to carry. * * @param {boolean | null} set * @returns {string} */ function describeLaunchdEnv(set) { if (set === true) return `${ENV_VAR_NAME}=1 set` - if (set === false) return `${ENV_VAR_NAME} not set - run \`hyp client attach claude\` to set it` + if (set === false) return `${ENV_VAR_NAME} not set` return 'unknown - the launchctl probe could not run' } diff --git a/test/core/attach-all-notes.test.js b/test/core/attach-all-notes.test.js index 92805053a..19cd13fb4 100644 --- a/test/core/attach-all-notes.test.js +++ b/test/core/attach-all-notes.test.js @@ -90,6 +90,8 @@ test('hyp client attach all: a catalog-known client missing from the live regist }) const code = await runAttach(['all'], ctx) assert.equal(code, 0, stderr.text()) + // Nothing on stderr: no bundled client row declares proxy attach since + // LLP 0262, so `attach all` owes no migration pointer either. assert.equal(stderr.text(), '') const lines = stdout.text().split('\n').filter((l) => l.startsWith('note:')) @@ -107,6 +109,7 @@ test('hyp client attach all: the note does not change the exit code, only real a const { ctx, stdout, stderr } = makeCtx({ home, registered: ['claude', 'claude-desktop'] }) const code = await runAttach(['all'], ctx) assert.equal(code, 0, stderr.text()) + // No migration pointer on stderr (see above). assert.equal(stderr.text(), '') const notes = stdout.text().split('\n').filter((l) => l.startsWith('note:')).sort() diff --git a/test/core/attach-enablement-state.test.js b/test/core/attach-enablement-state.test.js index b5c706144..631601d31 100644 --- a/test/core/attach-enablement-state.test.js +++ b/test/core/attach-enablement-state.test.js @@ -279,6 +279,9 @@ test('a registered client still attaches unchanged', async () => { const { ctx, stderr } = makeCtx({ home, registered: ['claude'] }) const code = await runAttach(['claude'], ctx) assert.equal(code, 0, stderr.text()) + // Silent: the claude row stopped declaring proxy attach with LLP 0262, so + // the LLP 0244 migration pointer no longer rides along, and an attach that + // enables nothing has nothing to say on stderr. assert.equal(stderr.text(), '') }) }) diff --git a/test/core/attach-proxy-migration.test.js b/test/core/attach-proxy-migration.test.js index ed9bfdaba..a931194eb 100644 --- a/test/core/attach-proxy-migration.test.js +++ b/test/core/attach-proxy-migration.test.js @@ -10,38 +10,40 @@ import test from 'node:test' import { runAttach } from '../../src/core/commands/clients.js' import { buildPluginCatalog } from '../../src/core/plugin_catalog.js' import { discoverBundledPlugins } from '../../src/core/runtime/bundled.js' +import { centralSeedPath } from '../../src/core/config/apply.js' +import { writeLock } from '../../src/core/plugin_install/lock.js' /** - * LLP 0262 retired proxy attach for `claude`: its picker row no longer - * composes `gateway_proxy_mode` or an Anthropic upstream, so the LLP 0244 - * migration offer in `maybeOfferProxyModeMigration` has no bundled row left - * to fire on. These tests pin that outcome from both ends, because a silent - * offer is indistinguishable from a broken one: + * LLP 0244 #attach-offers: `hyp client attach ` on a base-URL install offers + * the proxy-mode switch when the client's picker row declares proxy attach. + * These tests pin the offer's gates (interactive, single-client, wet-run, + * config lacks the key) and both answers. The switch machinery itself is + * covered by gateway-proxy-enable.test.js; here the daemon is never installed + * (the test-runner guard refuses real service managers anyway), so an accepted + * switch lands the write and reports that a daemon start is the next step. * - * - structurally, that no bundled picker row declares the flag the offer - * gates on, and - * - behaviorally, that a real `hyp client attach claude` against a - * base-URL-era config asks nothing, points at nothing, and writes nothing, - * in every attach shape that used to get a question or a pointer - * (interactive, `--json`, non-TTY, `all`). - * - * If a picker row ever declares `gateway_proxy_mode` again, the first test - * fails on purpose: the offer becomes reachable, and the behavior pins it - * needs (the consented question, the accept/decline writes, the fleet note, - * the failure path) have to come back with it. They are in this file's git - * history, at the commit before the LLP 0248 CLI rollover. + * The declarer is a staged installed plugin, not `claude`: LLP 0262 made the + * claude attach otel-only, so its row stopped declaring `gateway_proxy_mode` + * and the offer must never reach it. The last two tests pin that, and the rest + * keep the composition rule LLP 0243 preserved under test for any client still + * captured by the proxy. * * @import { CommandRunContext } from '../../hypaware-plugin-kernel-types.js' */ const MIGRATION_QUESTION = 'Switch this install to proxy mode now? [y/N] ' +const PROXY_CLIENT = 'proxyclient' -/** @returns {{ write(chunk: unknown): boolean, text(): string }} */ -function makeBuf() { +/** + * @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() { @@ -56,14 +58,10 @@ function localConfigPath(home) { } /** - * A pre-rollover install: a gateway with the Anthropic upstream and the claude - * adapter proxied through it, and no `proxy_mode` key. This is exactly the - * shape the migration offer existed to upgrade, so it is the shape that proves - * the offer is gone rather than merely unexercised. - * * @param {string} home + * @param {{ proxyMode?: boolean }} [opts] */ -function writeGatewayConfig(home) { +function writeGatewayConfig(home, opts) { mkdirSync(path.dirname(localConfigPath(home)), { recursive: true }) writeFileSync(localConfigPath(home), JSON.stringify({ version: 2, @@ -72,6 +70,7 @@ function writeGatewayConfig(home) { name: '@hypaware/ai-gateway', config: { upstreams: [{ name: 'anthropic', base_url: 'https://api.anthropic.com', path_prefix: '/v1/messages' }], + ...(opts?.proxyMode ? { proxy_mode: true } : {}), }, }, { name: '@hypaware/claude', config: { proxy: '@hypaware/ai-gateway' } }, @@ -80,15 +79,66 @@ function writeGatewayConfig(home) { } /** - * A registered-claude attach context: the adapter is live, so the LLP 0174 - * enable prompt never fires and nothing but the migration offer could ask a - * question. `answer` is pre-buffered so that a reached prompt trips the - * assertions below instead of parking on an empty stdin. + * Stage an installed plugin whose picker row declares proxy attach, so the + * offer has a declarer to key on. Mirrors what `hyp plugin install` lands on + * disk (install dir + lock entry), the shape `buildAttachPluginCatalog` + * discovers. + * + * @param {string} home + */ +async function stageProxyClientPlugin(home) { + const name = '@acme/proxy-client' + const stateDir = path.join(home, '.hyp', 'hypaware') + const installDir = path.join(stateDir, 'plugins', 'acme-proxy-client') + mkdirSync(installDir, { recursive: true }) + writeFileSync(path.join(installDir, 'hypaware.plugin.json'), JSON.stringify({ + schema_version: 1, + name, + version: '1.0.0', + hypaware_api: '^1.0.0', + runtime: 'node', + entrypoint: './index.js', + contributes: { + client: { name: PROXY_CLIENT }, + picker: [ + { + name: PROXY_CLIENT, + label: 'Proxy Client', + compose: { + plugin: { name, config: { proxy: '@hypaware/ai-gateway' } }, + requires_gateway: true, + gateway_proxy_mode: true, + }, + }, + ], + }, + })) + writeFileSync(path.join(installDir, 'index.js'), 'export async function activate() {}\n') + await writeLock(stateDir, { + schema_version: 1, + plugins: { + [name]: { + name, + version: '1.0.0', + source: { kind: 'local-dir', raw: installDir, path: installDir }, + install_dir: installDir, + content_hash: 'a'.repeat(64), + manifest_hash: 'b'.repeat(64), + installed_at: '2026-08-18T00:00:00.000Z', + }, + }, + }) +} + +/** + * A registered-client attach context: every adapter under test is live, so the + * LLP 0174 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 }} opts */ function makeCtx({ home, answer, tty = true }) { - const registered = ['claude', 'codex'] + const registered = ['claude', 'codex', PROXY_CLIENT] const gateway = { localEndpoint() { return 'http://127.0.0.1:60680' @@ -104,6 +154,13 @@ function makeCtx({ home, answer, tty = true }) { path.join(home, `${name}-attached.json`), JSON.stringify({ endpoint: args.endpoint, dryRun: args.dryRun === true }) ) + // Mirror a real adapter's --json contract: under json, stdout carries + // exactly the one-line machine payload and nothing else, so the + // --json pins below can assert it stays clean of any migration note. + // The flag is read off `args`, the way a real adapter reads it, so + // the pins also fail if the command stops propagating `--json` into + // `client.attach()` and every adapter starts printing prose into a + // machine-readable run. if (args.json === true) { args.stdout.write( JSON.stringify({ status: 'ok', action: 'attach', client: name, dry_run: args.dryRun === true }) + '\n' @@ -121,6 +178,8 @@ function makeCtx({ home, answer, tty = true }) { if (answer !== undefined) stdin.write(`${answer}\n`) const stdoutBuf = makeBuf() const stderrBuf = makeBuf() + // 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')) const ctx = /** @type {CommandRunContext} */ (/** @type {any} */ ({ stdout: stdoutBuf, @@ -165,73 +224,280 @@ test('no bundled picker row declares the proxy attach the migration offer gates .map(([name]) => name) assert.deepEqual(declaring, [], 'a row declaring proxy attach makes the offer reachable again') }) - -test('interactive attach on a base-URL-era config asks nothing and writes nothing', async () => { +test('decline: the question is asked once, nothing is written, attach still lands', async () => { await withTempHome(async (home) => { writeGatewayConfig(home) + await stageProxyClientPlugin(home) const before = readFileSync(localConfigPath(home), 'utf8') + const { ctx, stderr } = makeCtx({ home, answer: 'n' }) + const code = await runAttach([PROXY_CLIENT], ctx) + assert.equal(code, 0, stderr.text()) + assert.ok(stderr.text().includes(MIGRATION_QUESTION)) + assert.match(stderr.text(), /keeping the base-URL attach/) + assert.equal(readFileSync(localConfigPath(home), 'utf8'), before) + const attached = JSON.parse(readFileSync(path.join(home, `${PROXY_CLIENT}-attached.json`), 'utf8')) + assert.equal(attached.endpoint, 'http://127.0.0.1:60680') + }) +}) + +test('accept: proxy_mode lands in the local config; with no daemon service the next step is named', async () => { + await withTempHome(async (home) => { + writeGatewayConfig(home) + await stageProxyClientPlugin(home) const { ctx, stdout, stderr } = makeCtx({ home, answer: 'y' }) - const code = await runAttach(['claude'], ctx) + const code = await runAttach([PROXY_CLIENT], ctx) + assert.equal(code, 0, stderr.text()) + const after = JSON.parse(readFileSync(localConfigPath(home), 'utf8')) + assert.equal(after.plugins[0].config.proxy_mode, true) + // The test runner refuses real service managers (LLP 0181), which + // degrades to "no daemon installed": the write is the whole job and the + // output names the start + re-attach path. + assert.match(stdout.text(), /proxy_mode written/) + assert.match(stdout.text(), /hyp daemon install/) + // Attach itself still ran (base-URL: no CA exists here). + const attached = JSON.parse(readFileSync(path.join(home, `${PROXY_CLIENT}-attached.json`), 'utf8')) + assert.equal(attached.endpoint, 'http://127.0.0.1:60680') + }) +}) + +test('proxy_mode already in the config: no question, no note', async () => { + await withTempHome(async (home) => { + writeGatewayConfig(home, { proxyMode: true }) + await stageProxyClientPlugin(home) + const { ctx, stderr } = makeCtx({ home }) + const code = await runAttach([PROXY_CLIENT], ctx) assert.equal(code, 0, stderr.text()) assert.ok(!stderr.text().includes(MIGRATION_QUESTION)) assert.doesNotMatch(stderr.text(), /proxy mode/) - assert.doesNotMatch(stdout.text(), /proxy_mode written/) - const after = readFileSync(localConfigPath(home), 'utf8') - assert.equal(after, before) - assert.doesNotMatch(after, /proxy_mode/) - const attached = JSON.parse(readFileSync(path.join(home, 'claude-attached.json'), 'utf8')) - assert.equal(attached.endpoint, 'http://127.0.0.1:60680', 'the attach itself still lands') }) }) test('a client whose row does not declare proxy attach is never asked', async () => { await withTempHome(async (home) => { writeGatewayConfig(home) - const { ctx, stderr } = makeCtx({ home, answer: 'y' }) + await stageProxyClientPlugin(home) + const { ctx, stderr } = makeCtx({ home }) const code = await runAttach(['codex'], ctx) assert.equal(code, 0, stderr.text()) + assert.ok(!stderr.text().includes(MIGRATION_QUESTION)) assert.equal(stderr.text(), '') }) }) -test('--json on a TTY emits no migration pointer and stdout stays the attach payload', async () => { +// The live failure of 2026-08-17: a fleet host carries a central layer that +// also names the gateway, so the merge drops any local proxy_mode write. +// The offer must detect that from central NAMING the plugin (a local entry +// exists here too) and report instead of prompting, on a TTY included. +// @ref LLP 0244#central-managed [tests]: a fleet-owned gateway block reports, never prompts, even beside a local entry +test('centrally-managed gateway: no question even on a TTY, the fleet note instead, nothing written', async () => { await withTempHome(async (home) => { writeGatewayConfig(home) + await stageProxyClientPlugin(home) + const seedPath = centralSeedPath(path.join(home, '.hyp', 'hypaware')) + mkdirSync(path.dirname(seedPath), { recursive: true }) + writeFileSync(seedPath, JSON.stringify({ + version: 2, + plugins: [{ name: '@hypaware/ai-gateway', config: { listen: '127.0.0.1:18521' } }], + }) + '\n') const before = readFileSync(localConfigPath(home), 'utf8') - const { ctx, stdout, stderr } = makeCtx({ home, tty: true, answer: 'y' }) - const code = await runAttach(['--client', 'claude', '--json'], ctx) + const { ctx, stderr } = makeCtx({ home, answer: 'y' }) + const code = await runAttach([PROXY_CLIENT], ctx) assert.equal(code, 0, stderr.text()) - assert.equal(stderr.text(), '', 'the pointer the offer used to print is gone with it') + assert.ok(!stderr.text().includes(MIGRATION_QUESTION)) + assert.match(stderr.text(), /centrally managed; enable proxy_mode in the fleet config/) assert.equal(readFileSync(localConfigPath(home), 'utf8'), before) + }) +}) + +// @ref LLP 0244#non-interactive [tests]: no TTY means no migration and exactly the one-line pointer +test('non-TTY: no question, one pointer note, attach unchanged', async () => { + await withTempHome(async (home) => { + writeGatewayConfig(home) + await stageProxyClientPlugin(home) + const before = readFileSync(localConfigPath(home), 'utf8') + const { ctx, stderr } = makeCtx({ home, tty: false }) + const code = await runAttach([PROXY_CLIENT], ctx) + assert.equal(code, 0, stderr.text()) + assert.ok(!stderr.text().includes(MIGRATION_QUESTION)) + assert.match(stderr.text(), new RegExp(`run 'hyp client attach ${PROXY_CLIENT}' in an interactive terminal`)) + assert.equal(readFileSync(localConfigPath(home), 'utf8'), before) + }) +}) + +// @ref LLP 0244#non-interactive [tests]: --json never prompts even on a TTY and emits exactly the pointer +test('--json on a TTY: no prompt, exactly one pointer note, no write, stdout stays the attach JSON payload', async () => { + await withTempHome(async (home) => { + writeGatewayConfig(home) + await stageProxyClientPlugin(home) + const before = readFileSync(localConfigPath(home), 'utf8') + // A `y` is queued even though nothing may consume it: were the askYesNo + // seam ever reached, an unanswerable prompt would park on an empty stdin + // and the run would never settle, cancelling the rest of this file + // instead of naming the regression. With the answer buffered, a reached + // seam instead accepts the migration and trips the question, stderr, and + // config-unchanged assertions below, loudly and in place. + const { ctx, stdout, stderr } = makeCtx({ home, tty: true, answer: 'y' }) + const code = await runAttach(['--client', PROXY_CLIENT, '--json'], ctx) + assert.equal(code, 0, stderr.text()) + // The askYesNo seam is never reached: its question never reaches stderr. + assert.ok(!stderr.text().includes(MIGRATION_QUESTION)) + // Exactly the one pointer line, nothing else on stderr. + assert.equal( + stderr.text(), + `note: this install attaches ${PROXY_CLIENT} by base URL; ` + + `run 'hyp client attach ${PROXY_CLIENT}' in an interactive terminal to switch it to proxy mode\n` + ) + // No config write: proxy_mode stays absent from the file on disk. + const after = readFileSync(localConfigPath(home), 'utf8') + assert.equal(after, before) + assert.doesNotMatch(after, /proxy_mode/) + // stdout stays the attach's valid JSON payload, nothing interleaved. const stdoutText = stdout.text() assert.equal(stdoutText.split('\n').filter((line) => line.length > 0).length, 1) const payload = JSON.parse(stdoutText) assert.equal(payload.status, 'ok') - assert.equal(payload.client, 'claude') + assert.equal(payload.client, PROXY_CLIENT) }) }) -test('non-TTY attach emits no migration pointer', async () => { +// @ref LLP 0244#non-interactive [tests]: --json never prompts even on a TTY and emits exactly the pointer +test('--json combined with non-TTY still emits the pointer exactly once, not twice', async () => { await withTempHome(async (home) => { writeGatewayConfig(home) + await stageProxyClientPlugin(home) const before = readFileSync(localConfigPath(home), 'utf8') - const { ctx, stderr } = makeCtx({ home, tty: false }) - const code = await runAttach(['claude'], ctx) + const { ctx, stdout, stderr } = makeCtx({ home, tty: false, answer: 'y' }) + const code = await runAttach(['--client', PROXY_CLIENT, '--json'], ctx) assert.equal(code, 0, stderr.text()) - assert.equal(stderr.text(), '') + assert.ok(!stderr.text().includes(MIGRATION_QUESTION)) + // Both conditions (json and non-TTY) independently qualify for the + // pointer; it must still land exactly once, never doubled. + assert.equal( + stderr.text(), + `note: this install attaches ${PROXY_CLIENT} by base URL; ` + + `run 'hyp client attach ${PROXY_CLIENT}' in an interactive terminal to switch it to proxy mode\n` + ) + const after = readFileSync(localConfigPath(home), 'utf8') + assert.equal(after, before) + assert.doesNotMatch(after, /proxy_mode/) + const stdoutText = stdout.text() + assert.equal(stdoutText.split('\n').filter((line) => line.length > 0).length, 1) + const payload = JSON.parse(stdoutText) + assert.equal(payload.status, 'ok') + assert.equal(payload.client, PROXY_CLIENT) + }) +}) + +test('--dry-run: no question, no note, no write', async () => { + await withTempHome(async (home) => { + writeGatewayConfig(home) + await stageProxyClientPlugin(home) + const before = readFileSync(localConfigPath(home), 'utf8') + const { ctx, stderr } = makeCtx({ home, answer: 'y' }) + const code = await runAttach([PROXY_CLIENT, '--dry-run'], ctx) + assert.equal(code, 0, stderr.text()) + assert.ok(!stderr.text().includes(MIGRATION_QUESTION)) assert.equal(readFileSync(localConfigPath(home), 'utf8'), before) }) }) -test("'hyp client attach all' emits no migration pointer mid-run", async () => { +// LLP 0244 #non-interactive: `hyp client attach all` never prompts mid-run, but it +// is not silent either - it owes the one-line pointer naming the command +// that migrates, or the habit of attaching everything at once means an old +// install never learns the migration exists. +test("'hyp client attach all' never asks the migration question mid-run, but points at it", async () => { await withTempHome(async (home) => { writeGatewayConfig(home) + await stageProxyClientPlugin(home) const before = readFileSync(localConfigPath(home), 'utf8') const { ctx, stderr } = makeCtx({ home, answer: 'y' }) const code = await runAttach(['all'], ctx) assert.equal(code, 0, stderr.text()) assert.ok(!stderr.text().includes(MIGRATION_QUESTION)) - assert.doesNotMatch(stderr.text(), /base URL/) + assert.match(stderr.text(), new RegExp(`attaches ${PROXY_CLIENT} by base URL`)) + assert.match(stderr.text(), new RegExp(`run 'hyp client attach ${PROXY_CLIENT}' in an interactive terminal`)) assert.equal(readFileSync(localConfigPath(home), 'utf8'), before) }) }) + +// The catch that keeps LLP 0244's safety promise: a migration failure never +// fails the attach. The write step is driven into a real filesystem refusal; +// the attach must still land in base-URL mode with exit 0 and a warning that +// names the backup state. +test('a failed accepted migration warns and the attach still succeeds', async () => { + await withTempHome(async (home) => { + writeGatewayConfig(home) + await stageProxyClientPlugin(home) + const before = readFileSync(localConfigPath(home), 'utf8') + const { ctx, stdout, stderr } = makeCtx({ home, answer: 'y' }) + const hypDir = path.join(home, '.hyp') + const { chmodSync } = await import('node:fs') + chmodSync(hypDir, 0o555) + try { + const code = await runAttach([PROXY_CLIENT], ctx) + assert.equal(code, 0, 'the attach exit code is untouched by the migration failure') + assert.match(stderr.text(), /could not switch to proxy mode/) + assert.doesNotMatch(stdout.text(), /proxy mode enabled/) + const attached = JSON.parse(readFileSync(path.join(home, `${PROXY_CLIENT}-attached.json`), 'utf8')) + assert.equal(attached.endpoint, 'http://127.0.0.1:60680', 'base-URL attach still ran') + } finally { + chmodSync(hypDir, 0o755) + } + assert.equal(readFileSync(localConfigPath(home), 'utf8'), before, 'the config on disk is untouched') + }) +}) + +// LLP 0262 retired proxy capture for the `claude` client: its attach writes an +// OTEL settings block, repoints no base URL, mints no CA, and asks for no +// keychain trust. A question naming a repointed base URL, a daemon restart, +// and a coming trust dialog would describe an attach this build cannot +// perform, and a yes would write proxy_mode plus a CA the attach never +// consults. +// @ref LLP 0243#composed-default [tests]: the claude row stops declaring proxy attach, so the offer never reaches it +test('claude is never offered the proxy-mode switch: its attach is otel-only', async () => { + await withTempHome(async (home) => { + writeGatewayConfig(home) + const before = readFileSync(localConfigPath(home), 'utf8') + const { ctx, stdout, stderr } = makeCtx({ home, answer: 'n' }) + const code = await runAttach(['claude'], ctx) + assert.equal(code, 0, stderr.text()) + assert.ok(!stderr.text().includes(MIGRATION_QUESTION), 'no migration question') + assert.equal(stderr.text(), '', 'no proxy-mode chatter at all') + assert.doesNotMatch(stdout.text(), /proxy mode/) + assert.equal(readFileSync(localConfigPath(home), 'utf8'), before) + const attached = JSON.parse(readFileSync(path.join(home, 'claude-attached.json'), 'utf8')) + assert.equal(attached.endpoint, 'http://127.0.0.1:60680') + }) +}) + +// The same claim in its non-interactive shape: a scripted claude attach used +// to carry a one-line pointer at the interactive migration. There is no +// migration to point at now, so the line is gone. +test('a scripted claude attach carries no base-URL pointer note', async () => { + await withTempHome(async (home) => { + writeGatewayConfig(home) + const { ctx, stderr } = makeCtx({ home, tty: false }) + const code = await runAttach(['claude'], ctx) + assert.equal(code, 0, stderr.text()) + assert.equal(stderr.text(), '') + }) +}) + +// The other scripted shape, and the one whose pointer had its own pin before +// LLP 0262: `--json` qualifies for the note on a TTY too, so a claude row that +// still declared proxy attach would show it here even when the non-TTY case +// above passed. Nothing but the machine payload may reach a --json run. +test('a --json claude attach on a TTY carries no base-URL pointer note either', async () => { + await withTempHome(async (home) => { + writeGatewayConfig(home) + const before = readFileSync(localConfigPath(home), 'utf8') + const { ctx, stdout, stderr } = makeCtx({ home, tty: true, answer: 'y' }) + const code = await runAttach(['--client', 'claude', '--json'], ctx) + assert.equal(code, 0, stderr.text()) + assert.equal(stderr.text(), '') + assert.equal(readFileSync(localConfigPath(home), 'utf8'), before) + const payload = JSON.parse(stdout.text()) + assert.equal(payload.status, 'ok') + assert.equal(payload.client, 'claude') + }) +}) diff --git a/test/core/init-proxy-mode-default.test.js b/test/core/init-proxy-mode-composition.test.js similarity index 67% rename from test/core/init-proxy-mode-default.test.js rename to test/core/init-proxy-mode-composition.test.js index 331e129d8..c41b57865 100644 --- a/test/core/init-proxy-mode-default.test.js +++ b/test/core/init-proxy-mode-composition.test.js @@ -18,8 +18,14 @@ import { compileConfig } from '../../hypaware-core/plugins-workspace/ai-gateway/ import { activate as activateClaude } from '../../hypaware-core/plugins-workspace/claude/src/index.js' /** - * @ref LLP 0262#injection [tests]: Claude's sanctioned OTEL attach does not - * require gateway proxy mode, so no picker combination silently mints a CA. + * LLP 0243's composition rule is unchanged: a picker row that declares + * `compose.gateway_proxy_mode` turns the composed gateway into a proxy-mode + * gateway. What changed with LLP 0262 is who declares it. The claude row + * dropped the flag when its attach became otel-only, and no bundled row + * carries it now, so no config-writing install path mints a CA the install + * will never present. + * + * @ref LLP 0262#injection [tests]: Claude's sanctioned OTEL attach does not require gateway proxy mode, so no picker combination silently mints a CA */ /** @returns {Promise>} */ @@ -58,10 +64,43 @@ test('the picker fold leaves gateway proxy mode off for every client combination ['hermes'], ]) { const slice = gatewaySlice(compose(/** @type {PickerSource[]} */ (sources))) + assert.ok(!('proxy_mode' in slice), `picker(${sources.join('+')}) writes no proxy_mode key`) assert.equal(compileConfig(slice).proxyMode, false, `picker(${sources.join('+')})`) } }) +// The rule itself, still generic for any client captured by the proxy: a row +// that declares the flag composes the key, which is the half of LLP 0243 that +// LLP 0262 leaves standing. +// @ref LLP 0243#composed-default [tests]: a declaring row still turns the composed gateway into a proxy-mode gateway +test('a picker row that declares gateway_proxy_mode still composes the key', async () => { + const descriptors = await realPickerDescriptors() + /** @type {any} */ + const proxyRow = { + id: 'proxyclient', + label: 'Proxy Client', + plugin: '@acme/proxy-client', + compose: { + plugin: { name: '@acme/proxy-client' }, + requires_gateway: true, + gateway_proxy_mode: true, + }, + } + const withRow = new Map(descriptors) + withRow.set('proxyclient', proxyRow) + + const composed = composePickerConfig({ + sources: /** @type {PickerSource[]} */ (/** @type {unknown} */ (['proxyclient'])), + descriptors: withRow, + exportChoice: 'local-parquet', + retentionDays: 30, + hypHome: '/home/tester/.hyp', + }) + const slice = gatewaySlice(composed) + assert.equal(slice.proxy_mode, true) + assert.equal(compileConfig(slice).proxyMode, true) +}) + test('the claude-and-otel-local preset omits proxy_mode', async () => { const hypHome = await fs.mkdtemp(path.join(os.tmpdir(), 'hyp-init-proxy-default-')) try { diff --git a/test/core/status-proxy-trust.test.js b/test/core/status-proxy-trust.test.js index 36c1cf914..0b1d41cd9 100644 --- a/test/core/status-proxy-trust.test.js +++ b/test/core/status-proxy-trust.test.js @@ -87,6 +87,11 @@ test('hyp status reports the trust state alongside the CA fingerprint, and the l assert.ok(text.includes(`ca fingerprint: ${ca.fingerprint}`), 'the fingerprint is on the text surface') assert.match(text, /login keychain: trusted\n/) assert.match(text, /launchd env: {4}NODE_USE_SYSTEM_CA=1 set\n/) + // Both are residue on an otel-attached machine: nothing installs them any + // more, so the block names the one command that acts on what it reports. + assert.match(text, /note: {11}only proxy_mode capture uses this CA/) + assert.match(text, /hyp client detach claude --purge/) + assert.doesNotMatch(text, /hyp client attach claude/) const json = renderStatusJson({ report, @@ -192,8 +197,11 @@ test('a permitted host carrying terminal control bytes cannot repaint hyp status // The state this exists for: the dialog was cancelled, or a re-mint stranded // the trust. Capture keeps working, so nothing else in the report degrades and -// only this line can say so. -test('a CA the keychain does not trust is stated, with the repair, on both surfaces', async () => { +// only this line can say so. Since LLP 0262 no attach installs either +// mechanism, so neither line may name `hyp client attach claude` as the repair: the +// only command that acts on what this block reports is the purge. +// @ref LLP 0262#migration [tests]: the trust block names the purge, never an attach that would not touch the keychain +test('a CA the keychain does not trust is stated, without an attach that cannot fix it', async () => { const { hypHome, stateRoot } = await makeHome() try { const ca = await ensureLocalCa({ stateRoot, hosts: ['api.anthropic.com'] }) @@ -206,8 +214,10 @@ test('a CA the keychain does not trust is stated, with the repair, on both surfa const text = renderText(report, path.join(stateRoot, 'cache')) assert.ok(text.includes(`ca fingerprint: ${ca.fingerprint}`)) - assert.match(text, /login keychain: not trusted - .*hyp client attach claude/) - assert.match(text, /launchd env: {4}NODE_USE_SYSTEM_CA not set - .*hyp client attach claude/) + assert.match(text, /login keychain: not trusted\n/) + assert.match(text, /launchd env: {4}NODE_USE_SYSTEM_CA not set\n/) + assert.doesNotMatch(text, /hyp client attach claude/, 'attach installs neither mechanism now') + assert.match(text, /hyp client detach claude --purge/) // The report is still healthy: capture works without keychain trust // (LLP 0237#attach-anyway-on-refusal), which is exactly why the line has // to exist. From 28ed88f7bca3bfd4237ee52165f978fcea16fa8c Mon Sep 17 00:00:00 2001 From: test Date: Wed, 19 Aug 2026 05:15:11 +0000 Subject: [PATCH 2/7] Review fixes: the proxy-trust note must not tell a live proxy_mode install to purge its CA Review of e1ea1a2b found the new `hyp status` proxy-trust note is unconditional, while `proxy_mode: true` still has the gateway mint and present that CA on every start (ai-gateway/src/source.js prepareInterception). On such an install the note read "only proxy_mode capture uses this CA, and the claude attach no longer does" and told the user to run `hyp detach claude --purge`, which deletes the key the running interception terminates TLS with; the next start mints a different one and nothing in the product restores the keychain trust. - ProxyTrustReport carries `proxyModeConfigured`, read off the effective gateway entry, because neither probe can tell a live CA from residue. - The note branches on it: residue keeps the purge, a live proxy_mode gateway is told what still depends on the CA and given nothing to run. `--json` gains `proxy_mode_configured` alongside. - The migration question no longer promises "macOS will then ask to trust the HypAware Local CA": `installCaTrust` has had no production caller since the attach went otel-only, so a yes mints the CA and stops there. - The preset comment claimed writing `proxy_mode: true` would leave the gateway logging its stale-CA warning; that warning only fires on the `!proxyMode` branch. Restated to what the key would actually cost. - PRIVACY.md and `hyp detach --help` no longer say a detach keeps the trust "so a later re-attach does not ask for your password again", which contradicts the same section's "nothing installs this CA into a system trust store any more". Co-Authored-By: Claude Opus 5 (1M context) --- docs/PRIVACY.md | 24 +++------ .../plugins-workspace/claude/src/index.js | 19 ++++--- src/core/cli/core_commands.js | 6 +-- src/core/commands/clients.js | 12 +++-- src/core/commands/status.js | 38 ++++++++----- src/core/daemon/status.js | 20 ++++++- src/core/daemon/types.d.ts | 8 +++ test/core/status-proxy-trust.test.js | 54 ++++++++++++++++++- 8 files changed, 135 insertions(+), 46 deletions(-) diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index dcda06ea7..7c2bcbae2 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -78,25 +78,13 @@ those, that trust setting is still on your account until you remove it, and `hyp status` says so. It never needed admin rights, and the machine-wide system keychain and other user accounts were never modified. -**What else macOS attach leaves behind.** The keychain root only takes -effect if `NODE_USE_SYSTEM_CA=1` is in the environment before Claude Code -starts, so attach also runs `launchctl setenv NODE_USE_SYSTEM_CA 1` and -installs a LaunchAgent at -`~/Library/LaunchAgents/com.hyperparam.hypaware.node-system-ca.plist` that -re-runs that one command at each login. What it runs is `/bin/launchctl` -itself, once, which sets the variable and exits: there is no resident -process, no HypAware code in it, and nothing is sent anywhere. It is still a -login item on your machine, and a session-wide variable that other Node -programs will also read. `hyp client detach claude` unsets the variable and -removes the agent, as do `hyp client detach claude --purge` and -`hyp daemon uninstall`. - **Its lifetime.** `hyp status` shows the fingerprint, every host the CA is -permitted to vouch for, whether the keychain still trusts it, and whether -the launchd variable is live. `hyp client detach claude` deliberately keeps -the CA and the trust in place, so re-attaching later does not ask for your -password again; `hyp client detach claude --purge` and `hyp daemon uninstall` -remove the CA and its keychain trust. +permitted to vouch for, whether the login keychain still trusts it, and +whether the historic launchd variable is live. `hyp client detach claude` deliberately leaves +the CA and any trust from an earlier release in place, because a detach is +not a statement about the certificate and no attach re-creates the grant; +`hyp client detach claude --purge` and `hyp daemon uninstall` remove the CA and its +keychain trust. ## Where it goes diff --git a/hypaware-core/plugins-workspace/claude/src/index.js b/hypaware-core/plugins-workspace/claude/src/index.js index f027d75ea..107015237 100644 --- a/hypaware-core/plugins-workspace/claude/src/index.js +++ b/hypaware-core/plugins-workspace/claude/src/index.js @@ -550,12 +550,19 @@ async function runClaudeAndOtelLocalPreset(argv, ctx) { // @ref LLP 0114#init-writes-no-listen [implements]: the preset leaves listen unset so the default install keeps its fallback { name: '@hypaware/ai-gateway', - // Claude still requires the gateway capability as its normalized - // exchange writer, but its OTEL attach sends no model traffic through - // the gateway and therefore composes neither proxy mode nor an - // Anthropic upstream. - // @ref LLP 0262#capture [implements]: OTEL is the producer and the gateway capability remains the projection seam - config: { upstreams: [] }, + config: { + // No `proxy_mode`: this preset writes literally what the picker fold + // composes, and the claude row stopped declaring + // `gateway_proxy_mode` when its attach became otel-only. Writing the + // key here would mint a CA on first boot that nothing in this + // install ever presents, and hand `hyp status` a proxy-trust block + // reporting keychain and launchd state no attach on this machine + // can ever change. + // Claude still requires the gateway capability as its normalized + // exchange writer, but OTEL sends no model traffic through it. + // @ref LLP 0262#capture [implements]: OTEL is the producer and the gateway capability remains the projection seam + upstreams: [], + }, }, { name: '@hypaware/otel', diff --git a/src/core/cli/core_commands.js b/src/core/cli/core_commands.js index c9c9a866b..697a7b0a6 100644 --- a/src/core/cli/core_commands.js +++ b/src/core/cli/core_commands.js @@ -401,9 +401,9 @@ function buildCoreCommands(registry) { '', 'Detaching stops future capture for that client; it does not delete', 'anything already recorded (see hyp privacy purge for that).', - 'A proxy-mode detach keeps the local interception CA and its keychain', - 'trust so a later re-attach needs no new password dialog; --purge', - 'removes both as well.', + 'A proxy-mode detach keeps the local interception CA and any keychain', + 'trust an earlier release was granted, because no attach re-creates', + 'either; --purge removes both as well.', '--dry-run reports what would change without writing.', ].join('\n'), aliases: ['detach', 'unattach'], diff --git a/src/core/commands/clients.js b/src/core/commands/clients.js index 355b5570a..2587bc8a2 100644 --- a/src/core/commands/clients.js +++ b/src/core/commands/clients.js @@ -912,7 +912,12 @@ async function maybeInteractiveEnableAttach({ name, ctx, parsed, enablement }) { * warning, because base-URL attach is what this install already does and * remains the working fallback. * - * @ref LLP 0244#attach-offers [implements]: one consented question, default no, naming the config write, the restart, and the coming trust dialog + * The question names the config write and the restart, and says where the CA + * is trusted. It no longer promises a macOS trust dialog: nothing in the tree + * calls `installCaTrust` since the claude attach went otel-only, so a yes + * mints the CA and stops there. + * + * @ref LLP 0244#attach-offers [implements]: one consented question, default no, naming the config write and the restart * @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 * @param {{ name: string, ctx: CommandRunContext, parsed: { client: string, dryRun: boolean, json: boolean } }} args @@ -1004,8 +1009,9 @@ async function maybeOfferProxyModeMigration({ name, ctx, parsed }) { ctx, `${capitalizeClientLabel(name)} can attach through HypAware's local HTTPS proxy instead of a ` + `repointed base URL, which keeps Remote Control working. Switching writes proxy_mode ` + - `into the local config and restarts the daemon; macOS will then ask to trust the ` + - `HypAware Local CA. Switch this install to proxy mode now? [y/N] ` + `into the local config and restarts the daemon, which mints the HypAware Local CA; ` + + `nothing adds that CA to a system trust store, so the client trusts it through its own ` + + `settings. Switch this install to proxy mode now? [y/N] ` ) if (!accepted) { ctx.stderr.write( diff --git a/src/core/commands/status.js b/src/core/commands/status.js index bdbd9ad87..72dc8c014 100644 --- a/src/core/commands/status.js +++ b/src/core/commands/status.js @@ -513,6 +513,9 @@ export function renderStatusJson({ report, clientNames, datasets, cacheRoot }) { permitted_hosts: report.proxyTrust.hosts, ca_trusted: report.proxyTrust.trusted, launchd_env_set: report.proxyTrust.launchdEnvSet, + // Whether the CA is live or residue, which neither probe above can + // answer and which decides whether purging it is safe. + proxy_mode_configured: report.proxyTrust.proxyModeConfigured, } : null, diagnostics: report.diagnostics.map((d) => ({ @@ -748,19 +751,30 @@ export function renderStatusText({ report, clientNames, datasets, cacheRoot, std stdout.write(` permitted: ${describePermittedHosts(report.proxyTrust.hosts)}\n`) stdout.write(` login keychain: ${describeCaTrust(report.proxyTrust.trusted)}\n`) stdout.write(` launchd env: ${describeLaunchdEnv(report.proxyTrust.launchdEnvSet)}\n`) - // The block only ever renders on a CA a past proxy attach minted, and - // since LLP 0262 the claude attach neither installs nor reads either - // mechanism. So the block names the one command that acts on what it - // reports instead of leaving three facts with no verb. Unconditional: - // the CA on disk is itself something the purge removes, whatever the two - // probes answered. + // Three facts with no verb is what this block used to be, and since + // LLP 0262 the claude attach neither installs nor reads either mechanism, + // so nothing else on the machine names what acts on them. What the note + // may say depends on whether the CA is live: `proxy_mode: true` has the + // gateway re-mint and present it on every start, so a purge there is not + // tidying up, it is deleting the key the running interception terminates + // TLS with. Residue gets the purge; live proxy capture gets told what + // still depends on the CA and nothing to run. // @ref LLP 0262#migration [implements]: the CA and any trust it was granted outlive the migration and end at `detach --purge`, never at another attach - stdout.write( - ' note: only proxy_mode capture uses this CA, and the claude attach no longer does\n' - ) - stdout.write( - " 'hyp client detach claude --purge' removes the CA, its keychain trust, and the launchd env\n" - ) + if (report.proxyTrust.proxyModeConfigured) { + stdout.write( + ' note: proxy_mode is on, so the gateway still terminates TLS with this CA\n' + ) + stdout.write( + ' the claude attach does not use it; turn proxy_mode off before purging\n' + ) + } else { + stdout.write( + ' note: only proxy_mode capture uses this CA, and no plugin here is configured for it\n' + ) + stdout.write( + " 'hyp client detach claude --purge' removes the CA, its keychain trust, and the launchd env\n" + ) + } } stdout.write(` cache: ${cacheRoot}\n`) diff --git a/src/core/daemon/status.js b/src/core/daemon/status.js index b058014d0..bd2482632 100644 --- a/src/core/daemon/status.js +++ b/src/core/daemon/status.js @@ -1867,6 +1867,7 @@ export async function collectHypAwareStatus(opts = {}) { const proxyTrust = await collectProxyTrust({ platform, stateRoot, + config, isCaTrustedFn: opts.isCaTrusted ?? ((args) => probeCaTrusted({ ...args, timeoutMs: TRUST_PROBE_TIMEOUT_MS })), isLaunchdEnvSetFn: opts.isLaunchdEnvSet @@ -1988,9 +1989,15 @@ const TRUST_PROBE_TIMEOUT_MS = 5_000 * that names the same grant, and applied here at collection like every other * label in this file so `--json` carries exactly what was printed. * + * The effective config rides along because the CA alone cannot say whether + * it is live or residue: `proxy_mode: true` makes the gateway re-mint and + * present it on every start, and that is the difference between "this file + * is safe to purge" and "purging this file breaks the running interception". + * * @param {object} args * @param {NodeJS.Platform} args.platform * @param {string} args.stateRoot + * @param {HypAwareV2Config | null} args.config * @param {(args: { certPath: string }) => Promise} args.isCaTrustedFn * @param {() => Promise} args.isLaunchdEnvSetFn * @returns {Promise} @@ -1998,7 +2005,7 @@ const TRUST_PROBE_TIMEOUT_MS = 5_000 * @ref LLP 0238#consequences [implements]: hyp status names all permitted hosts, so a grant wider than the configured providers stays informed * @ref LLP 0239#terminals-predating-attach [implements]: hyp status reports whether the variable is present in the launchd environment */ -async function collectProxyTrust({ platform, stateRoot, isCaTrustedFn, isLaunchdEnvSetFn }) { +async function collectProxyTrust({ platform, stateRoot, config, isCaTrustedFn, isLaunchdEnvSetFn }) { if (platform !== 'darwin') return null /** @type {LocalCaInfo | undefined} */ let ca @@ -2025,7 +2032,16 @@ async function collectProxyTrust({ platform, stateRoot, isCaTrustedFn, isLaunchd /** @type {boolean | null} */ const launchdEnvSet = launchdResult.status === 'fulfilled' ? launchdResult.value : null - return { caFingerprint: ca.fingerprint, hosts: displayableCaHosts(ca.hosts), trusted, launchdEnvSet } + const gateway = (config?.plugins ?? []).find((entry) => entry.name === GATEWAY_PLUGIN_NAME) + const proxyModeConfigured = gateway?.config?.proxy_mode === true + + return { + caFingerprint: ca.fingerprint, + hosts: displayableCaHosts(ca.hosts), + trusted, + launchdEnvSet, + proxyModeConfigured, + } } /** diff --git a/src/core/daemon/types.d.ts b/src/core/daemon/types.d.ts index c4531bf06..19ea9439f 100644 --- a/src/core/daemon/types.d.ts +++ b/src/core/daemon/types.d.ts @@ -396,6 +396,14 @@ export interface ProxyTrustReport { trusted: boolean | null /** `launchctl getenv NODE_USE_SYSTEM_CA` is `1`, or null when it could not run. */ launchdEnvSet: boolean | null + /** + * Whether the effective gateway entry still carries `proxy_mode: true`. + * The two probes above cannot tell a CA in live use from residue an old + * proxy attach left behind, and the advice differs completely: a + * proxy-mode gateway re-mints this CA on every start and terminates TLS + * with it, so purging it breaks capture rather than tidying it up. + */ + proxyModeConfigured: boolean } export interface HypAwareStatusReport { diff --git a/test/core/status-proxy-trust.test.js b/test/core/status-proxy-trust.test.js index 0b1d41cd9..f41640a14 100644 --- a/test/core/status-proxy-trust.test.js +++ b/test/core/status-proxy-trust.test.js @@ -80,6 +80,7 @@ test('hyp status reports the trust state alongside the CA fingerprint, and the l hosts: ['api.anthropic.com'], trusted: true, launchdEnvSet: true, + proxyModeConfigured: false, }) const text = renderText(report, path.join(stateRoot, 'cache')) @@ -87,8 +88,9 @@ test('hyp status reports the trust state alongside the CA fingerprint, and the l assert.ok(text.includes(`ca fingerprint: ${ca.fingerprint}`), 'the fingerprint is on the text surface') assert.match(text, /login keychain: trusted\n/) assert.match(text, /launchd env: {4}NODE_USE_SYSTEM_CA=1 set\n/) - // Both are residue on an otel-attached machine: nothing installs them any - // more, so the block names the one command that acts on what it reports. + // Both are residue on an otel-attached machine with no proxy_mode gateway: + // nothing installs them any more, so the block names the one command that + // acts on what it reports. assert.match(text, /note: {11}only proxy_mode capture uses this CA/) assert.match(text, /hyp client detach claude --purge/) assert.doesNotMatch(text, /hyp client attach claude/) @@ -104,6 +106,7 @@ test('hyp status reports the trust state alongside the CA fingerprint, and the l permitted_hosts: ['api.anthropic.com'], ca_trusted: true, launchd_env_set: true, + proxy_mode_configured: false, }) } finally { await fs.rm(hypHome, { recursive: true, force: true }) @@ -236,6 +239,53 @@ test('a CA the keychain does not trust is stated, without an attach that cannot } }) +// A `proxy_mode: true` gateway re-mints and presents this CA on every start, +// so the purge the block names for residue would delete the key the running +// interception terminates TLS with, and the next start would mint a different +// one. The note must not offer it there. +// @ref LLP 0262#migration [tests]: the purge is named for a CA nothing here uses, never for one a live proxy_mode gateway depends on +test('a live proxy_mode gateway is never told to purge the CA it is using', async () => { + const { hypHome, stateRoot } = await makeHome() + try { + await fs.writeFile( + defaultConfigPath(hypHome), + JSON.stringify({ + version: 2, + plugins: [{ + name: '@hypaware/ai-gateway', + config: { + proxy_mode: true, + upstreams: [{ + name: 'anthropic', + base_url: 'https://api.anthropic.com', + path_prefix: '/v1/messages', + }], + }, + }], + }) + '\n' + ) + await ensureLocalCa({ stateRoot, hosts: ['api.anthropic.com'] }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + assert.equal(report.proxyTrust?.proxyModeConfigured, true) + + const text = renderText(report, path.join(stateRoot, 'cache')) + assert.match(text, /proxy_mode is on, so the gateway still terminates TLS with this CA/) + assert.doesNotMatch(text, /hyp client detach claude --purge/, 'the purge would break live capture') + assert.doesNotMatch(text, /hyp client attach claude/) + + const json = renderStatusJson({ + report, + clientNames: [], + datasets: [], + cacheRoot: path.join(stateRoot, 'cache'), + }) + assert.equal(json.proxy_trust?.proxy_mode_configured, true) + } finally { + await fs.rm(hypHome, { recursive: true, force: true }) + } +}) + // `--json` is a machine copy, so a probe that could not run must not be // rounded to `false`: "the dialog was cancelled" and "`security` did not run" // are different answers and only the first is actionable. From 19bae259f7db05b495411a4cdf64bc152062a27a Mon Sep 17 00:00:00 2001 From: neutral Date: Wed, 19 Aug 2026 06:18:20 +0000 Subject: [PATCH 3/7] Review fixes: the proxy-trust note must not guess a client, hide the detach, or read an unreadable config as proxy_mode off --- src/core/commands/status.js | 28 +++++++++-- src/core/daemon/status.js | 18 +++++++- src/core/daemon/types.d.ts | 8 ++-- test/core/status-proxy-trust.test.js | 69 ++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 9 deletions(-) diff --git a/src/core/commands/status.js b/src/core/commands/status.js index 72dc8c014..25a204aaa 100644 --- a/src/core/commands/status.js +++ b/src/core/commands/status.js @@ -514,7 +514,9 @@ export function renderStatusJson({ report, clientNames, datasets, cacheRoot }) { ca_trusted: report.proxyTrust.trusted, launchd_env_set: report.proxyTrust.launchdEnvSet, // Whether the CA is live or residue, which neither probe above can - // answer and which decides whether purging it is safe. + // answer and which decides whether purging it is safe. Tri-state like + // its siblings: `null` means the config could not be read, which a + // consumer must not read as "proxy_mode is off". proxy_mode_configured: report.proxyTrust.proxyModeConfigured, } : null, @@ -758,14 +760,29 @@ export function renderStatusText({ report, clientNames, datasets, cacheRoot, std // gateway re-mint and present it on every start, so a purge there is not // tidying up, it is deleting the key the running interception terminates // TLS with. Residue gets the purge; live proxy capture gets told what - // still depends on the CA and nothing to run. + // still depends on the CA and nothing to run; a config we could not read + // gets neither, because "cannot tell" is not "safe to delete". + // + // The live branch names no client: `proxy_mode` is retained for codex, + // claude-desktop, openclaw, hermes, and raw SDK traffic, so the client + // this CA is actually serving here is not knowable from the trust state. + // The residue branch has to name one, because `hyp client detach` takes a + // client; that command detaches it as well as purging, which the note + // states rather than leaving the user to discover it. // @ref LLP 0262#migration [implements]: the CA and any trust it was granted outlive the migration and end at `detach --purge`, never at another attach - if (report.proxyTrust.proxyModeConfigured) { + if (report.proxyTrust.proxyModeConfigured === null) { + stdout.write( + ' note: the config could not be read, so whether this CA is live or residue is unknown\n' + ) + stdout.write( + ' fix the config first: purging a CA a proxy_mode gateway presents breaks capture\n' + ) + } else if (report.proxyTrust.proxyModeConfigured) { stdout.write( ' note: proxy_mode is on, so the gateway still terminates TLS with this CA\n' ) stdout.write( - ' the claude attach does not use it; turn proxy_mode off before purging\n' + ' the clients captured through the proxy need it; turn proxy_mode off before purging\n' ) } else { stdout.write( @@ -774,6 +791,9 @@ export function renderStatusText({ report, clientNames, datasets, cacheRoot, std stdout.write( " 'hyp client detach claude --purge' removes the CA, its keychain trust, and the launchd env\n" ) + stdout.write( + ' it detaches claude on the way, so re-attach it afterwards to keep capturing\n' + ) } } diff --git a/src/core/daemon/status.js b/src/core/daemon/status.js index bd2482632..8c30c768c 100644 --- a/src/core/daemon/status.js +++ b/src/core/daemon/status.js @@ -2032,8 +2032,22 @@ async function collectProxyTrust({ platform, stateRoot, config, isCaTrustedFn, i /** @type {boolean | null} */ const launchdEnvSet = launchdResult.status === 'fulfilled' ? launchdResult.value : null - const gateway = (config?.plugins ?? []).find((entry) => entry.name === GATEWAY_PLUGIN_NAME) - const proxyModeConfigured = gateway?.config?.proxy_mode === true + // Tri-state for the same reason the two probes above are: a plain `false` + // sends the caller's note to "this CA is residue, purge it", which is the + // wrong advice for a machine whose gateway is still intercepting. `config` + // is null when the local config would not parse and no central layer covers + // for it, and a config we could not read is not a config with `proxy_mode` + // off. A disabled gateway entry is skipped for the same reason + // `activePlugins` skips it: it is not what runs. + /** @type {boolean | null} */ + const proxyModeConfigured = config === null + ? null + : (config.plugins ?? []).some( + (entry) => + entry.name === GATEWAY_PLUGIN_NAME && + entry.enabled !== false && + entry.config?.proxy_mode === true + ) return { caFingerprint: ca.fingerprint, diff --git a/src/core/daemon/types.d.ts b/src/core/daemon/types.d.ts index 19ea9439f..cb6f4b3dd 100644 --- a/src/core/daemon/types.d.ts +++ b/src/core/daemon/types.d.ts @@ -397,13 +397,15 @@ export interface ProxyTrustReport { /** `launchctl getenv NODE_USE_SYSTEM_CA` is `1`, or null when it could not run. */ launchdEnvSet: boolean | null /** - * Whether the effective gateway entry still carries `proxy_mode: true`. + * Whether an enabled gateway entry in the effective config still carries + * `proxy_mode: true`, or null when the config could not be read at all. * The two probes above cannot tell a CA in live use from residue an old * proxy attach left behind, and the advice differs completely: a * proxy-mode gateway re-mints this CA on every start and terminates TLS - * with it, so purging it breaks capture rather than tidying it up. + * with it, so purging it breaks capture rather than tidying it up. Null is + * "cannot tell", which a consumer must not read as "proxy_mode is off". */ - proxyModeConfigured: boolean + proxyModeConfigured: boolean | null } export interface HypAwareStatusReport { diff --git a/test/core/status-proxy-trust.test.js b/test/core/status-proxy-trust.test.js index f41640a14..e45da91d9 100644 --- a/test/core/status-proxy-trust.test.js +++ b/test/core/status-proxy-trust.test.js @@ -94,6 +94,10 @@ test('hyp status reports the trust state alongside the CA fingerprint, and the l assert.match(text, /note: {11}only proxy_mode capture uses this CA/) assert.match(text, /hyp client detach claude --purge/) assert.doesNotMatch(text, /hyp client attach claude/) + // The command named is not a CA-only purge: it runs the full detach for + // `claude` first, which strips the managed `env` block that is this + // machine's whole capture path. Advice that ends capture has to say so. + assert.match(text, /it detaches claude on the way, so re-attach it afterwards to keep capturing/) const json = renderStatusJson({ report, @@ -273,6 +277,10 @@ test('a live proxy_mode gateway is never told to purge the CA it is using', asyn assert.match(text, /proxy_mode is on, so the gateway still terminates TLS with this CA/) assert.doesNotMatch(text, /hyp client detach claude --purge/, 'the purge would break live capture') assert.doesNotMatch(text, /hyp client attach claude/) + // proxy_mode is retained for codex, claude-desktop, openclaw, hermes and + // raw SDK traffic (LLP 0262 #migration), so the trust state cannot say + // which client this CA is serving and the note must not guess one. + assert.doesNotMatch(text, /the claude attach does not use it/) const json = renderStatusJson({ report, @@ -286,6 +294,67 @@ test('a live proxy_mode gateway is never told to purge the CA it is using', asyn } }) +// A gateway entry the config disables is not what runs, so its `proxy_mode` +// key does not make this CA live. Reading it as live would withhold the purge +// from the one machine where purging is unambiguously safe. +test('a disabled gateway entry does not make the CA live', async () => { + const { hypHome, stateRoot } = await makeHome() + try { + await fs.writeFile( + defaultConfigPath(hypHome), + JSON.stringify({ + version: 2, + plugins: [{ + name: '@hypaware/ai-gateway', + enabled: false, + config: { proxy_mode: true }, + }], + }) + '\n' + ) + await ensureLocalCa({ stateRoot, hosts: ['api.anthropic.com'] }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + assert.equal(report.proxyTrust?.proxyModeConfigured, false) + + const text = renderText(report, path.join(stateRoot, 'cache')) + assert.match(text, /hyp client detach claude --purge/) + } finally { + await fs.rm(hypHome, { recursive: true, force: true }) + } +}) + +// The population this protects: a config the user has just broken, on a +// machine whose gateway is still running `proxy_mode: true` from its last +// good boot. `config` is null there, and rounding that to "proxy_mode is off" +// would hand exactly that machine the purge for the CA its live interception +// terminates TLS with. +// @ref LLP 0262#migration [tests]: the purge is offered for a CA nothing here uses, and a config we cannot read is not proof of that +test('a config that will not parse is reported as unknown, not as proxy_mode off', async () => { + const { hypHome, stateRoot } = await makeHome() + try { + await fs.writeFile(defaultConfigPath(hypHome), '{ this is not json\n') + await ensureLocalCa({ stateRoot, hosts: ['api.anthropic.com'] }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + assert.equal(report.proxyTrust?.proxyModeConfigured, null) + + const text = renderText(report, path.join(stateRoot, 'cache')) + assert.match(text, /whether this CA is live or residue is unknown/) + assert.doesNotMatch(text, /hyp client detach claude --purge/, 'the CA may be in live use') + assert.doesNotMatch(text, /hyp client attach claude/) + + const json = renderStatusJson({ + report, + clientNames: [], + datasets: [], + cacheRoot: path.join(stateRoot, 'cache'), + }) + assert.equal(json.proxy_trust?.proxy_mode_configured, null) + } finally { + await fs.rm(hypHome, { recursive: true, force: true }) + } +}) + // `--json` is a machine copy, so a probe that could not run must not be // rounded to `false`: "the dialog was cancelled" and "`security` did not run" // are different answers and only the first is actionable. From 07138b8d04e3796b55c896be006be3c74c29353c Mon Sep 17 00:00:00 2001 From: neutral Date: Wed, 19 Aug 2026 08:20:01 +0000 Subject: [PATCH 4/7] README's proxy-mode section still promises a keychain trust step no attach performs (#923) LLP 0262 moved Claude Code off proxy capture and took the last production caller of `installCaTrust` with it: nothing in the tree installs the interception CA into a trust store any more. The README's "Proxy mode" section was outside that change's diff and still described the removed step in the present tense, including the claim that a detach keeps the keychain trust "so re-attaching does not ask again". That last sentence is the one that costs the reader something. It reads the keychain trust an older release left on their account as a convenience being held for them, when it is a leftover nothing re-creates and only `hyp detach --purge` or `hyp daemon uninstall` clears. The section now says trust is file-scoped to the proxied client's own settings, puts the keychain grant in the past tense as something earlier releases did, and tells a reader who ran one of those that the trust setting is still on their account until they remove it. `test/core/readme-proxy-trust-claims.test.js` is the gate: it pairs the fact that no production module outside `src/core/tls/darwin_trust.js` names `installCaTrust` with the claims the README may not make while that holds. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 23 +++-- test/core/readme-proxy-trust-claims.test.js | 106 ++++++++++++++++++++ 2 files changed, 119 insertions(+), 10 deletions(-) create mode 100644 test/core/readme-proxy-trust-claims.test.js diff --git a/README.md b/README.md index ace5a896c..8eff5cfc4 100644 --- a/README.md +++ b/README.md @@ -335,16 +335,19 @@ Such a client is then pointed at the gateway with `HTTPS_PROXY` and - **A machine-local certificate authority is generated** under `~/.hyp/hypaware/tls`, readable only by you, and name-constrained so it cannot vouch for any host outside the provider set HypAware intercepts. - On macOS it can also be added to your **login keychain** as a user-domain - trusted root, for a client whose transport trusts only the keychain: macOS - raises its own password dialog, and declining it leaves capture working - with that inbound channel off. No admin rights are needed and the - machine-wide system keychain is not touched. On other platforms trust - stays file-scoped to the client's own settings. `hyp status` shows the - fingerprint, every host the CA is permitted to vouch for, and whether the - keychain still trusts it. `hyp client detach ` keeps the CA and the - trust, so re-attaching does not ask again; `hyp client detach --purge` - and `hyp daemon uninstall` remove both. + Trust stays file-scoped to the proxied client's own settings: nothing + installs it into a system trust store, and anything wider is your own + decision. Earlier releases attached Claude Code by proxy and did add the + CA to the macOS **login keychain** as a user-domain trusted root, for a + transport that trusts only the keychain; macOS raised its own password + dialog for that, it never needed admin rights, and the machine-wide system + keychain was never touched. If you ran one of those releases, that trust + setting is still on your account until you remove it. `hyp status` shows + the fingerprint, every host the CA is permitted to vouch for, and whether + the login keychain still trusts it. `hyp client detach ` leaves the CA + and any trust an earlier release was granted in place, because a detach is + not a statement about the certificate and no attach re-creates the grant; + `hyp client detach --purge` and `hyp daemon uninstall` remove both. - **On macOS, a proxy attach also leaves a login-session variable behind.** Bun picks its trust store before any settings file is read, so a keychain root only counts if `NODE_USE_SYSTEM_CA=1` is already in the process diff --git a/test/core/readme-proxy-trust-claims.test.js b/test/core/readme-proxy-trust-claims.test.js new file mode 100644 index 000000000..9e3b021c6 --- /dev/null +++ b/test/core/readme-proxy-trust-claims.test.js @@ -0,0 +1,106 @@ +// @ts-check + +// LLP 0262 moved Claude Code off proxy capture, and the last production caller +// that installed the interception CA into a trust store went with it. The +// product prose did not all move. The README's "Proxy mode" section still +// described attach adding the CA to the macOS login keychain, still described +// macOS raising a password dialog for it, and still sold a detach as sparing +// the reader that dialog on the next attach. +// +// The last one is the expensive kind of wrong: it reads the keychain trust an +// older release left on the account as a convenience being held for you, when +// it is a leftover nothing re-creates and only `hyp detach --purge` or +// `hyp daemon uninstall` clears. +// +// This is a lint over one document, not a behavior check. It pairs a fact about +// the tree (nothing outside `darwin_trust.js` can install trust any more) with +// the claims the README may not make while that fact holds, so the prose cannot +// drift back without the fact drifting back too. +// +// @ref LLP 0262#migration [tests]: attach offers the CA purge and never re-creates the keychain grant, so no document may promise one is waiting + +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') + +// Where the trust installer may still be named in production code: its own +// module, which keeps exporting it for the day another client needs it. +const TRUST_INSTALLER = 'installCaTrust' +const TRUST_INSTALLER_HOME = 'src/core/tls/darwin_trust.js' + +// Scoped to the README because it is the product document whose proxy-mode +// section LLP 0262 left behind. `docs/PRIVACY.md` and the `hyp detach` help +// text carry the same sentence and are corrected on their own change; add them +// here once they are clean, so the gate widens as the sweep does. +const SCANNED = ['README.md'] + +/** + * Claims the README may not make while nothing installs CA trust, each paired + * with what is true instead so a hit reads as an instruction, not a riddle. + * Phrases match whitespace-insensitively, so reflowing a paragraph does not + * hide one. + */ +const STALE_CLAIMS = [ + { + phrase: 'added to your **login keychain**', + truth: 'no attach installs the CA into a trust store; an earlier release did, and that trust is a leftover the reader has to purge', + }, + { + phrase: 'raises its own password dialog', + truth: 'nothing HypAware runs raises that dialog any more, so the present tense promises a step that never comes', + }, + { + phrase: 'so re-attaching does not ask again', + truth: 'no attach re-creates the grant, so a detach is not saving the reader a dialog', + }, +] + +/** + * @param {string} phrase + * @returns {RegExp} + */ +function phrasePattern(phrase) { + const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return new RegExp(escaped.replace(/\s+/g, '\\s+')) +} + +/** @returns {string[]} repo-relative production files that name the trust installer */ +function trustInstallerCallers() { + const listed = execFileSync('git', ['ls-files', '-z', '*.js'], { cwd: REPO_ROOT, encoding: 'utf8' }) + return listed + .split('\0') + .filter(file => file !== '' && !file.startsWith('test/') && file !== TRUST_INSTALLER_HOME) + .filter(file => fs.readFileSync(path.join(REPO_ROOT, file), 'utf8').includes(TRUST_INSTALLER)) +} + +/** @returns {string[]} one entry per stale claim still standing in the scanned docs */ +function staleClaims() { + /** @type {string[]} */ + const found = [] + for (const rel of SCANNED) { + const text = fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8') + for (const claim of STALE_CLAIMS) { + const match = phrasePattern(claim.phrase).exec(text) + if (!match) continue + const line = text.slice(0, match.index).split('\n').length + found.push(`${rel}:${line} "${claim.phrase}"\n true instead: ${claim.truth}`) + } + } + return found +} + +test('the README promises no CA trust step that no attach performs', () => { + assert.deepEqual( + trustInstallerCallers(), + [], + 'production code installs CA trust again: either that is the bug, or the claims this gate bans are true once more and the gate needs rewriting rather than the docs' + ) + + const found = staleClaims() + assert.deepEqual(found, [], `stale trust-store claims:\n${found.join('\n')}`) +}) From 54bde13e5238e67f9f5268c412782b8c0d254f95 Mon Sep 17 00:00:00 2001 From: test Date: Wed, 19 Aug 2026 09:19:55 +0000 Subject: [PATCH 5/7] Proxy-mode section still promises the launchd variable no attach writes (#933 review) The fix for #923 corrected the CA-trust bullet and left the bullet under it making the same stale promise: "On macOS, a proxy attach also leaves a login-session variable behind." `installLaunchdEnv` has no production caller, so no attach runs `launchctl setenv NODE_USE_SYSTEM_CA 1` or writes the login agent. The bullet's own body had already drifted to past tense, so it contradicted its own lead. A reader turning proxy mode on for codex or claude-desktop was told to expect a session-wide variable and a login item that are never created. Also: "nothing installs it into a system trust store" understated the change, since the system keychain was never touched either way. What stopped is the login-keychain install, so the sentence now names it. The guard test only banned the three sentences this branch rewrote, so a paragraph re-documented from scratch could re-assert every one of them in fresh words and stay green. It now carries both halves: the banned claims, and the sentences the README has to keep saying instead. Its tree-fact assertion covers both trust writers, since the keychain root and the launchd variable that made the root count are one grant in two halves. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 32 ++++---- test/core/readme-proxy-trust-claims.test.js | 88 ++++++++++++++++++--- 2 files changed, 92 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 8eff5cfc4..d0bfa3291 100644 --- a/README.md +++ b/README.md @@ -336,27 +336,29 @@ Such a client is then pointed at the gateway with `HTTPS_PROXY` and `~/.hyp/hypaware/tls`, readable only by you, and name-constrained so it cannot vouch for any host outside the provider set HypAware intercepts. Trust stays file-scoped to the proxied client's own settings: nothing - installs it into a system trust store, and anything wider is your own - decision. Earlier releases attached Claude Code by proxy and did add the - CA to the macOS **login keychain** as a user-domain trusted root, for a - transport that trusts only the keychain; macOS raised its own password - dialog for that, it never needed admin rights, and the machine-wide system - keychain was never touched. If you ran one of those releases, that trust - setting is still on your account until you remove it. `hyp status` shows + installs it into any OS trust store, including your login keychain, and + anything wider is your own decision. Earlier releases attached Claude + Code by proxy and did add the CA to the macOS **login keychain** as a + user-domain trusted root, for a transport that trusts only the keychain; + macOS raised its own password dialog for that, it never needed admin + rights, and the machine-wide system keychain was never touched. If you + ran one of those releases, that trust setting is still on your account + until you remove it. `hyp status` shows the fingerprint, every host the CA is permitted to vouch for, and whether the login keychain still trusts it. `hyp client detach ` leaves the CA and any trust an earlier release was granted in place, because a detach is not a statement about the certificate and no attach re-creates the grant; `hyp client detach --purge` and `hyp daemon uninstall` remove both. -- **On macOS, a proxy attach also leaves a login-session variable behind.** - Bun picks its trust store before any settings file is read, so a keychain - root only counts if `NODE_USE_SYSTEM_CA=1` is already in the process - environment. The attach that trusted the CA therefore ran `launchctl - setenv NODE_USE_SYSTEM_CA 1` and installed a small LaunchAgent, +- **On macOS, an earlier proxy attach also left a login-session variable + behind.** Bun picks its trust store before any settings file is read, so a + keychain root only counts if `NODE_USE_SYSTEM_CA=1` is already in the + process environment. The attach that trusted the CA therefore ran + `launchctl setenv NODE_USE_SYSTEM_CA 1` and installed a small LaunchAgent, `~/Library/LaunchAgents/com.hyperparam.hypaware.node-system-ca.plist`, - whose only job is to re-run that command at each login. It stays a login - item on your machine until it is removed, and it is a session-wide - variable that other Node programs read too. `launchctl setenv` reaches + whose only job is to re-run that command at each login. No attach writes + either one today; on a machine that ran one of those releases the agent + stays a login item until it is removed, and the variable stays session-wide + for other Node programs to read too. `launchctl setenv` reaches processes launched after it, so a terminal app that was already running must be fully quit and reopened. `hyp client detach ` unsets the variable and removes the agent, as do `hyp client detach --purge` and diff --git a/test/core/readme-proxy-trust-claims.test.js b/test/core/readme-proxy-trust-claims.test.js index 9e3b021c6..381e2215c 100644 --- a/test/core/readme-proxy-trust-claims.test.js +++ b/test/core/readme-proxy-trust-claims.test.js @@ -13,9 +13,9 @@ // `hyp daemon uninstall` clears. // // This is a lint over one document, not a behavior check. It pairs a fact about -// the tree (nothing outside `darwin_trust.js` can install trust any more) with -// the claims the README may not make while that fact holds, so the prose cannot -// drift back without the fact drifting back too. +// the tree (the two trust writers have no caller outside their own modules) +// with the claims the README may not make while that fact holds, so the prose +// cannot drift back without the fact drifting back too. // // @ref LLP 0262#migration [tests]: attach offers the CA purge and never re-creates the keychain grant, so no document may promise one is waiting @@ -28,10 +28,15 @@ import { fileURLToPath } from 'node:url' const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') -// Where the trust installer may still be named in production code: its own -// module, which keeps exporting it for the day another client needs it. -const TRUST_INSTALLER = 'installCaTrust' -const TRUST_INSTALLER_HOME = 'src/core/tls/darwin_trust.js' +// The two writers a proxy attach used to run, each paired with the module that +// may still name it: its own, which keeps exporting it for the day another +// client needs it. The keychain root and the launchd variable that made the +// root count are one grant in two halves, so no half may be described in the +// present tense while neither half has a caller. +const TRUST_INSTALLERS = [ + { symbol: 'installCaTrust', home: 'src/core/tls/darwin_trust.js' }, + { symbol: 'installLaunchdEnv', home: 'src/core/daemon/launchd_env.js' }, +] // Scoped to the README because it is the product document whose proxy-mode // section LLP 0262 left behind. `docs/PRIVACY.md` and the `hyp detach` help @@ -58,6 +63,35 @@ const STALE_CLAIMS = [ phrase: 'so re-attaching does not ask again', truth: 'no attach re-creates the grant, so a detach is not saving the reader a dialog', }, + { + phrase: 'a proxy attach also leaves a login-session variable behind', + truth: 'no attach runs launchctl setenv NODE_USE_SYSTEM_CA 1 or installs the login agent; on a machine that ran an earlier release both are leftovers to remove', + }, +] + +/** + * The other half of the gate. A denylist catches a revert, not a rewrite: a + * paragraph re-documented from scratch could re-assert every banned claim in + * fresh words and trip nothing. So the README must also still carry the + * sentences that make the leftover explicit. Dropping one fails here even + * though it matches no banned phrase. + * + * Reword these freely, but say the same thing and update this list in the same + * commit; the list is the claim, not the wording. + */ +const REQUIRED_TRUTHS = [ + { + phrase: 'nothing installs it into any OS trust store', + why: 'the CA bullet has to say outright that no trust store is written, or a reader takes the keychain install to be current', + }, + { + phrase: 'that trust setting is still on your account until you remove it', + why: 'the keychain grant an earlier release was given is a leftover to purge, and this is where the reader learns it is theirs to clear', + }, + { + phrase: 'No attach writes either one today', + why: 'the launchd variable and its login agent are leftovers on the same terms, and the bullet says so only while this sentence survives', + }, ] /** @@ -69,13 +103,24 @@ function phrasePattern(phrase) { return new RegExp(escaped.replace(/\s+/g, '\\s+')) } -/** @returns {string[]} repo-relative production files that name the trust installer */ +/** @returns {string[]} one ` names ` entry per production caller of a trust writer */ function trustInstallerCallers() { const listed = execFileSync('git', ['ls-files', '-z', '*.js'], { cwd: REPO_ROOT, encoding: 'utf8' }) - return listed + const candidates = listed .split('\0') - .filter(file => file !== '' && !file.startsWith('test/') && file !== TRUST_INSTALLER_HOME) - .filter(file => fs.readFileSync(path.join(REPO_ROOT, file), 'utf8').includes(TRUST_INSTALLER)) + .filter(file => file !== '' && !file.startsWith('test/')) + // A tracked path can be absent from the working tree mid-rebase. That is + // not a caller, and this gate must not die on it. + .filter(file => fs.existsSync(path.join(REPO_ROOT, file))) + /** @type {string[]} */ + const callers = [] + for (const file of candidates) { + const text = fs.readFileSync(path.join(REPO_ROOT, file), 'utf8') + for (const { symbol, home } of TRUST_INSTALLERS) { + if (file !== home && text.includes(symbol)) callers.push(`${file} names ${symbol}`) + } + } + return callers } /** @returns {string[]} one entry per stale claim still standing in the scanned docs */ @@ -94,13 +139,30 @@ function staleClaims() { return found } -test('the README promises no CA trust step that no attach performs', () => { +/** @returns {string[]} one entry per required truth the scanned docs no longer state */ +function missingTruths() { + /** @type {string[]} */ + const missing = [] + for (const rel of SCANNED) { + const text = fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8') + for (const truth of REQUIRED_TRUTHS) { + if (phrasePattern(truth.phrase).test(text)) continue + missing.push(`${rel} "${truth.phrase}"\n why it has to stay: ${truth.why}`) + } + } + return missing +} + +test('the README promises no trust step that no attach performs', () => { assert.deepEqual( trustInstallerCallers(), [], - 'production code installs CA trust again: either that is the bug, or the claims this gate bans are true once more and the gate needs rewriting rather than the docs' + 'production code installs proxy trust again: either that is the bug, or the claims this gate bans are true once more and the gate needs rewriting rather than the docs' ) const found = staleClaims() assert.deepEqual(found, [], `stale trust-store claims:\n${found.join('\n')}`) + + const missing = missingTruths() + assert.deepEqual(missing, [], `the README dropped what it has to say instead:\n${missing.join('\n')}`) }) From dcff8c24c2bc9a6d1c9c63edb5a8599ce513ffad Mon Sep 17 00:00:00 2001 From: test Date: Wed, 19 Aug 2026 10:36:02 +0000 Subject: [PATCH 6/7] The trust-leftover correction stopped at the README (#933 review) The proxy-mode rewrite told README readers that no attach installs the interception CA into an OS trust store, and left `docs/PRIVACY.md` and `hyp detach --help` asserting the opposite in the present tense. The privacy document is where a reader checks what touched their machine and the help text is read at the moment of deciding, so those two were the worse copies to defer. - docs/PRIVACY.md: the keychain install, the password dialog, and the `launchctl setenv` step move to the past tense, and the lifetime paragraph stops selling a plain detach as holding the grant open for a re-attach that never asks again. - src/core/cli/core_commands.js: `hyp detach --help` says the kept trust is a leftover no attach re-creates, not a spared dialog. - README.md: `hyp detach ` does not unconditionally clear the launchd variable and its login agent. `releaseProxyModeLaunchdEnv` returns early unless the marker still records a proxy attach, so on a machine migrated to another attach mode (the machine the bullet is about) a plain detach is a no-op for the leftover. Only `--purge` and `hyp daemon uninstall` run the release unconditionally. The gate grows to match. Its claim lists are now per document, because the three say the same thing in three registers and the README's sentences read as non-sequiturs in a terminal help string; the old shared `REQUIRED_TRUTHS` was applied as a cross-product over `SCANNED`, so the widening the file invited would have demanded the README's verbatim prose of every document that joined. The installer scan is word-bounded so a future `installCaTrustForHost` is not read as this one, and its failure message says a module *names* a writer rather than accusing it of installing trust, which is all a text scan can prove. Renamed off `readme-` now that it covers three files. --- README.md | 12 +- docs/PRIVACY.md | 48 ++-- src/core/cli/core_commands.js | 7 +- test/core/proxy-trust-claims.test.js | 236 ++++++++++++++++++++ test/core/readme-proxy-trust-claims.test.js | 168 -------------- 5 files changed, 281 insertions(+), 190 deletions(-) create mode 100644 test/core/proxy-trust-claims.test.js delete mode 100644 test/core/readme-proxy-trust-claims.test.js diff --git a/README.md b/README.md index d0bfa3291..8a322dad4 100644 --- a/README.md +++ b/README.md @@ -360,11 +360,13 @@ Such a client is then pointed at the gateway with `HTTPS_PROXY` and stays a login item until it is removed, and the variable stays session-wide for other Node programs to read too. `launchctl setenv` reaches processes launched after it, so a terminal app that was already running - must be fully quit and reopened. `hyp client detach ` unsets the - variable and removes the agent, as do `hyp client detach --purge` and - `hyp daemon uninstall`; `hyp client attach claude` unwinds it when it migrates a - previously proxied machine; and `hyp status` shows whether the variable is - currently live. + must be fully quit and reopened. `hyp client detach --purge` and + `hyp daemon uninstall` clear both unconditionally, and `hyp client attach claude` + unwinds them when it migrates a previously proxied machine. A plain + `hyp client detach ` only clears them while that client's attach marker still + records a proxy attach, so on a machine already migrated to another attach + mode it is not the command that removes the leftover. `hyp status` shows + whether the variable is currently live. - **Only the hosts a registered upstream names are decrypted.** Every other host the client talks to is tunnelled through without being decrypted. - **What gets recorded does not change.** Only the recorded API paths are diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 7c2bcbae2..4c244cfa0 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -69,22 +69,42 @@ outside the provider set HypAware's client adapters intercept (today `api.anthropic.com`, `api.openai.com`, `chatgpt.com`). All IP addresses are excluded. -**Where it is trusted.** Nothing installs this CA into a system trust store -any more. A proxied client trusts it through that client's own settings, and -you decide anything wider yourself. Earlier releases attached Claude Code by -proxy and did add the CA to your macOS **login keychain** as a user-domain -trusted root (macOS raised its own password dialog for it); if you ran one of -those, that trust setting is still on your account until you remove it, and -`hyp status` says so. It never needed admin rights, and the machine-wide -system keychain and other user accounts were never modified. +**Where it is trusted.** Trust stays file-scoped to the proxied client's own +settings: nothing HypAware runs installs the CA into an OS trust store, +including your login keychain, and anything wider is your own decision. +Earlier releases attached Claude Code by proxy and did install it into your +**login keychain** as a user-domain trusted root, because that client's +Remote Control transport trusted only the keychain and nothing else. That +changed your account's certificate trust settings, which is why macOS itself +raised the password dialog: an application running as you that consults the +login keychain will accept certificates this CA signs, for those hosts. +Declining the dialog was supported and capture kept working without it, with +only Remote Control's inbound channel lost. The change never needed admin +rights, and the machine-wide system keychain and other user accounts were +never modified. If you ran one of those releases, that trust setting is +still on your account until you remove it. + +**What else an earlier macOS attach left behind.** The keychain root only +took effect if `NODE_USE_SYSTEM_CA=1` was in the environment before Claude +Code started, so that attach also ran `launchctl setenv NODE_USE_SYSTEM_CA 1` +and installed a LaunchAgent at +`~/Library/LaunchAgents/com.hyperparam.hypaware.node-system-ca.plist` that +re-runs that one command at each login. What it runs is `/bin/launchctl` +itself, once, which sets the variable and exits: there is no resident +process, no HypAware code in it, and nothing is sent anywhere. No attach +writes either one today. On a machine that ran one of those releases it is +still a login item, and still a session-wide variable that other Node +programs will also read. **Its lifetime.** `hyp status` shows the fingerprint, every host the CA is -permitted to vouch for, whether the login keychain still trusts it, and -whether the historic launchd variable is live. `hyp client detach claude` deliberately leaves -the CA and any trust from an earlier release in place, because a detach is -not a statement about the certificate and no attach re-creates the grant; -`hyp client detach claude --purge` and `hyp daemon uninstall` remove the CA and its -keychain trust. +permitted to vouch for, whether the keychain still trusts it, and whether +the launchd variable is live. `hyp client detach claude --purge` and `hyp daemon +uninstall` remove the CA, its keychain trust, the launchd variable, and the +login agent. A plain `hyp client detach claude` leaves the CA and any trust an +earlier release was granted in place, because a detach is not a statement +about the certificate and no attach re-creates the grant; it clears the +launchd variable and its agent only while that client's attach marker still +records a proxy attach. ## Where it goes diff --git a/src/core/cli/core_commands.js b/src/core/cli/core_commands.js index 697a7b0a6..9ff84a3b8 100644 --- a/src/core/cli/core_commands.js +++ b/src/core/cli/core_commands.js @@ -401,9 +401,10 @@ function buildCoreCommands(registry) { '', 'Detaching stops future capture for that client; it does not delete', 'anything already recorded (see hyp privacy purge for that).', - 'A proxy-mode detach keeps the local interception CA and any keychain', - 'trust an earlier release was granted, because no attach re-creates', - 'either; --purge removes both as well.', + 'A detach keeps the local interception CA, and any OS trust store', + 'grant an earlier release was given: no attach re-creates that grant,', + 'so it is a leftover rather than a convenience being held for you.', + '--purge removes the CA and that trust as well.', '--dry-run reports what would change without writing.', ].join('\n'), aliases: ['detach', 'unattach'], diff --git a/test/core/proxy-trust-claims.test.js b/test/core/proxy-trust-claims.test.js new file mode 100644 index 000000000..add185e3d --- /dev/null +++ b/test/core/proxy-trust-claims.test.js @@ -0,0 +1,236 @@ +// @ts-check + +// LLP 0262 moved Claude Code off proxy capture, and the last production caller +// that installed the interception CA into a trust store went with it. The +// product prose did not all move. The README's "Proxy mode" section, the +// privacy document, and `hyp client detach --help` all still described attach adding +// the CA to the macOS login keychain, still described macOS raising a password +// dialog for it, and still sold a detach as sparing the reader that dialog on +// the next attach. +// +// The last one is the expensive kind of wrong: it reads the keychain trust an +// older release left on the account as a convenience being held for you, when +// it is a leftover nothing re-creates and only `hyp client detach --purge` or +// `hyp daemon uninstall` clears. +// +// This is a lint over documents, not a behavior check. It pairs a fact about +// the tree (the two trust writers have no caller outside their own modules) +// with the claims those documents may not make while that fact holds, so the +// prose cannot drift back without the fact drifting back too. +// +// @ref LLP 0262#migration [tests]: attach offers the CA purge and never re-creates the keychain grant, so no document may promise one is waiting + +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') + +// The two writers a proxy attach used to run, each paired with the module that +// may still name it: its own, which keeps exporting it for the day another +// client needs it. The keychain root and the launchd variable that made the +// root count are one grant in two halves, so no half may be described in the +// present tense while neither half has a caller. +const TRUST_INSTALLERS = [ + { symbol: 'installCaTrust', home: 'src/core/tls/darwin_trust.js' }, + { symbol: 'installLaunchdEnv', home: 'src/core/daemon/launchd_env.js' }, +] + +/** + * Every document that carries the claim, with its own banned phrases and its + * own required replacements. Per document, not one shared list: these say the + * same thing in three registers (product prose, privacy prose, terminal help), + * and a phrase that has to be present in the README reads as a non-sequitur in + * `hyp client detach --help`. Adding a fourth document means writing its own pair of + * lists, which is the point: the gate widens by stating what that document + * must say, never by demanding another's sentences. + * + * `banned` catches a revert. `required` catches a rewrite: a paragraph + * re-documented from scratch could re-assert every banned claim in fresh words + * and trip nothing, so each document must also still carry the sentences that + * make the leftover explicit. Dropping one fails here even though it matches no + * banned phrase. + * + * Reword a `required` phrase freely, but say the same thing and update this + * list in the same commit; the list is the claim, not the wording. Phrases + * match whitespace-insensitively, so reflowing a paragraph does not hide one. + */ +const SCANNED = [ + { + file: 'README.md', + banned: [ + { + phrase: 'added to your **login keychain**', + truth: 'no attach installs the CA into a trust store; an earlier release did, and that trust is a leftover the reader has to purge', + }, + { + phrase: 'raises its own password dialog', + truth: 'nothing HypAware runs raises that dialog any more, so the present tense promises a step that never comes', + }, + { + phrase: 'so re-attaching does not ask again', + truth: 'no attach re-creates the grant, so a detach is not saving the reader a dialog', + }, + { + phrase: 'a proxy attach also leaves a login-session variable behind', + truth: 'no attach runs launchctl setenv NODE_USE_SYSTEM_CA 1 or installs the login agent; on a machine that ran an earlier release both are leftovers to remove', + }, + { + phrase: '`hyp client detach ` unsets the variable and removes the agent', + truth: 'a plain detach releases the launchd environment only for a marker that still records a proxy attach, so on a migrated machine it is not the command that clears the leftover', + }, + ], + required: [ + { + phrase: 'nothing installs it into any OS trust store', + why: 'the CA bullet has to say outright that no trust store is written, or a reader takes the keychain install to be current', + }, + { + phrase: 'that trust setting is still on your account until you remove it', + why: 'the keychain grant an earlier release was given is a leftover to purge, and this is where the reader learns it is theirs to clear', + }, + { + phrase: 'No attach writes either one today', + why: 'the launchd variable and its login agent are leftovers on the same terms, and the bullet says so only while this sentence survives', + }, + { + phrase: 'only clears them while that client\'s attach marker still records a proxy attach', + why: 'releaseProxyModeLaunchdEnv returns early on a non-proxy marker, so pointing a migrated machine at a plain detach would name a command that does nothing for it', + }, + ], + }, + { + file: 'docs/PRIVACY.md', + banned: [ + { + phrase: 'attach installs the CA into your **login keychain**', + truth: 'no attach installs the CA into a trust store; an earlier release did, and the privacy document is the last place that may imply otherwise', + }, + { + phrase: 'which is why macOS itself raises the password dialog', + truth: 'nothing HypAware runs raises that dialog any more', + }, + { + phrase: 'so attach also runs `launchctl setenv NODE_USE_SYSTEM_CA 1`', + truth: 'no attach runs it today; on a machine that ran an earlier release the variable and its login agent are leftovers to remove', + }, + { + phrase: 'so re-attaching later does not ask for your password again', + truth: 'no attach re-creates the grant, so a detach is not saving the reader a dialog', + }, + ], + required: [ + { + phrase: 'nothing HypAware runs installs the CA into an OS trust store', + why: 'the privacy document is where a reader checks what touched their machine, so it has to state that no trust store is written', + }, + { + phrase: 'that trust setting is still on your account until you remove it', + why: 'a reader who ran an earlier release learns here that the grant is theirs to clear', + }, + { + phrase: 'No attach writes either one today', + why: 'the launchd variable and its login agent are leftovers on the same terms', + }, + { + phrase: 'no attach re-creates the grant', + why: 'without it the lifetime paragraph reads as a detach holding a convenience open, which is the claim this gate exists to keep out', + }, + ], + }, + { + file: 'src/core/cli/core_commands.js', + banned: [ + { + phrase: 'needs no new password dialog', + truth: 'the detach help text is read at the moment the user is deciding, and no attach re-creates the grant it promises to spare them', + }, + ], + required: [ + { + phrase: 'no attach re-creates that grant', + why: 'the help text has to say the kept trust is a leftover, or a terminal contradicts the README a line at a time', + }, + ], + }, +] + +/** + * @param {string} phrase + * @returns {RegExp} + */ +function phrasePattern(phrase) { + const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return new RegExp(escaped.replace(/\s+/g, '\\s+')) +} + +/** @returns {string[]} one ` names ` entry per production module outside the writer's home that names it */ +function trustInstallerNamers() { + const listed = execFileSync('git', ['ls-files', '-z', '*.js'], { cwd: REPO_ROOT, encoding: 'utf8' }) + const candidates = listed + .split('\0') + .filter(file => file !== '' && !file.startsWith('test/')) + // A tracked path can be absent from the working tree mid-rebase. That is + // not a caller, and this gate must not die on it. + .filter(file => fs.existsSync(path.join(REPO_ROOT, file))) + /** @type {string[]} */ + const namers = [] + for (const file of candidates) { + const text = fs.readFileSync(path.join(REPO_ROOT, file), 'utf8') + for (const { symbol, home } of TRUST_INSTALLERS) { + // Word-bounded, so a future `installCaTrustForHost` is not read as this + // one. Still only a mention, not proof of a call: a `{@link}` in a + // comment trips it too, which is why the failure below says "names" + // rather than accusing the file of installing anything. + if (file !== home && new RegExp(`\\b${symbol}\\b`).test(text)) namers.push(`${file} names ${symbol}`) + } + } + return namers +} + +/** @returns {string[]} one entry per stale claim still standing in the scanned documents */ +function staleClaims() { + /** @type {string[]} */ + const found = [] + for (const { file, banned } of SCANNED) { + const text = fs.readFileSync(path.join(REPO_ROOT, file), 'utf8') + for (const claim of banned) { + const match = phrasePattern(claim.phrase).exec(text) + if (!match) continue + const line = text.slice(0, match.index).split('\n').length + found.push(`${file}:${line} "${claim.phrase}"\n true instead: ${claim.truth}`) + } + } + return found +} + +/** @returns {string[]} one entry per required truth a scanned document no longer states */ +function missingTruths() { + /** @type {string[]} */ + const missing = [] + for (const { file, required } of SCANNED) { + const text = fs.readFileSync(path.join(REPO_ROOT, file), 'utf8') + for (const truth of required) { + if (phrasePattern(truth.phrase).test(text)) continue + missing.push(`${file} "${truth.phrase}"\n why it has to stay: ${truth.why}`) + } + } + return missing +} + +test('no document promises a trust step that no attach performs', () => { + assert.deepEqual( + trustInstallerNamers(), + [], + 'a production module outside its home names a proxy trust writer: if it now calls one, the claims this gate bans are true again and the gate needs rewriting rather than the docs; if it only mentions one in prose, reword the mention' + ) + + const found = staleClaims() + assert.deepEqual(found, [], `stale trust-store claims:\n${found.join('\n')}`) + + const missing = missingTruths() + assert.deepEqual(missing, [], `a document dropped what it has to say instead:\n${missing.join('\n')}`) +}) diff --git a/test/core/readme-proxy-trust-claims.test.js b/test/core/readme-proxy-trust-claims.test.js deleted file mode 100644 index 381e2215c..000000000 --- a/test/core/readme-proxy-trust-claims.test.js +++ /dev/null @@ -1,168 +0,0 @@ -// @ts-check - -// LLP 0262 moved Claude Code off proxy capture, and the last production caller -// that installed the interception CA into a trust store went with it. The -// product prose did not all move. The README's "Proxy mode" section still -// described attach adding the CA to the macOS login keychain, still described -// macOS raising a password dialog for it, and still sold a detach as sparing -// the reader that dialog on the next attach. -// -// The last one is the expensive kind of wrong: it reads the keychain trust an -// older release left on the account as a convenience being held for you, when -// it is a leftover nothing re-creates and only `hyp detach --purge` or -// `hyp daemon uninstall` clears. -// -// This is a lint over one document, not a behavior check. It pairs a fact about -// the tree (the two trust writers have no caller outside their own modules) -// with the claims the README may not make while that fact holds, so the prose -// cannot drift back without the fact drifting back too. -// -// @ref LLP 0262#migration [tests]: attach offers the CA purge and never re-creates the keychain grant, so no document may promise one is waiting - -import test from 'node:test' -import assert from 'node:assert/strict' -import fs from 'node:fs' -import path from 'node:path' -import { execFileSync } from 'node:child_process' -import { fileURLToPath } from 'node:url' - -const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') - -// The two writers a proxy attach used to run, each paired with the module that -// may still name it: its own, which keeps exporting it for the day another -// client needs it. The keychain root and the launchd variable that made the -// root count are one grant in two halves, so no half may be described in the -// present tense while neither half has a caller. -const TRUST_INSTALLERS = [ - { symbol: 'installCaTrust', home: 'src/core/tls/darwin_trust.js' }, - { symbol: 'installLaunchdEnv', home: 'src/core/daemon/launchd_env.js' }, -] - -// Scoped to the README because it is the product document whose proxy-mode -// section LLP 0262 left behind. `docs/PRIVACY.md` and the `hyp detach` help -// text carry the same sentence and are corrected on their own change; add them -// here once they are clean, so the gate widens as the sweep does. -const SCANNED = ['README.md'] - -/** - * Claims the README may not make while nothing installs CA trust, each paired - * with what is true instead so a hit reads as an instruction, not a riddle. - * Phrases match whitespace-insensitively, so reflowing a paragraph does not - * hide one. - */ -const STALE_CLAIMS = [ - { - phrase: 'added to your **login keychain**', - truth: 'no attach installs the CA into a trust store; an earlier release did, and that trust is a leftover the reader has to purge', - }, - { - phrase: 'raises its own password dialog', - truth: 'nothing HypAware runs raises that dialog any more, so the present tense promises a step that never comes', - }, - { - phrase: 'so re-attaching does not ask again', - truth: 'no attach re-creates the grant, so a detach is not saving the reader a dialog', - }, - { - phrase: 'a proxy attach also leaves a login-session variable behind', - truth: 'no attach runs launchctl setenv NODE_USE_SYSTEM_CA 1 or installs the login agent; on a machine that ran an earlier release both are leftovers to remove', - }, -] - -/** - * The other half of the gate. A denylist catches a revert, not a rewrite: a - * paragraph re-documented from scratch could re-assert every banned claim in - * fresh words and trip nothing. So the README must also still carry the - * sentences that make the leftover explicit. Dropping one fails here even - * though it matches no banned phrase. - * - * Reword these freely, but say the same thing and update this list in the same - * commit; the list is the claim, not the wording. - */ -const REQUIRED_TRUTHS = [ - { - phrase: 'nothing installs it into any OS trust store', - why: 'the CA bullet has to say outright that no trust store is written, or a reader takes the keychain install to be current', - }, - { - phrase: 'that trust setting is still on your account until you remove it', - why: 'the keychain grant an earlier release was given is a leftover to purge, and this is where the reader learns it is theirs to clear', - }, - { - phrase: 'No attach writes either one today', - why: 'the launchd variable and its login agent are leftovers on the same terms, and the bullet says so only while this sentence survives', - }, -] - -/** - * @param {string} phrase - * @returns {RegExp} - */ -function phrasePattern(phrase) { - const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - return new RegExp(escaped.replace(/\s+/g, '\\s+')) -} - -/** @returns {string[]} one ` names ` entry per production caller of a trust writer */ -function trustInstallerCallers() { - const listed = execFileSync('git', ['ls-files', '-z', '*.js'], { cwd: REPO_ROOT, encoding: 'utf8' }) - const candidates = listed - .split('\0') - .filter(file => file !== '' && !file.startsWith('test/')) - // A tracked path can be absent from the working tree mid-rebase. That is - // not a caller, and this gate must not die on it. - .filter(file => fs.existsSync(path.join(REPO_ROOT, file))) - /** @type {string[]} */ - const callers = [] - for (const file of candidates) { - const text = fs.readFileSync(path.join(REPO_ROOT, file), 'utf8') - for (const { symbol, home } of TRUST_INSTALLERS) { - if (file !== home && text.includes(symbol)) callers.push(`${file} names ${symbol}`) - } - } - return callers -} - -/** @returns {string[]} one entry per stale claim still standing in the scanned docs */ -function staleClaims() { - /** @type {string[]} */ - const found = [] - for (const rel of SCANNED) { - const text = fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8') - for (const claim of STALE_CLAIMS) { - const match = phrasePattern(claim.phrase).exec(text) - if (!match) continue - const line = text.slice(0, match.index).split('\n').length - found.push(`${rel}:${line} "${claim.phrase}"\n true instead: ${claim.truth}`) - } - } - return found -} - -/** @returns {string[]} one entry per required truth the scanned docs no longer state */ -function missingTruths() { - /** @type {string[]} */ - const missing = [] - for (const rel of SCANNED) { - const text = fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8') - for (const truth of REQUIRED_TRUTHS) { - if (phrasePattern(truth.phrase).test(text)) continue - missing.push(`${rel} "${truth.phrase}"\n why it has to stay: ${truth.why}`) - } - } - return missing -} - -test('the README promises no trust step that no attach performs', () => { - assert.deepEqual( - trustInstallerCallers(), - [], - 'production code installs proxy trust again: either that is the bug, or the claims this gate bans are true once more and the gate needs rewriting rather than the docs' - ) - - const found = staleClaims() - assert.deepEqual(found, [], `stale trust-store claims:\n${found.join('\n')}`) - - const missing = missingTruths() - assert.deepEqual(missing, [], `the README dropped what it has to say instead:\n${missing.join('\n')}`) -}) From 3d231b394a0734990af8b973c5930d64d877d548 Mon Sep 17 00:00:00 2001 From: Phillip Cunliffe Date: Wed, 19 Aug 2026 15:00:18 -0700 Subject: [PATCH 7/7] Align proxy migration checks with current CLI --- src/core/commands/clients.js | 8 ++++---- test/core/attach-proxy-migration.test.js | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/core/commands/clients.js b/src/core/commands/clients.js index 2587bc8a2..7061a56a8 100644 --- a/src/core/commands/clients.js +++ b/src/core/commands/clients.js @@ -913,9 +913,9 @@ async function maybeInteractiveEnableAttach({ name, ctx, parsed, enablement }) { * remains the working fallback. * * The question names the config write and the restart, and says where the CA - * is trusted. It no longer promises a macOS trust dialog: nothing in the tree - * calls `installCaTrust` since the claude attach went otel-only, so a yes - * mints the CA and stops there. + * is trusted. It no longer promises a macOS trust dialog: no production path + * calls the trust-store writer since the claude attach went otel-only, so a + * yes mints the CA and stops there. * * @ref LLP 0244#attach-offers [implements]: one consented question, default no, naming the config write and the restart * @ref LLP 0244#central-managed [implements]: a fleet-owned gateway block reports instead of prompting @@ -925,7 +925,7 @@ async function maybeInteractiveEnableAttach({ name, ctx, parsed, enablement }) { */ async function maybeOfferProxyModeMigration({ name, ctx, parsed }) { // A dry run changes nothing and promises nothing, so it says nothing. - // `hyp attach all` never prompts mid-run either (same posture as + // `hyp client attach all` never prompts mid-run either (same posture as // maybeInteractiveEnableAttach above), but it does not return here: it // falls through to the one-line pointer below, because LLP 0244 // #non-interactive owes every non-migrating attach shape the line naming diff --git a/test/core/attach-proxy-migration.test.js b/test/core/attach-proxy-migration.test.js index a931194eb..3f7f14c13 100644 --- a/test/core/attach-proxy-migration.test.js +++ b/test/core/attach-proxy-migration.test.js @@ -304,7 +304,7 @@ test('centrally-managed gateway: no question even on a TTY, the fleet note inste const code = await runAttach([PROXY_CLIENT], ctx) assert.equal(code, 0, stderr.text()) assert.ok(!stderr.text().includes(MIGRATION_QUESTION)) - assert.match(stderr.text(), /centrally managed; enable proxy_mode in the fleet config/) + assert.match(stderr.text(), /centrally managed; enable proxy_mode in the central config/) assert.equal(readFileSync(localConfigPath(home), 'utf8'), before) }) })