diff --git a/src/core/cli/walkthrough.js b/src/core/cli/walkthrough.js index df86ac5e..035847eb 100644 --- a/src/core/cli/walkthrough.js +++ b/src/core/cli/walkthrough.js @@ -1501,38 +1501,21 @@ export function resolveSingleSourceEnablement(descriptor) { } /** - * Wait for the proxy CA before the finale attaches clients, when the config - * that will govern the freshly installed daemon runs the gateway in proxy - * mode. Adapters pick their mode by whether the CA file exists (LLP 0232 - * #proxy-attach-preflight) and the daemon mints it asynchronously on gateway - * start, so attaching without waiting races the mint and silently lands - * every client back on base-URL mode. Bounded, and a timeout degrades to a - * warning: base-URL attach still captures, and a re-run of `hyp attach` - * repairs the mode. + * Whether the gateway that will govern this machine runs in proxy mode. * * "Will govern" is the effective view, not the just-written local file: on a * fleet-joined machine the central layer names the gateway and the LLP 0031 * merge drops the local entry, so a local `proxy_mode: true` is dead and - * waiting on it could only time out. When the central layer names the - * gateway, its own `proxy_mode` value decides the wait instead. + * reading it would answer for a config nothing runs. When the central layer + * names the gateway, its own `proxy_mode` value is the answer instead. + * * @ref LLP 0243#composed-default [implements]: a fresh proxy-mode install must attach in proxy mode, not lose the race to the CA mint * @ref LLP 0244#central-managed [constrained-by]: the central layer owning the gateway block decides the mode, locally written keys prove nothing * - * @param {{ - * config: HypAwareV2Config, - * env: NodeJS.ProcessEnv, - * stderr: { write(chunk: string): unknown }, - * waitForCaFn?: (args: { - * stateRoot: string, - * timeoutMs?: number, - * sleep?: (ms: number) => Promise, - * now?: () => number, - * }) => Promise<{ ready: boolean, certPath?: string }>, - * timeoutMs?: number, - * }} args - * @returns {Promise<{ waited: boolean, ready: boolean }>} + * @param {{ config: HypAwareV2Config, env: NodeJS.ProcessEnv }} args + * @returns {Promise} */ -export async function waitForProxyCaBeforeAttach({ config, env, stderr, waitForCaFn, timeoutMs }) { +export async function governingGatewayProxyMode({ config, env }) { const stateRoot = defaultStateRoot(env) /** @type {PluginConfigInstance | undefined} */ @@ -1553,7 +1536,35 @@ export async function waitForProxyCaBeforeAttach({ config, env, stderr, waitForC const governing = centralGateway ?? (config.plugins ?? []).find((p) => p.name === GATEWAY_PLUGIN) - if (governing?.config?.proxy_mode !== true) return { waited: false, ready: false } + return governing?.config?.proxy_mode === true +} + +/** + * Wait for the proxy CA before the finale attaches clients, when the config + * that will govern the daemon runs the gateway in proxy mode. Adapters pick + * their mode by whether the CA file exists (LLP 0232 #proxy-attach-preflight) + * and the daemon mints it asynchronously on gateway start, so attaching + * without waiting races the mint and silently lands every client back on + * base-URL mode. Bounded, and a timeout degrades to a warning: base-URL + * attach still captures, and a re-run of `hyp attach` repairs the mode. + * + * @param {{ + * config: HypAwareV2Config, + * env: NodeJS.ProcessEnv, + * stderr: { write(chunk: string): unknown }, + * waitForCaFn?: (args: { + * stateRoot: string, + * timeoutMs?: number, + * sleep?: (ms: number) => Promise, + * now?: () => number, + * }) => Promise<{ ready: boolean, certPath?: string }>, + * timeoutMs?: number, + * }} args + * @returns {Promise<{ waited: boolean, ready: boolean }>} + */ +export async function waitForProxyCaBeforeAttach({ config, env, stderr, waitForCaFn, timeoutMs }) { + const stateRoot = defaultStateRoot(env) + if (!(await governingGatewayProxyMode({ config, env }))) return { waited: false, ready: false } const waitFn = waitForCaFn ?? waitForLocalCa const caWait = await waitFn({ @@ -1578,8 +1589,9 @@ export async function waitForProxyCaBeforeAttach({ config, env, stderr, waitForC * Exported for the wizard orchestrator (LLP 0135 #finale), which wraps * it with the team-pathway skips: `finale.skipDaemonInstall` skips only * the install step (the restart still runs so the just-written local - * config takes effect), and `skipAttachClients` names picked clients the - * join lane already attached. + * config takes effect, and moves ahead of attach when that config puts the + * gateway in proxy mode), and `skipAttachClients` names picked clients the + * join lane already attached in the mode this install uses. * * @param {{ * finale: PickerFinaleActions, @@ -1602,6 +1614,7 @@ export async function waitForProxyCaBeforeAttach({ config, env, stderr, waitForC * skipAttachClients?: Set, * progress?: string, * installDaemonFn?: (options: DaemonInstallOptions) => Promise, + * restartDaemonFn?: (options: { homeDir?: string }) => Promise, * waitForCaFn?: (args: { * stateRoot: string, * timeoutMs?: number, @@ -1623,6 +1636,12 @@ export async function runPickerFinale(args) { if (args.progress) stdout.write(`${args.progress}\n`) const homeDir = env.HOME ?? '' const skipInstall = finale.skipDaemon === true || finale.skipDaemonInstall === true + // The finale restarts the daemon exactly once, so that the config written + // just before it takes effect. `willRestart` records that the restart is + // coming; `restartedEarly` records that it has already been spent by the + // proxy-readiness step below, which moves it rather than adding one. + const willRestart = finale.skipDaemon !== true && finale.skipDaemonRestart !== true && !dryRun + let restartedEarly = false // The attach/start cutoff: backfill imports history strictly before // this instant so it never overlaps with live gateway capture, which @@ -1715,16 +1734,31 @@ export async function runPickerFinale(args) { } if (clientsPicked.length > 0 && capabilities.has('hypaware.ai-gateway')) { - // Skipped when no daemon was installed (the join lane restarts only - // after attach, so no CA can appear before it) and on dry runs; the - // wait-or-skip decision itself lives in the helper. - if (!dryRun && !skipInstall) { - await waitForProxyCaBeforeAttach({ - config, - env, - stderr, - ...(args.waitForCaFn ? { waitForCaFn: args.waitForCaFn } : {}), - }) + // Proxy attach preflights on the CA file (LLP 0232 + // #proxy-attach-preflight), and only a daemon running the governing + // proxy-mode config mints one. An install just above started such a + // daemon, so the wait alone is enough. A *skipped* install means the + // daemon predates the config this run wrote - the upgrade-on-an-enrolled + // machine shape - and the restart that would put proxy mode on the wire + // sat at the end of the finale, after the only chance to attach. Bring + // that restart forward instead of adding one: the tail restart below + // stands down, backfill still runs against the `backfillUntil` cutoff + // taken before any of this, and the ordering matches the fresh-install + // path, where the install starts the daemon before attach too. + // @ref LLP 0243#composed-default [implements]: an install whose gateway runs in proxy mode attaches in proxy mode, whether or not this run installed the daemon + if (!dryRun) { + if (skipInstall && willRestart && await governingGatewayProxyMode({ config, env })) { + await restartFinaleDaemon({ homeDir, stderr, summary, ...(args.restartDaemonFn ? { restartDaemonFn: args.restartDaemonFn } : {}) }) + restartedEarly = true + } + if (!skipInstall || restartedEarly) { + await waitForProxyCaBeforeAttach({ + config, + env, + stderr, + ...(args.waitForCaFn ? { waitForCaFn: args.waitForCaFn } : {}), + }) + } } /** @type {AiGatewayCapability} */ const gateway = capabilities.require('hyp-core/walkthrough', 'hypaware.ai-gateway', '^2.0.0') @@ -1877,16 +1911,8 @@ export async function runPickerFinale(args) { writeAttachedNotConfiguredWarning({ clients: summary.attachedNotConfigured, stdout, dryRun }) } - if (!finale.skipDaemon && !finale.skipDaemonRestart && !dryRun) { - try { - const { restartServiceDaemon } = await import('../daemon/install.js') - await restartServiceDaemon({ ...(homeDir ? { homeDir } : {}) }) - summary.daemonRestart = { skipped: false, dryRun: false, ok: true } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - stderr.write(`daemon restart failed: ${message}\n`) - summary.daemonRestart = { skipped: false, dryRun: false, ok: false } - } + if (willRestart && !restartedEarly) { + await restartFinaleDaemon({ homeDir, stderr, summary, ...(args.restartDaemonFn ? { restartDaemonFn: args.restartDaemonFn } : {}) }) } else if (dryRun && !finale.skipDaemon) { summary.daemonRestart = { skipped: false, dryRun: true, ok: true } stdout.write(`(dry-run) Would restart the daemon\n`) @@ -1895,6 +1921,39 @@ export async function runPickerFinale(args) { return summary } +/** + * The finale's one daemon restart, wherever in the lane it falls. A failure + * is reported and recorded, never thrown: the config is written and the + * clients are attached either way, and `hyp daemon restart` is the repair. + * + * Injectable for the same reason the install seam is: the real service-manager + * call refuses to spawn launchd or systemd under the test runner (LLP 0181), + * so the ordering this function participates in would otherwise be untestable. + * + * @param {{ + * homeDir: string, + * stderr: { write(chunk: string): unknown }, + * summary: FinaleSummary, + * restartDaemonFn?: (options: { homeDir?: string }) => Promise, + * }} args + * @returns {Promise} + */ +async function restartFinaleDaemon({ homeDir, stderr, summary, restartDaemonFn }) { + try { + const options = { ...(homeDir ? { homeDir } : {}) } + if (restartDaemonFn) await restartDaemonFn(options) + else { + const { restartServiceDaemon } = await import('../daemon/install.js') + await restartServiceDaemon(options) + } + summary.daemonRestart = { skipped: false, dryRun: false, ok: true } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + stderr.write(`daemon restart failed: ${message}\n`) + summary.daemonRestart = { skipped: false, dryRun: false, ok: false } + } +} + /** * The plugin names the org's central layer declares, read-only and * best-effort. A centrally named client is attached and reversed by the diff --git a/src/core/cli/wizard/index.js b/src/core/cli/wizard/index.js index 33207d90..62ccf293 100644 --- a/src/core/cli/wizard/index.js +++ b/src/core/cli/wizard/index.js @@ -3,7 +3,7 @@ /** * @import { PluginCatalog } from '../../../../src/core/types.js' * @import { FinaleSummary, PickerSource } from '../../../../src/core/cli/types.js' - * @import { CollectStatusOptions, HypAwareStatusReport } from '../../../../src/core/daemon/types.js' + * @import { ClientAttachReport, CollectStatusOptions, HypAwareStatusReport } from '../../../../src/core/daemon/types.js' * @import { * FirstAskResult, * FirstLookOutcome, @@ -29,6 +29,7 @@ import { buildWalkthroughClientDescriptorMap, defaultConfirmSelectPromptFactory, defaultPickerDetect, + governingGatewayProxyMode, runPickerFinale, writeAttachedNotConfiguredReminder, writeWalkthroughRunSummary, @@ -861,6 +862,12 @@ function printJoinFailure(opts, join) { * already-attached clients skip attach (LLP 0134 #login-lane: the finale * detects and skips what enrollment already did). * + * "Already attached" has to mean attached *the way this install attaches*. + * A marker alone does not say that: on a machine upgrading from a base-URL + * version to a proxy-mode one, every picked client carries a marker, and + * reading it as done is what left the upgrade in base-URL mode with no + * further prompt. So a marker recording the other mode is not a skip. + * * @param {{ * opts: RunInitWizardOptions, * picked: WizardPickResult, @@ -876,7 +883,18 @@ async function runWizardFinale({ opts, picked, joinedAlready, progress }) { if (joinedAlready) { const report = await collectStatusSafe(opts) if (report?.daemon?.installed) finaleActions.skipDaemonInstall = true - const attached = (report?.clients ?? []).filter((c) => c.attached).map((c) => c.name) + const proxyMode = await governingGatewayProxyMode({ config: picked.config, env: opts.env }) + // The picked rows that attach their client through the proxy, by plugin: + // the status report names each client's plugin, and the picker row is + // where `gateway_proxy_mode` is declared (LLP 0243 #composed-default). + const proxyAttachPlugins = new Set( + picked.descriptors + .filter((descriptor) => descriptor.compose?.gateway_proxy_mode === true) + .map((descriptor) => descriptor.plugin) + ) + const attached = (report?.clients ?? []) + .filter((client) => client.attached && !attachModeIsStale({ client, proxyMode, proxyAttachPlugins })) + .map((client) => client.name) if (attached.length > 0) skipAttachClients = new Set(attached) } @@ -919,6 +937,33 @@ async function runWizardFinale({ opts, picked, joinedAlready, progress }) { ) } +/** + * Whether an existing attach marker records a mode this install no longer + * attaches in, which makes re-attaching the point rather than the waste the + * skip exists to avoid. + * + * Only one direction is stale: proxy mode is on and the client's row attaches + * through the proxy, but the marker says base URL (or predates modes and says + * nothing, which is the same base-URL attach without the label). The reverse + * is not this function's call - a proxy marker on an install whose gateway + * dropped proxy mode is the LLP 0244 offer's business, and re-attaching a + * client whose row never attaches by proxy would only rewrite the file it + * already has. + * + * @ref LLP 0244 [implements]: a base-URL attach on a proxy-mode install is unfinished migration, not a completed attach + * @param {{ + * client: ClientAttachReport, + * proxyMode: boolean, + * proxyAttachPlugins: Set, + * }} args + * @returns {boolean} + */ +function attachModeIsStale({ client, proxyMode, proxyAttachPlugins }) { + if (!proxyMode) return false + if (!proxyAttachPlugins.has(client.plugin)) return false + return client.mode !== 'proxy' +} + /** * State, clearly, that nothing has been uploaded and when the first * upload happens. Reads the first-sync hold the join lane's login wrote diff --git a/src/core/daemon/status.js b/src/core/daemon/status.js index d8851833..6bc7777a 100644 --- a/src/core/daemon/status.js +++ b/src/core/daemon/status.js @@ -999,6 +999,7 @@ export async function collectHypAwareStatus(opts = {}) { ...(probe.settingsPath ? { settingsPath: probe.settingsPath } : {}), ...(probe.version !== undefined ? { version: probe.version } : {}), ...(probe.port !== undefined ? { port: probe.port } : {}), + ...(probe.mode !== undefined ? { mode: probe.mode } : {}), ...(probe.error !== undefined ? { error: probe.error } : {}), }) // Deliberately ungated by `attachable`, unlike the two derived-state @@ -1590,9 +1591,15 @@ function readRetention(config) { * "not attached" off a path the manifest never named is the one answer a * probe must never give: it looks identical to a correct negative. * + * The marker's `mode` comes back beside its version and port. Two attaches + * that both leave a marker are not the same attach: a `base_url` marker on a + * proxy-mode install is a stale attach still waiting to be migrated, and a + * caller that can only see `attached: true` reads it as finished. + * * @ref LLP 0045#settings_file-is-home-relative-and-a-violation-is-loud [implements]: an unresolvable settings_file is an error result, not a silent not-attached + * @ref LLP 0244 [implements]: a base-URL attach on a proxy-capable install is a distinct state, so the probe reports the mode rather than a bare attached flag * @param {{ descriptor: ClientDescriptor, homeDir: string, env?: NodeJS.ProcessEnv }} args - * @returns {Promise<{ attached: boolean, settingsPath?: string, version?: string, port?: string, error?: string }>} + * @returns {Promise<{ attached: boolean, settingsPath?: string, version?: string, port?: string, mode?: string, error?: string }>} */ export async function probeClientAttachFromDescriptor({ descriptor, homeDir, env }) { if (!homeDir || !descriptor.attachProbe) return { attached: false } @@ -1622,6 +1629,7 @@ export async function probeClientAttachFromDescriptor({ descriptor, homeDir, env settingsPath, version: typeof markerObj.version === 'string' ? markerObj.version : undefined, port: typeof markerObj.port === 'number' ? String(markerObj.port) : undefined, + mode: typeof markerObj.mode === 'string' ? markerObj.mode : undefined, } } diff --git a/src/core/daemon/types.d.ts b/src/core/daemon/types.d.ts index 944c9afc..7c6e1252 100644 --- a/src/core/daemon/types.d.ts +++ b/src/core/daemon/types.d.ts @@ -223,6 +223,14 @@ export interface ClientAttachReport { version?: string /** Local gateway port the adapter routes through, when recorded. */ port?: string + /** + * Attach mode recorded in the marker (`proxy` or `base_url`), when the + * adapter recorded one. Absent for a marker written before modes existed + * and for probe formats that carry no marker object. Read it as evidence + * of *which* attach happened: `attached` alone cannot tell a proxy attach + * from the base-URL attach LLP 0244 migrates away from. + */ + mode?: string /** Probe error string, when the file was unreadable. */ error?: string } diff --git a/test/core/cli/wizard/joined-upgrade-proxy-attach.test.js b/test/core/cli/wizard/joined-upgrade-proxy-attach.test.js new file mode 100644 index 00000000..8d34327a --- /dev/null +++ b/test/core/cli/wizard/joined-upgrade-proxy-attach.test.js @@ -0,0 +1,250 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { runInitWizard } from '../../../../src/core/cli/wizard/index.js' +import { runPickerFinale } from '../../../../src/core/cli/walkthrough.js' + +// The joined-upgrade shape (#842): an enrolled machine moving from a +// base-URL version to a proxy-capable one runs `hyp init` through the team +// pathway. Every picked client already carries an attach marker and the +// daemon is already installed, so the finale used to skip both the install +// and the attach and restart at the very end - minting the proxy CA after +// the only chance to attach had passed, and leaving Claude on `base_url` +// with the wizard reporting success. +// +// @ref LLP 0243#composed-default [tests]: a proxy-mode install attaches in proxy mode +// @ref LLP 0244 [tests]: a base-URL attach on a proxy-mode install is unfinished, not done + +/** @returns {{ write(chunk: string): boolean, text(): string }} */ +function makeBuf() { + let value = '' + return { + write(chunk) { value += String(chunk); return true }, + text() { return value }, + } +} + +async function tmpHome() { + return fs.mkdtemp(path.join(os.tmpdir(), 'hypaware-joined-upgrade-')) +} + +/** + * Write the Claude settings a pre-proxy version left behind: the marker is + * there, and it names the mode. + * + * @param {string} home + * @param {string} [mode] + */ +async function writeClaudeMarker(home, mode) { + const dir = path.join(home, '.claude') + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(path.join(dir, 'settings.json'), JSON.stringify({ + env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:4319' }, + _hypaware: { + version: '1.22.0', + port: 4319, + ...(mode ? { mode } : {}), + }, + }, null, 2)) +} + +/** A gateway block in proxy mode, the shape LLP 0243's fold composes. */ +function proxyModeConfig() { + return /** @type {any} */ ({ + version: 2, + plugins: [ + { name: '@hypaware/ai-gateway', config: { upstreams: [], proxy_mode: true } }, + { name: '@hypaware/claude', config: {} }, + ], + }) +} + +/** The Claude picker row, which is where `gateway_proxy_mode` is declared. */ +function claudeRow() { + return /** @type {any} */ ({ + plugin: '@hypaware/claude', + id: 'claude', + label: 'Claude Code', + compose: { requires_gateway: true, gateway_proxy_mode: true }, + }) +} + +/** + * The wizard driven down the team pathway with every phase scripted, so the + * only thing under test is what the joined finale decides to skip. + * + * @param {string} home + */ +function joinedWizardOpts(home) { + const stdout = makeBuf() + const stderr = makeBuf() + const opts = /** @type {any} */ ({ + stdout, + stderr, + env: { HOME: home, HYP_HOME: path.join(home, '.hyp'), HYP_NO_TUI: '1' }, + ctx: /** @type {any} */ ({ commands: { run: async () => 0 } }), + capabilities: /** @type {any} */ ({ has: () => false }), + catalog: /** @type {any} */ ({ + plugins: new Map(), + pluginMetadata: new Map(), + knownDatasets: new Set(), + clientDescriptors: new Map(), + pickerDescriptors: new Map(), + }), + finale: {}, + gate: async () => ({ action: 'first-run', managed: false, report: {} }), + fork: async () => 'team', + join: async () => ({ status: 'ok', lockedSources: [], managed: true }), + pick: async () => /** @type {any} */ ({ + exitCode: 0, + configPath: path.join(home, '.hyp', 'config.json'), + config: proxyModeConfig(), + sourcesPicked: ['claude'], + exportPicked: 'local-parquet', + clientsPicked: ['claude'], + retentionDays: 30, + descriptors: [claudeRow()], + lockedSources: [], + }), + syncScope: async () => ({ optedOut: [] }), + folderAsk: async () => ({ mode: 'sync' }), + express: async () => 'choose', + configure: async () => ({ results: [] }), + finaleRunner: async (/** @type {any} */ args) => { + opts._finaleArgs = args + return { + daemonInstall: { skipped: true, dryRun: false }, + globalInstall: { skipped: true, installed: false }, + attach: [], + skillsInstalled: [], + agentsInstalled: [], + daemonRestart: { skipped: true, dryRun: false, ok: false }, + backfill: [], + } + }, + }) + return { opts, stdout, stderr } +} + +test('a joined upgrade does not skip attach for a client still marked base_url', async () => { + const home = await tmpHome() + await writeClaudeMarker(home, 'base_url') + const { opts } = joinedWizardOpts(home) + + await runInitWizard(opts) + + const skipped = opts._finaleArgs?.skipAttachClients + assert.ok( + !skipped || !skipped.has('claude'), + 'a base-URL marker on a proxy-mode install is unfinished migration, not a completed attach' + ) +}) + +// A marker written before modes existed is the same base-URL attach without +// the label, so it must not read as a proxy attach either. +test('a joined upgrade does not skip attach for a marker that records no mode at all', async () => { + const home = await tmpHome() + await writeClaudeMarker(home) + const { opts } = joinedWizardOpts(home) + + await runInitWizard(opts) + + const skipped = opts._finaleArgs?.skipAttachClients + assert.ok(!skipped || !skipped.has('claude'), 'an unlabelled marker is not evidence of a proxy attach') +}) + +// The skip still does its job where it always did: enrollment attached this +// client in the mode the install runs, so re-attaching is pure waste. +test('a joined run still skips attach for a client already attached by proxy', async () => { + const home = await tmpHome() + await writeClaudeMarker(home, 'proxy') + const { opts } = joinedWizardOpts(home) + + await runInitWizard(opts) + + const skipped = opts._finaleArgs?.skipAttachClients + assert.ok(skipped?.has('claude'), 'a proxy marker on a proxy-mode install is the attach already done') +}) + +// The ordering half of #842: with the install skipped, the restart that puts +// proxy mode (and therefore the CA) on the wire has to happen before attach, +// not after it. It is still exactly one restart. +test('a skipped install restarts the daemon before attach so the proxy CA exists', async () => { + const home = await tmpHome() + const stdout = makeBuf() + const stderr = makeBuf() + /** @type {string[]} */ + const events = [] + + const summary = await runPickerFinale(/** @type {any} */ ({ + finale: { skipDaemonInstall: true, dryRun: false }, + retentionDays: 30, + interactive: false, + clientsPicked: ['claude'], + capabilities: /** @type {any} */ ({ + has: (/** @type {string} */ id) => id === 'hypaware.ai-gateway', + require: () => ({ + getClient: (/** @type {string} */ name) => + name === 'claude' ? { attach: async () => { events.push('attach') } } : undefined, + localEndpoint: () => 'http://127.0.0.1:4319', + }), + }), + config: proxyModeConfig(), + configPath: path.join(home, '.hyp', 'config.json'), + env: { HOME: home, HYP_HOME: path.join(home, '.hyp') }, + stdout, + stderr, + restartDaemonFn: async () => { events.push('restart') }, + waitForCaFn: async () => { + events.push('ca-wait') + return { ready: true, certPath: path.join(home, 'ca-cert.pem') } + }, + })) + + assert.deepEqual(events, ['restart', 'ca-wait', 'attach']) + assert.equal(events.filter((e) => e === 'restart').length, 1, 'the restart moved, it did not multiply') + assert.deepEqual(summary.daemonRestart, { skipped: false, dryRun: false, ok: true }) + assert.equal(stderr.text(), '', 'a ready CA prints no warning') +}) + +// A base-URL install keeps today's ordering exactly: nothing about proxy +// readiness applies, so the one restart stays at the end of the lane. +test('a skipped install with no proxy mode leaves the restart at the end', async () => { + const home = await tmpHome() + const stdout = makeBuf() + const stderr = makeBuf() + /** @type {string[]} */ + const events = [] + + await runPickerFinale(/** @type {any} */ ({ + finale: { skipDaemonInstall: true, dryRun: false }, + retentionDays: 30, + interactive: false, + clientsPicked: ['claude'], + capabilities: /** @type {any} */ ({ + has: (/** @type {string} */ id) => id === 'hypaware.ai-gateway', + require: () => ({ + getClient: (/** @type {string} */ name) => + name === 'claude' ? { attach: async () => { events.push('attach') } } : undefined, + localEndpoint: () => 'http://127.0.0.1:4319', + }), + }), + config: /** @type {any} */ ({ + version: 2, + plugins: [{ name: '@hypaware/ai-gateway', config: { upstreams: [] } }], + }), + configPath: path.join(home, '.hyp', 'config.json'), + env: { HOME: home, HYP_HOME: path.join(home, '.hyp') }, + stdout, + stderr, + restartDaemonFn: async () => { events.push('restart') }, + waitForCaFn: async () => { throw new Error('must not be called') }, + })) + + assert.deepEqual(events, ['attach', 'restart']) +}) diff --git a/test/core/daemon.test.js b/test/core/daemon.test.js index 752db7b8..e79b83bf 100644 --- a/test/core/daemon.test.js +++ b/test/core/daemon.test.js @@ -214,7 +214,7 @@ test('probeClientAttachFromDescriptor reads JSON attach markers', async () => { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'hypaware-attach-json-')) const settingsPath = path.join(tmp, '.claude', 'settings.json') await fs.mkdir(path.dirname(settingsPath), { recursive: true }) - await fs.writeFile(settingsPath, JSON.stringify({ _hypaware: { version: '2.0.0', port: 4388 } })) + await fs.writeFile(settingsPath, JSON.stringify({ _hypaware: { version: '2.0.0', port: 4388, mode: 'proxy' } })) const descriptor = /** @type {ClientDescriptor} */ ({ plugin: '@hypaware/claude', @@ -227,6 +227,21 @@ test('probeClientAttachFromDescriptor reads JSON attach markers', async () => { }, }) + // The mode comes back with the version and the port: a marker alone cannot + // tell a proxy attach from the base-URL attach LLP 0244 migrates away from. + assert.deepEqual( + await probeClientAttachFromDescriptor({ descriptor, homeDir: tmp }), + { + attached: true, + settingsPath, + version: '2.0.0', + port: '4388', + mode: 'proxy', + } + ) + + // A marker written before modes existed reports none, rather than guessing. + await fs.writeFile(settingsPath, JSON.stringify({ _hypaware: { version: '2.0.0', port: 4388 } })) assert.deepEqual( await probeClientAttachFromDescriptor({ descriptor, homeDir: tmp }), { @@ -234,6 +249,7 @@ test('probeClientAttachFromDescriptor reads JSON attach markers', async () => { settingsPath, version: '2.0.0', port: '4388', + mode: undefined, } ) })