@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.
- package/dist/commands/codex-reconcile.d.ts +22 -0
- package/dist/commands/codex-reconcile.d.ts.map +1 -0
- package/dist/commands/codex-reconcile.js +61 -0
- package/dist/commands/codex-reconcile.js.map +1 -0
- package/dist/commands/console.d.ts.map +1 -1
- package/dist/commands/console.js +34 -1
- package/dist/commands/console.js.map +1 -1
- package/dist/commands/pain-retry.d.ts.map +1 -1
- package/dist/commands/pain-retry.js +4 -2
- package/dist/commands/pain-retry.js.map +1 -1
- package/dist/commands/runtime-internalization-retry.d.ts.map +1 -1
- package/dist/commands/runtime-internalization-retry.js +14 -0
- package/dist/commands/runtime-internalization-retry.js.map +1 -1
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -1
- package/dist/services/console-launcher.d.ts +8 -2
- package/dist/services/console-launcher.d.ts.map +1 -1
- package/dist/services/console-launcher.js +34 -4
- package/dist/services/console-launcher.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/codex-reconcile.ts +84 -0
- package/src/commands/console.ts +35 -2
- package/src/commands/pain-retry.ts +4 -2
- package/src/commands/runtime-internalization-retry.ts +14 -0
- package/src/index.ts +25 -0
- package/src/services/console-launcher.ts +49 -6
- package/tests/commands/console-open.test.ts +83 -2
|
@@ -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
|
|
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<
|
|
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)
|
|
174
|
-
if (body
|
|
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
|
-
|
|
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);
|