@principles/pd-cli 1.142.4 → 1.144.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,6 +21,7 @@ import * as http from 'http';
21
21
  // ─── Public types ────────────────────────────────────────────────────────────
22
22
 
23
23
  export type ConsoleStatus = 'reused' | 'started' | 'failed' | 'refused';
24
+ export type ConsoleAuthenticationMode = 'authenticated' | 'no_auth';
24
25
 
25
26
  export interface ConsoleLaunchResult {
26
27
  status: ConsoleStatus;
@@ -34,6 +35,8 @@ export interface ConsoleLaunchResult {
34
35
  reused: boolean;
35
36
  /** True when a browser should/has been opened (skipped in --json mode). */
36
37
  browserOpened: boolean;
38
+ /** Verified for successful launch/reuse; omitted when no server was reached. */
39
+ authenticationMode?: ConsoleAuthenticationMode;
37
40
  /**
38
41
  * PID of the freshly spawned console server process. Present only when
39
42
  * status === 'started'; absent on 'reused' (the server was started by
@@ -58,6 +61,10 @@ const DEFAULT_PORT = 3100;
58
61
  const DEFAULT_HOST = '127.0.0.1';
59
62
  const PORT_FALLBACK_LIMIT = 20; // try 3100..3119 before giving up
60
63
 
64
+ function isRecord(value: unknown): value is Record<string, unknown> {
65
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
66
+ }
67
+
61
68
  // ─── Loopback safety (ERR-049) ──────────────────────────────────────────────
62
69
 
63
70
  /** Returns true if the host resolves to a loopback address. */
@@ -138,13 +145,20 @@ export interface HealthProbeOptions {
138
145
  }
139
146
 
140
147
  /** Probe a port to see if it serves a healthy PD Console. */
141
- export async function probeConsoleHealth(opts: HealthProbeOptions): Promise<{ healthy: boolean; reason?: string }> {
148
+ export interface ConsoleHealthProbeResult {
149
+ healthy: boolean;
150
+ authenticationMode?: ConsoleAuthenticationMode;
151
+ reason?: string;
152
+ failureKind?: 'unauthorized' | 'invalid_response' | 'unreachable';
153
+ }
154
+
155
+ export async function probeConsoleHealth(opts: HealthProbeOptions): Promise<ConsoleHealthProbeResult> {
142
156
  const { host, port, timeoutMs = 1500, token } = opts;
143
157
 
144
158
  if (Object.hasOwn(globalThis, '__mockProbeConsoleHealth')) {
145
159
  const mock = Reflect.get(globalThis, '__mockProbeConsoleHealth') as (
146
160
  o: HealthProbeOptions
147
- ) => Promise<{ healthy: boolean; reason?: string }>;
161
+ ) => Promise<ConsoleHealthProbeResult>;
148
162
  return mock(opts);
149
163
  }
150
164
  return new Promise((resolve) => {
@@ -157,7 +171,7 @@ export async function probeConsoleHealth(opts: HealthProbeOptions): Promise<{ he
157
171
  (res) => {
158
172
  // 401 means auth required — treat as unhealthy, not a generic error
159
173
  if (res.statusCode === 401) {
160
- resolve({ healthy: false, reason: 'console health endpoint returned 401 (unauthorized) — check PD_CONSOLE_TOKEN' });
174
+ resolve({ healthy: false, failureKind: 'unauthorized', reason: 'console health endpoint returned 401 (unauthorized) — check PD_CONSOLE_TOKEN' });
161
175
  return;
162
176
  }
163
177
  if (res.statusCode !== 200) {
@@ -170,13 +184,19 @@ export async function probeConsoleHealth(opts: HealthProbeOptions): Promise<{ he
170
184
  });
171
185
  res.on('end', () => {
172
186
  try {
173
- const body = JSON.parse(data) as unknown;
174
- if (body && typeof body === 'object') {
187
+ const body: unknown = JSON.parse(data);
188
+ if (isRecord(body)) {
175
189
  const isHealthy =
176
190
  (Object.hasOwn(body, 'healthy') && Reflect.get(body, 'healthy') === true) ||
177
191
  (Object.hasOwn(body, 'success') && Reflect.get(body, 'success') === true);
178
192
  if (isHealthy) {
179
- resolve({ healthy: true });
193
+ const dataValue = Object.hasOwn(body, 'data') ? Reflect.get(body, 'data') : undefined;
194
+ const payloadRecord = isRecord(dataValue) ? dataValue : body;
195
+ const mode = Reflect.get(payloadRecord, 'authenticationMode');
196
+ resolve({
197
+ healthy: true,
198
+ ...(mode === 'authenticated' || mode === 'no_auth' ? { authenticationMode: mode } : {}),
199
+ });
180
200
  } else {
181
201
  resolve({ healthy: false, reason: 'console health JSON was missing healthy/success markers' });
182
202
  }
@@ -387,6 +407,17 @@ export async function planConsoleLaunch(input: OrchestratorInput): Promise<Orche
387
407
  // Step 1: Is there already a healthy console on the preferred port?
388
408
  const health = await probeConsoleHealth({ host, port: preferredPort, token });
389
409
  if (health.healthy) {
410
+ if (token && health.authenticationMode !== 'authenticated') {
411
+ return {
412
+ status: 'refused',
413
+ url: '',
414
+ port: preferredPort,
415
+ host,
416
+ reused: false,
417
+ reason: 'console_authentication_mode_mismatch',
418
+ nextAction: `Stop the Console on port ${preferredPort}, then retry so Companion can start an authenticated Console.`,
419
+ };
420
+ }
390
421
  return {
391
422
  status: 'reused',
392
423
  url: buildConsoleUrl(host, preferredPort),
@@ -396,6 +427,18 @@ export async function planConsoleLaunch(input: OrchestratorInput): Promise<Orche
396
427
  };
397
428
  }
398
429
 
430
+ if (token && health.failureKind === 'unauthorized') {
431
+ return {
432
+ status: 'refused',
433
+ url: '',
434
+ port: preferredPort,
435
+ host,
436
+ reused: false,
437
+ reason: 'console_authentication_failed',
438
+ nextAction: 'Verify PD_CONSOLE_TOKEN matches the running Console, stop that Console, then retry.',
439
+ };
440
+ }
441
+
399
442
  // Step 2: Is the preferred port simply occupied by something else?
400
443
  const preferredInUse = await isPortInUse(host, preferredPort);
401
444
 
@@ -311,6 +311,46 @@ describe('planConsoleLaunch — reused (healthy console on preferred port)', ()
311
311
  }
312
312
  });
313
313
 
314
+ it('does not reuse a verified no-auth Console when a token is configured', async () => {
315
+ const server = http.createServer((_req, res) => {
316
+ res.statusCode = 200;
317
+ res.end(JSON.stringify({ success: true, data: { authenticationMode: 'no_auth' } }));
318
+ });
319
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
320
+ const addr = server.address();
321
+ if (typeof addr !== 'object' || !addr) throw new Error('no addr');
322
+ try {
323
+ const result = await planConsoleLaunch({
324
+ workspaceDir: '/tmp/anywhere', preferredPort: addr.port,
325
+ host: '127.0.0.1', token: 'configured-token',
326
+ });
327
+ expect(result.status).toBe('refused');
328
+ expect(result.reason).toBe('console_authentication_mode_mismatch');
329
+ } finally {
330
+ await new Promise<void>((resolve) => server.close(resolve));
331
+ }
332
+ });
333
+
334
+ it('fails loud when the configured token is rejected by an existing Console', async () => {
335
+ const server = http.createServer((_req, res) => {
336
+ res.statusCode = 401;
337
+ res.end('unauthorized');
338
+ });
339
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
340
+ const addr = server.address();
341
+ if (typeof addr !== 'object' || !addr) throw new Error('no addr');
342
+ try {
343
+ const result = await planConsoleLaunch({
344
+ workspaceDir: '/tmp/anywhere', preferredPort: addr.port,
345
+ host: '127.0.0.1', token: 'wrong-token',
346
+ });
347
+ expect(result.status).toBe('refused');
348
+ expect(result.reason).toBe('console_authentication_failed');
349
+ } finally {
350
+ await new Promise<void>((resolve) => server.close(resolve));
351
+ }
352
+ });
353
+
314
354
  it('does NOT classify a non-console responder as reused', async () => {
315
355
  // Server returns 200 on any path but with a non-OK status code from /api/health
316
356
  const server = http.createServer((req, res) => {
@@ -387,6 +427,22 @@ describe('probeConsoleHealth', () => {
387
427
  }
388
428
  });
389
429
 
430
+ it('returns the verified authentication mode from the health contract', async () => {
431
+ const server = http.createServer((_req, res) => {
432
+ res.statusCode = 200;
433
+ res.end(JSON.stringify({ success: true, data: { authenticationMode: 'authenticated' } }));
434
+ });
435
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
436
+ const addr = server.address();
437
+ if (typeof addr !== 'object' || !addr) throw new Error('no addr');
438
+ try {
439
+ const h = await probeConsoleHealth({ host: '127.0.0.1', port: addr.port, token: 'token' });
440
+ expect(h).toMatchObject({ healthy: true, authenticationMode: 'authenticated' });
441
+ } finally {
442
+ await new Promise<void>((resolve) => server.close(resolve));
443
+ }
444
+ });
445
+
390
446
  it('returns healthy=false with reason for a server that returns 500', async () => {
391
447
  const server = http.createServer((req, res) => {
392
448
  res.statusCode = 500;
@@ -491,8 +547,11 @@ describe('CLI command wiring (pd console open)', () => {
491
547
  let cliPath: string;
492
548
  let workspaceRoot: string;
493
549
  let tmp: string;
550
+ let originalConsoleToken: string | undefined;
494
551
 
495
552
  beforeEach(() => {
553
+ originalConsoleToken = process.env.PD_CONSOLE_TOKEN;
554
+ delete process.env.PD_CONSOLE_TOKEN;
496
555
  workspaceRoot = path.resolve(__dirname, '../../../..');
497
556
  cliPath = path.join(workspaceRoot, 'packages', 'pd-cli', 'dist', 'index.js');
498
557
  tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-console-open-test-'));
@@ -520,7 +579,7 @@ describe('CLI command wiring (pd console open)', () => {
520
579
  const server = http.createServer((req, res) => {
521
580
  if (req.url === '/api/health') {
522
581
  res.writeHead(200, {'Content-Type': 'application/json'});
523
- res.end(JSON.stringify({success: true}));
582
+ res.end(JSON.stringify({success: true, data: {authenticationMode: 'no_auth'}}));
524
583
  } else {
525
584
  res.writeHead(404);
526
585
  res.end();
@@ -533,6 +592,8 @@ describe('CLI command wiring (pd console open)', () => {
533
592
  });
534
593
 
535
594
  afterEach(() => {
595
+ if (originalConsoleToken === undefined) delete process.env.PD_CONSOLE_TOKEN;
596
+ else process.env.PD_CONSOLE_TOKEN = originalConsoleToken;
536
597
  delete process.env.__PD_CONSOLE_TEST_FAKE_HOME;
537
598
  try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
538
599
  });
@@ -598,13 +659,32 @@ describe('CLI command wiring (pd console open)', () => {
598
659
  }
599
660
  }, 20_000);
600
661
 
662
+ it('kills and refuses a fresh Console whose reported authentication mode mismatches the configured token', async () => {
663
+ process.env.PD_CONSOLE_TOKEN = 'configured-token';
664
+ let run: CliJsonRun | undefined;
665
+ try {
666
+ run = await runPdUntilJson(
667
+ ['console', 'open', '--workspace', tmp, '--port', '49391', '--json', '--no-browser'],
668
+ workspaceRoot,
669
+ );
670
+ if (!isRecord(run.parsed)) throw new Error('CLI JSON output was not an object');
671
+ expect(run.parsed.status).toBe('refused');
672
+ expect(run.parsed.reason).toBe('console_authentication_mode_mismatch');
673
+ expect(run.parsed).not.toHaveProperty('serverPid');
674
+ await new Promise((resolve) => setTimeout(resolve, 100));
675
+ expect(await isPortInUse('127.0.0.1', 49391)).toBe(false);
676
+ } finally {
677
+ await teardownCliTree(run);
678
+ }
679
+ }, 20_000);
680
+
601
681
  it('pd console open --json reused path does NOT include serverPid (PRI-526)', async () => {
602
682
  // Stand up a fake healthy console in-process, then point the CLI at its
603
683
  // port: planConsoleLaunch probes /api/health → 200 → reused, no spawn.
604
684
  const server = http.createServer((req, res) => {
605
685
  if (req.url === '/api/health') {
606
686
  res.statusCode = 200;
607
- res.end(JSON.stringify({ success: true }));
687
+ res.end(JSON.stringify({ success: true, data: { authenticationMode: 'no_auth' } }));
608
688
  return;
609
689
  }
610
690
  res.statusCode = 404;
@@ -622,6 +702,7 @@ describe('CLI command wiring (pd console open)', () => {
622
702
  if (!isRecord(run.parsed)) throw new Error('CLI JSON output was not an object');
623
703
  expect(run.parsed.status).toBe('reused');
624
704
  expect(run.parsed.reused).toBe(true);
705
+ expect(run.parsed.authenticationMode).toBe('no_auth');
625
706
  expect(run.parsed).not.toHaveProperty('serverPid');
626
707
  } finally {
627
708
  await teardownCliTree(run);