diff --git a/src/commands/bar/bar-server-probe.ts b/src/commands/bar/bar-server-probe.ts index 0ec13b42..036d85f0 100644 --- a/src/commands/bar/bar-server-probe.ts +++ b/src/commands/bar/bar-server-probe.ts @@ -12,12 +12,33 @@ import * as path from 'path'; export interface DashboardInfo { port: number; baseUrl: string; + authRequired?: boolean; } /** * Read the port recorded in an existing bar.json. * Returns null when the file is absent or malformed. */ + +function ensureLoopbackNoProxy(): void { + const loopbackHosts = ['localhost', '127.0.0.1', '::1']; + const existing = process.env.NO_PROXY ?? process.env.no_proxy ?? ''; + const parts = new Set( + existing + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + ); + + for (const host of loopbackHosts) { + parts.add(host); + } + + const next = Array.from(parts).join(','); + process.env.NO_PROXY = next; + process.env.no_proxy = next; +} + export function resolveBarPort(ccsDir: string): number | null { const barJsonPath = path.join(ccsDir, 'bar.json'); try { @@ -37,37 +58,48 @@ export function resolveBarPort(ccsDir: string): number | null { * (one timeout) rather than N × 1.5 s sequentially. Results are awaited in * priority order so a lower-priority slow or streaming response cannot block * returning an already-known higher-priority hit. + * + * Each probe speaks raw HTTP/1.1 over a socket and resolves on the status line, + * which lets discovery distinguish a live-but-auth-protected server (401/403) + * from a healthy one (200) without depending on a higher-level HTTP client. */ export async function defaultFindRunningServer(ccsDir: string): Promise { - const { request } = await import('undici'); + ensureLoopbackNoProxy(); - async function probe(url: string): Promise<{ ok: boolean }> { - try { - const { statusCode, body } = await request(url, { - method: 'GET', - headersTimeout: 1500, - bodyTimeout: 1500, + async function probe(url: string): Promise<{ ok: boolean; authRequired: boolean }> { + const net = await import('net'); + const parsed = new URL(url); + const port = Number(parsed.port); + const host = parsed.hostname.replace(/^\[|\]$/g, ''); + + return new Promise((resolve) => { + let buffer = ''; + let settled = false; + const finish = (statusCode = 0) => { + if (settled) return; + settled = true; + // Tear down the socket the moment the status line is known. The summary + // endpoint only needs the status code for liveness, so a non-CCS + // loopback service that streams forever cannot block discovery from + // returning a higher-priority hit. + socket.destroy(); + const authRequired = statusCode === 401 || statusCode === 403; + resolve({ ok: statusCode === 200 || authRequired, authRequired }); + }; + const socket = net.connect({ host, port }, () => { + socket.write( + `GET ${parsed.pathname}${parsed.search} HTTP/1.1\r\nHost: ${parsed.host}\r\nConnection: close\r\n\r\n` + ); }); - - // The summary endpoint only needs the status code for liveness. Do not - // buffer the response body: a non-CCS loopback service can stream forever - // and keep discovery from returning a higher-priority hit. Release the - // response as soon as headers arrive. - try { - if (typeof body.resume === 'function') { - body.resume(); - } else if (typeof body.destroy === 'function') { - body.destroy(); - } - } catch { - // Closing the response is a best-effort cleanup; liveness depends only - // on the status code already received. - } - - return { ok: statusCode === 200 }; - } catch { - return { ok: false }; - } + socket.setTimeout(1500, () => finish()); + socket.on('data', (chunk) => { + buffer += chunk.toString('utf8'); + const match = buffer.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/); + if (match) finish(Number(match[1])); + }); + socket.on('error', () => finish()); + socket.on('end', () => finish()); + }); } const barJsonPort = resolveBarPort(ccsDir); @@ -83,9 +115,10 @@ export async function defaultFindRunningServer(ccsDir: string): Promise probe(t.url)); for (let i = 0; i < probeTargets.length; i++) { - if ((await probes[i]).ok) { + const result = await probes[i]; + if (result.ok) { const { port, baseUrl } = probeTargets[i]; - return { port, baseUrl }; + return { port, baseUrl, authRequired: result.authRequired }; } } return null; diff --git a/src/commands/bar/launch-subcommand.ts b/src/commands/bar/launch-subcommand.ts index 5a449638..a8b12556 100644 --- a/src/commands/bar/launch-subcommand.ts +++ b/src/commands/bar/launch-subcommand.ts @@ -19,6 +19,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import type { ChildProcess } from 'child_process'; import { getCcsDir } from '../../config/config-loader-facade'; import { getBarDir, @@ -71,7 +72,7 @@ export interface LaunchDeps { * Spawn the `ccs bar serve --port N` process detached and return immediately. * The spawned process must be unref()ed so the launcher can exit. */ - spawnDetachedServer: (port: number, logPath: string) => void; + spawnDetachedServer: (port: number, logPath: string) => ChildProcess | void; /** * Poll GET {baseUrl}/api/bar/summary until HTTP 200 or timeout. * Returns the live baseUrl on success, throws on timeout. @@ -104,7 +105,7 @@ async function defaultGetPort(opts: { port: number[]; host: string }): Promise { - const { request } = await import('undici'); + const net = await import('net'); const INTERVAL_MS = 250; const TIMEOUT_MS = 10_000; const deadline = Date.now() + TIMEOUT_MS; - while (Date.now() < deadline) { - try { - const { statusCode, body } = await request(`${baseUrl}/api/bar/summary`, { - method: 'GET', - headersTimeout: 1500, - bodyTimeout: 1500, + async function probe(): Promise { + const url = new URL(`${baseUrl}/api/bar/summary`); + return new Promise((resolve) => { + let buffer = ''; + let settled = false; + const finish = (statusCode: number | null = null) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(statusCode); + }; + const socket = net.connect( + { host: url.hostname.replace(/^\[|\]$/g, ''), port: Number(url.port) }, + () => { + socket.write( + `GET ${url.pathname}${url.search} HTTP/1.1\r\nHost: ${url.host}\r\nConnection: close\r\n\r\n` + ); + } + ); + socket.setTimeout(1500, () => finish()); + socket.on('data', (chunk) => { + buffer += chunk.toString('utf8'); + const match = buffer.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/); + if (match) finish(Number(match[1])); }); - await body.text(); - if (statusCode === 200) return; - } catch { - /* not yet live — keep polling */ + socket.on('error', () => finish()); + socket.on('end', () => finish()); + }); + } + + while (Date.now() < deadline) { + const statusCode = await probe(); + + if (statusCode === 200) return; + if (statusCode !== null && isAuthRequiredStatus(statusCode)) { + throw new BarServerAuthRequiredError(baseUrl, statusCode); } + await new Promise((resolve) => setTimeout(resolve, INTERVAL_MS)); } @@ -198,6 +237,16 @@ export async function handleBarLaunch( } if (running !== null) { + if (running.authRequired) { + console.error( + `[X] CCS Bar cannot launch while dashboard authentication protects ${running.baseUrl}.` + ); + console.error( + '[i] Disable dashboard authentication for CCS Bar or start the dashboard manually.' + ); + return; + } + // Reuse the live server — write bar.json and open the app. const barJson: BarDiscoveryJson = { baseUrl: running.baseUrl, @@ -249,9 +298,10 @@ export async function handleBarLaunch( // 2c. Spawn the detached server. const serveLogPath = getServeLogPath(ccsDir); const baseUrl = `http://127.0.0.1:${port}`; + let spawnedChild: ChildProcess | void; try { fs.mkdirSync(getBarDir(ccsDir), { recursive: true }); - spawnDetachedServer(port, serveLogPath); + spawnedChild = spawnDetachedServer(port, serveLogPath); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[X] Could not start CCS web-server: ${msg}`); @@ -265,6 +315,16 @@ export async function handleBarLaunch( try { await waitForServerLive(baseUrl); } catch (err) { + if (err instanceof BarServerAuthRequiredError) { + spawnedChild?.kill(); + console.error( + `[X] CCS Bar cannot launch while dashboard authentication protects ${baseUrl}.` + ); + console.error( + '[i] Disable dashboard authentication for CCS Bar or start the dashboard manually.' + ); + return; + } const msg = err instanceof Error ? err.message : String(err); console.error(`[X] Could not connect to CCS web-server: ${msg}`); console.error(`[i] Check logs at ${serveLogPath}`); diff --git a/tests/unit/commands/bar-command.test.ts b/tests/unit/commands/bar-command.test.ts index bccee39c..1451a617 100644 --- a/tests/unit/commands/bar-command.test.ts +++ b/tests/unit/commands/bar-command.test.ts @@ -274,10 +274,18 @@ function makeDetachedDeps(ccsDir: string, port = 4242) { return { findRunningServer: async () => null, getPort: async () => port, - spawnDetachedServer: (_p: number, _log: string) => { /* noop */ }, - waitForServerLive: async (_url: string) => { /* live immediately */ }, - writeLaunchDescriptor: () => { /* noop */ }, - openApp: async (_appPath: string) => { /* noop */ }, + spawnDetachedServer: (_p: number, _log: string) => { + /* noop */ + }, + waitForServerLive: async (_url: string) => { + /* live immediately */ + }, + writeLaunchDescriptor: () => { + /* noop */ + }, + openApp: async (_appPath: string) => { + /* noop */ + }, getCcsDir: () => ccsDir, appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), }; @@ -501,7 +509,7 @@ describe('bar install subcommand', () => { downloadAndExtract: fakeExtract(appsDir), verifyCompat: async () => ({ compatible: false, reason: 'no-bar-api' }), readAppBundleVersion: (_appPath: string) => FAKE_VERSION, - isBarRunning: async () => false, + isBarRunning: async () => false, promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, @@ -848,7 +856,7 @@ describe('bar install: compat capability handshake', () => { throw new Error('network explosion'); }, readAppBundleVersion: (_appPath: string) => '1.4.0', - isBarRunning: async () => false, + isBarRunning: async () => false, promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, @@ -1597,10 +1605,18 @@ describe('launch: findRunningServer reuse-first (GH-1500)', () => { await handleBarLaunch([], { findRunningServer: async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }), getPort: async () => 9999, - spawnDetachedServer: () => { spawnCalled = true; }, - waitForServerLive: async () => { /* noop */ }, - writeLaunchDescriptor: () => { /* noop */ }, - openApp: async () => { /* noop */ }, + spawnDetachedServer: () => { + spawnCalled = true; + }, + waitForServerLive: async () => { + /* noop */ + }, + writeLaunchDescriptor: () => { + /* noop */ + }, + openApp: async () => { + /* noop */ + }, getCcsDir: () => ccsDir, appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), }); @@ -1628,10 +1644,18 @@ describe('launch: findRunningServer reuse-first (GH-1500)', () => { await handleBarLaunch([], { findRunningServer: async () => null, getPort: async () => 4242, - spawnDetachedServer: () => { spawnCalled = true; }, - waitForServerLive: async () => { /* live */ }, - writeLaunchDescriptor: () => { /* noop */ }, - openApp: async () => { /* noop */ }, + spawnDetachedServer: () => { + spawnCalled = true; + }, + waitForServerLive: async () => { + /* live */ + }, + writeLaunchDescriptor: () => { + /* noop */ + }, + openApp: async () => { + /* noop */ + }, getCcsDir: () => ccsDir, appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), }); @@ -1651,10 +1675,18 @@ describe('launch: findRunningServer reuse-first (GH-1500)', () => { throw new Error('probe exploded'); }, getPort: async () => 4242, - spawnDetachedServer: () => { spawnCalled = true; }, - waitForServerLive: async () => { /* live */ }, - writeLaunchDescriptor: () => { /* noop */ }, - openApp: async () => { /* noop */ }, + spawnDetachedServer: () => { + spawnCalled = true; + }, + waitForServerLive: async () => { + /* live */ + }, + writeLaunchDescriptor: () => { + /* noop */ + }, + openApp: async () => { + /* noop */ + }, getCcsDir: () => ccsDir, appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), }); @@ -1680,9 +1712,15 @@ describe('launch: bar.json contract (deterministic — GH-1500 null probe)', () await handleBarLaunch([], { findRunningServer: async () => null, getPort: async () => 4242, - spawnDetachedServer: () => { /* noop */ }, - waitForServerLive: async () => { /* live */ }, - writeLaunchDescriptor: () => { /* noop */ }, + spawnDetachedServer: () => { + /* noop */ + }, + waitForServerLive: async () => { + /* live */ + }, + writeLaunchDescriptor: () => { + /* noop */ + }, openApp: async (_appPath: string) => { calls.push(`open:${_appPath}`); }, @@ -2899,33 +2937,54 @@ describe('defaultFindRunningServer: streaming lower-priority probes', () => { JSON.stringify({ port: 41235, baseUrl: 'http://127.0.0.1:41235', authMode: 'loopback' }) ); - let highPriorityBodyDestroyed = false; + let highPrioritySocketDestroyed = false; let lowerPriorityProbeStarted = false; - mock.module('undici', () => ({ - request: (url: string) => { - if (url === 'http://127.0.0.1:41235/api/bar/summary') { - return Promise.resolve({ - statusCode: 200, - body: { - destroy: () => { - highPriorityBodyDestroyed = true; - }, - }, - }); - } + // The probe speaks raw HTTP/1.1 over a `net` socket and resolves on the + // status line, so mock `net.connect` rather than a higher-level client. + // The high-priority port (41235) answers HTTP 200 immediately; the + // lower-priority port (3000) connects but never sends a status line, + // emulating a non-CCS service that streams forever. + mock.module('net', () => ({ + connect: (opts: { host: string; port: number }, onConnect: () => void): unknown => { + // net.connect is called synchronously for every probe target, so the + // lower-priority probe is observably "started" the moment discovery + // fires it — even though it never receives a status line. + if (opts.port === 3000) lowerPriorityProbeStarted = true; + const listeners: Record void>> = {}; + const socket = { + on(event: string, cb: (arg?: unknown) => void) { + (listeners[event] ??= []).push(cb); + return socket; + }, + setTimeout() { + return socket; + }, + write() { + return true; + }, + destroy() { + if (opts.port === 41235) highPrioritySocketDestroyed = true; + return socket; + }, + }; - if (url === 'http://127.0.0.1:3000/api/bar/summary') { - lowerPriorityProbeStarted = true; - return new Promise(() => { - // Simulate a lower-priority service that never finishes responding. - }); - } - - return Promise.resolve({ - statusCode: 404, - body: { destroy: () => {} }, + // Fire the connect callback asynchronously, mirroring net.connect. + setImmediate(() => { + onConnect(); + if (opts.port === 41235) { + const data = listeners.data ?? []; + for (const cb of data) { + cb(Buffer.from('HTTP/1.1 200 OK\r\n\r\n', 'utf8')); + } + } + // Port 3000 never emits a status line: simulate an endlessly + // streaming service that must not block the higher-priority hit. + // Any other port stays silent and is settled by the 1.5s timeout, + // which the Promise.race below short-circuits. }); + + return socket; }, })); @@ -2933,7 +2992,9 @@ describe('defaultFindRunningServer: streaming lower-priority probes', () => { const { defaultFindRunningServer } = (await import( `../../../src/commands/bar/bar-server-probe?test=${Date.now()}-${moduleSeq}` )) as { - defaultFindRunningServer: (ccsDir: string) => Promise<{ port: number; baseUrl: string } | null>; + defaultFindRunningServer: ( + ccsDir: string + ) => Promise<{ port: number; baseUrl: string; authRequired?: boolean } | null>; }; const result = await Promise.race([ @@ -2941,8 +3002,12 @@ describe('defaultFindRunningServer: streaming lower-priority probes', () => { new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 250)), ]); - expect(result).toEqual({ port: 41235, baseUrl: 'http://127.0.0.1:41235' }); - expect(highPriorityBodyDestroyed).toBe(true); + expect(result).toEqual({ + port: 41235, + baseUrl: 'http://127.0.0.1:41235', + authRequired: false, + }); + expect(highPrioritySocketDestroyed).toBe(true); expect(lowerPriorityProbeStarted).toBe(true); }); }); diff --git a/tests/unit/commands/bar-lifecycle-subcommands.test.ts b/tests/unit/commands/bar-lifecycle-subcommands.test.ts index 97abd5e1..89ac7747 100644 --- a/tests/unit/commands/bar-lifecycle-subcommands.test.ts +++ b/tests/unit/commands/bar-lifecycle-subcommands.test.ts @@ -84,6 +84,7 @@ async function loadLaunchSubcommand() { ); return mod as { handleBarLaunch: (args: string[], deps?: Record) => Promise; + BarServerAuthRequiredError: new (baseUrl: string, statusCode: number) => Error; }; } @@ -611,6 +612,75 @@ describe('launch: detached-spawn model', () => { expect(allOutput()).toMatch(/timeout|connect|server/i); }); + it('stops the spawned child when the bar API is protected by dashboard auth', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + let killCalled = false; + const { handleBarLaunch, BarServerAuthRequiredError } = await loadLaunchSubcommand(); + + await handleBarLaunch([], { + getCcsDir: () => ccsDir, + findRunningServer: async () => null, + getPort: async () => 3000, + spawnDetachedServer: () => ({ + kill: () => { + killCalled = true; + return true; + }, + }), + waitForServerLive: async () => { + throw new BarServerAuthRequiredError('http://127.0.0.1:3000', 401); + }, + writeLaunchDescriptor: () => { + /* noop */ + }, + openApp: async () => { + /* noop */ + }, + appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), + }); + + expect(killCalled).toBe(true); + expect(fs.existsSync(path.join(ccsDir, 'bar.json'))).toBe(false); + expect(allOutput()).toMatch(/authentication/i); + }); + + it('does not spawn a new server when an existing server is protected by dashboard auth', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + let spawnCalled = false; + const { handleBarLaunch } = await loadLaunchSubcommand(); + + await handleBarLaunch([], { + getCcsDir: () => ccsDir, + findRunningServer: async () => ({ + port: 3000, + baseUrl: 'http://127.0.0.1:3000', + authRequired: true, + }), + getPort: async () => 3001, + spawnDetachedServer: () => { + spawnCalled = true; + }, + waitForServerLive: async () => { + /* noop */ + }, + writeLaunchDescriptor: () => { + /* noop */ + }, + openApp: async () => { + /* noop */ + }, + appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), + }); + + expect(spawnCalled).toBe(false); + expect(fs.existsSync(path.join(ccsDir, 'bar.json'))).toBe(false); + expect(allOutput()).toMatch(/authentication/i); + }); + it('writes launch.json via writeLaunchDescriptor on the start path', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true });