@principles/pd-cli 1.147.5 → 1.147.7

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.
Files changed (32) hide show
  1. package/dist/commands/codex-ingest-quarantine.d.ts +12 -0
  2. package/dist/commands/codex-ingest-quarantine.d.ts.map +1 -0
  3. package/dist/commands/codex-ingest-quarantine.js +116 -0
  4. package/dist/commands/codex-ingest-quarantine.js.map +1 -0
  5. package/dist/commands/codex-setup.d.ts +46 -0
  6. package/dist/commands/codex-setup.d.ts.map +1 -0
  7. package/dist/commands/codex-setup.js +423 -0
  8. package/dist/commands/codex-setup.js.map +1 -0
  9. package/dist/commands/console.d.ts.map +1 -1
  10. package/dist/commands/console.js +12 -2
  11. package/dist/commands/console.js.map +1 -1
  12. package/dist/commands/health-codex.d.ts.map +1 -1
  13. package/dist/commands/health-codex.js +350 -67
  14. package/dist/commands/health-codex.js.map +1 -1
  15. package/dist/index.js +51 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/services/console-launcher.d.ts.map +1 -1
  18. package/dist/services/console-launcher.js +8 -1
  19. package/dist/services/console-launcher.js.map +1 -1
  20. package/package.json +1 -1
  21. package/src/commands/codex-ingest-quarantine.ts +127 -0
  22. package/src/commands/codex-setup.ts +476 -0
  23. package/src/commands/console.ts +13 -2
  24. package/src/commands/health-codex.ts +396 -69
  25. package/src/index.ts +53 -0
  26. package/src/services/console-launcher.ts +9 -1
  27. package/tests/commands/codex-consent-reversibility.steps.test.ts +299 -0
  28. package/tests/commands/codex-ingest-quarantine.test.ts +136 -0
  29. package/tests/commands/codex-setup.test.ts +297 -0
  30. package/tests/commands/codex-worker-registration.test.ts +29 -0
  31. package/tests/commands/console-open.test.ts +31 -0
  32. package/tests/commands/health-codex.test.ts +278 -172
@@ -0,0 +1,476 @@
1
+ /**
2
+ * pd codex setup — consent UX for Codex conversation ingestion (Codex
3
+ * Governance Closure Slice D, PRI-625; SPEC rev 2 §17; G2A frozen disclosure).
4
+ *
5
+ * The ONE authority for enabling `codex_conversation_ingestion`: presents the
6
+ * G2A-frozen disclosure text verbatim (Chinese SSoT; optional English
7
+ * rendering) and records the Owner's explicit decision BEFORE the flag is
8
+ * flipped. Declining leaves the flag off, leaves every existing
9
+ * prompt/RuleHost/tool governance surface untouched, and never opens a
10
+ * transcript (this command performs no Codex-home I/O at all). Upgrade paths
11
+ * never call this command, so upgrading can never enable ingestion.
12
+ *
13
+ * Modes:
14
+ * - default: print disclosure, then interactive prompt (TTY) for an explicit
15
+ * yes/no; EOF/abort mutates nothing and records nothing.
16
+ * - --accept / --decline: non-interactive explicit decision (the plugin's
17
+ * $pd-setup presents the disclosure itself, then calls one of these).
18
+ * - --show-disclosure: print the frozen text for the requested language and
19
+ * exit; no mutation, no record.
20
+ *
21
+ * Config writes are line-targeted edits of .pd/config.yaml (comments
22
+ * preserved), atomic, and round-trip verified: if the rewritten config does
23
+ * not validate with the flag at the intended value, the previous content is
24
+ * restored and the failure is loud — a half-consented state is impossible.
25
+ *
26
+ * CLI gate compliance:
27
+ * - cli-1: --json outputs exactly one parseable JSON object on stdout.
28
+ * - cli-2: exit paths stop execution.
29
+ * - cli-4: --accept and --decline are mutually exclusive.
30
+ * - cli-5: refused/failed runs mutate nothing (consent + flag both untouched).
31
+ * - cli-6: every refusal/degradation carries reason + nextAction.
32
+ */
33
+ import * as fs from 'node:fs';
34
+ import * as path from 'node:path';
35
+ import * as readline from 'node:readline/promises';
36
+ import { resolveWorkspaceDir } from '../resolve-workspace.js';
37
+ import {
38
+ CODEX_INGESTION_DISCLOSURE_VERSION,
39
+ getCodexIngestionDisclosureText,
40
+ deriveCodexIngestionConsentState,
41
+ getPdConfigPath,
42
+ loadPdConfigForPlugin,
43
+ readCodexIngestionConsent,
44
+ recordCodexIngestionConsent,
45
+ type CodexIngestionConsentDecision,
46
+ type CodexIngestionConsentState,
47
+ type CodexIngestionDisclosureLanguage,
48
+ } from '@principles/host-runtime';
49
+ import { computeEffectivePdConfig, computeFeatureFlagsFromConfig, isFeatureEnabled } from '@principles/core/runtime-v2';
50
+
51
+ export interface CodexSetupOptions {
52
+ workspace?: string;
53
+ json?: boolean;
54
+ lang?: string;
55
+ accept?: boolean;
56
+ decline?: boolean;
57
+ showDisclosure?: boolean;
58
+ }
59
+
60
+ export interface CodexSetupReport {
61
+ generatedAt: string;
62
+ host: 'codex';
63
+ workspace: string;
64
+ status: 'ok' | 'degraded';
65
+ decision?: CodexIngestionConsentDecision;
66
+ consentStateBefore: CodexIngestionConsentState;
67
+ consentState: CodexIngestionConsentState;
68
+ disclosureVersion: string;
69
+ ingestionFlag: { name: 'codex_conversation_ingestion'; enabled: boolean; source: string };
70
+ hostCodexFlagEnabled: boolean;
71
+ warnings: string[];
72
+ reason?: string;
73
+ nextAction?: string;
74
+ }
75
+
76
+ // ── Targeted config.yaml flag edit ───────────────────────────────────────────
77
+
78
+ type FlagWriteResult = { ok: true; enabled: boolean } | { ok: false; reason: string; nextAction: string };
79
+
80
+ const FEATURES_LINE = /^features:\s*(?:#.*)?$/;
81
+ const INGESTION_KEY_LINE = /^ {2}codex_conversation_ingestion:\s*(?:#.*)?$/;
82
+ const INGESTION_ENABLED_LINE = /^ {4}enabled:\s*(?:true|false)\s*(?:#.*)?$/;
83
+
84
+ function stripYamlComment(line: string): string {
85
+ const hash = line.indexOf('#');
86
+ return (hash === -1 ? line : line.slice(0, hash)).trim();
87
+ }
88
+
89
+ function isEmptyFeaturesMapping(line: string): boolean {
90
+ // Matches `features: {}` (with optional YAML comment) without a regex
91
+ // literal over brace syntax.
92
+ return stripYamlComment(line).replace(/\s+/g, '') === 'features:' + String.fromCharCode(123, 125);
93
+ }
94
+
95
+ function trailingComment(line: string): string {
96
+ // ' #' starts a plain-scalar YAML comment on these simple enabled lines.
97
+ const hash = line.indexOf(' #');
98
+ return hash === -1 ? '' : ' ' + line.slice(hash + 1);
99
+ }
100
+
101
+ /**
102
+ * Enable/disable `features.codex_conversation_ingestion.enabled` in the
103
+ * workspace config.yaml with a line-targeted edit that preserves all other
104
+ * content (comments included). Atomic write; round-trip verified with the
105
+ * production loader, restored on any mismatch.
106
+ */
107
+ export function setCodexConversationIngestionFlag(workspaceDir: string, enabled: boolean): FlagWriteResult {
108
+ const configPath = getPdConfigPath(workspaceDir);
109
+ let raw: string;
110
+ try {
111
+ raw = fs.readFileSync(configPath, 'utf8');
112
+ } catch (error) {
113
+ const code = typeof error === 'object' && error !== null && Object.hasOwn(error, 'code')
114
+ ? String((error as Record<string, unknown>).code)
115
+ : String(error);
116
+ return {
117
+ ok: false,
118
+ reason: 'config_unreadable: ' + code,
119
+ nextAction: 'Run `pd runtime init --confirm` in this workspace to create .pd/config.yaml, then re-run `pd codex setup`.',
120
+ };
121
+ }
122
+ const lineEnding = raw.includes('\r\n') ? '\r\n' : '\n';
123
+ const lines = raw.replace(/\r\n/g, '\n').split('\n');
124
+
125
+ // Every feature override requires category+enabled (validatePdConfig), so
126
+ // inserted blocks carry the registry's current category for this flag.
127
+ const registryCategory =
128
+ computeFeatureFlagsFromConfig(computeEffectivePdConfig(null)).flags.codex_conversation_ingestion?.category ?? 'quiet';
129
+ // A bare `features:` line and an inline empty mapping both count as the
130
+ // features section; anything else is a mapping with entries.
131
+ const featuresIndex = lines.findIndex((line) => FEATURES_LINE.test(line) || isEmptyFeaturesMapping(line));
132
+ const blockLines = [
133
+ ' codex_conversation_ingestion:',
134
+ ' category: ' + registryCategory,
135
+ ' enabled: ' + String(enabled),
136
+ ];
137
+
138
+ let mutated = false;
139
+ if (featuresIndex === -1) {
140
+ // Unreachable behind the workspace_config gate (validatePdConfig requires
141
+ // a features section); refuse loudly instead of inventing one (cli-5).
142
+ return {
143
+ ok: false,
144
+ reason: 'config_features_section_missing',
145
+ nextAction: 'Add a features section to ' + configPath + ' and re-run `pd codex setup`; config.yaml was left unchanged.',
146
+ };
147
+ }
148
+ const featuresLine: string | undefined = lines[featuresIndex];
149
+ if (featuresLine === undefined) {
150
+ return { ok: false, reason: 'config_features_line_undefined', nextAction: 'config.yaml was left unchanged; re-run `pd codex setup`.' };
151
+ }
152
+ if (isEmptyFeaturesMapping(featuresLine)) {
153
+ // The inline empty mapping IS the features key — keep the parent key and
154
+ // expand its children in place.
155
+ lines.splice(featuresIndex, 1, 'features:', ...blockLines);
156
+ mutated = true;
157
+ } else {
158
+ // Find the ingestion key's block under features: (2-space indent) and the
159
+ // end of that block (next 2-space-indented key or next top-level key).
160
+ let keyIndex = -1;
161
+ let blockEnd = lines.length;
162
+ for (let i = featuresIndex + 1; i < lines.length; i += 1) {
163
+ const line: string | undefined = lines[i];
164
+ if (line === undefined || line.trim() === '') continue;
165
+ if (INGESTION_KEY_LINE.test(line)) {
166
+ keyIndex = i;
167
+ continue;
168
+ }
169
+ if (keyIndex !== -1 && /^( {0,1}\S| {2}\S)/.test(line)) {
170
+ blockEnd = i;
171
+ break;
172
+ }
173
+ }
174
+ if (keyIndex === -1) {
175
+ lines.splice(featuresIndex + 1, 0, ...blockLines);
176
+ mutated = true;
177
+ } else {
178
+ // enabled-line search: found → rewrite (or keep); NOT found → insert.
179
+ // `enabledFound` is tracked separately from `mutated` so an existing
180
+ // line that already carries the target value does NOT trigger a
181
+ // second insertion (duplicate-key bug, review round 3).
182
+ let enabledFound = false;
183
+ for (let i = keyIndex + 1; i < blockEnd; i += 1) {
184
+ const current: string | undefined = lines[i];
185
+ if (current === undefined || !INGESTION_ENABLED_LINE.test(current)) continue;
186
+ enabledFound = true;
187
+ const next = ' enabled: ' + String(enabled) + trailingComment(current);
188
+ if (current !== next) {
189
+ lines[i] = next;
190
+ mutated = true;
191
+ }
192
+ break;
193
+ }
194
+ if (!enabledFound) {
195
+ // Key exists but no enabled line inside its block — add it as the
196
+ // first entry of the block so the mapping is not folded into a sibling.
197
+ lines.splice(keyIndex + 1, 0, ' enabled: ' + String(enabled));
198
+ mutated = true;
199
+ }
200
+ }
201
+ }
202
+
203
+ if (!mutated) {
204
+ return { ok: true, enabled };
205
+ }
206
+ const nextContent = lines.join(lineEnding);
207
+ const tmpPath = configPath + '.tmp-setup-' + String(process.pid) + '-' + String(Date.now());
208
+ try {
209
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
210
+ fs.writeFileSync(tmpPath, nextContent, { encoding: 'utf8' });
211
+ fs.renameSync(tmpPath, configPath);
212
+ } catch (error) {
213
+ const message = error instanceof Error ? error.message : String(error);
214
+ try {
215
+ fs.rmSync(tmpPath, { force: true });
216
+ } catch {
217
+ // best-effort cleanup; the failure below is the loud signal
218
+ }
219
+ return {
220
+ ok: false,
221
+ reason: 'config_write_failed: ' + message.slice(0, 160),
222
+ nextAction: 'Check permissions on ' + path.dirname(configPath) + '; config.yaml was left unchanged.',
223
+ };
224
+ }
225
+ // Round-trip: the production loader must validate the rewrite AND report
226
+ // the flag at the intended value. Anything else → restore, fail loud.
227
+ const verification = loadPdConfigForPlugin(workspaceDir);
228
+ const verifiedEnabled = verification.ok
229
+ ? isFeatureEnabled(computeFeatureFlagsFromConfig(verification.effective), 'codex_conversation_ingestion')
230
+ : undefined;
231
+ if (!verification.ok || verifiedEnabled !== enabled) {
232
+ try {
233
+ fs.writeFileSync(configPath, raw, { encoding: 'utf8' });
234
+ } catch {
235
+ // restoration failure must not mask the primary failure
236
+ }
237
+ return {
238
+ ok: false,
239
+ reason: verification.ok ? 'config_roundtrip_mismatch' : 'config_roundtrip_invalid: ' + (verification.errors[0]?.reason ?? 'unknown'),
240
+ nextAction: 'config.yaml was restored to its previous content. Fix the features block manually in ' + configPath + ' and re-run `pd codex setup`.',
241
+ };
242
+ }
243
+ return { ok: true, enabled };
244
+ }
245
+
246
+ // ── Interactive decision ─────────────────────────────────────────────────────
247
+
248
+ async function promptExplicitDecision(disclosure: string): Promise<'granted' | 'declined' | 'aborted'> {
249
+ process.stdout.write(disclosure);
250
+ process.stdout.write(
251
+ '\n按上述说明做出选择(y = 开启对话观察并写入治理闭环 / n = 拒绝,保持关闭)。\n' +
252
+ 'Make your choice per the disclosure above (y = enable / n = decline, keep off):\n> ',
253
+ );
254
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
255
+ try {
256
+ const answer = (await rl.question('')).trim().toLowerCase();
257
+ if (answer === 'y' || answer === 'yes') return 'granted';
258
+ if (answer === 'n' || answer === 'no') return 'declined';
259
+ return 'aborted';
260
+ } finally {
261
+ rl.close();
262
+ }
263
+ }
264
+
265
+ // ── Command handler ──────────────────────────────────────────────────────────
266
+
267
+ function languageFrom(lang: string | undefined): CodexIngestionDisclosureLanguage {
268
+ return lang === 'en' ? 'en' : 'zh';
269
+ }
270
+
271
+ export async function handleCodexSetup(options: CodexSetupOptions): Promise<void> {
272
+ const generatedAt = new Date().toISOString();
273
+
274
+ if (options.showDisclosure) {
275
+ process.stdout.write(getCodexIngestionDisclosureText(languageFrom(options.lang)));
276
+ process.stdout.write('\n');
277
+ return;
278
+ }
279
+
280
+ const workspace = resolveWorkspaceDir(options.workspace);
281
+ const warnings: string[] = [];
282
+ const finish = (report: CodexSetupReport): void => {
283
+ if (options.json) {
284
+ // cli-1: exactly one parseable JSON object on stdout.
285
+ console.log(JSON.stringify(report));
286
+ } else {
287
+ const lines = [
288
+ 'Codex conversation-ingestion setup (' + report.workspace + ')',
289
+ ' status: ' + report.status,
290
+ ' disclosureVersion: ' + report.disclosureVersion,
291
+ ' consent (before): ' + report.consentStateBefore,
292
+ ' consent (now): ' + report.consentState,
293
+ ' ingestion flag: ' + String(report.ingestionFlag.enabled) + ' (source: ' + report.ingestionFlag.source + ')',
294
+ ' host.codex flag: ' + String(report.hostCodexFlagEnabled),
295
+ ];
296
+ for (const warning of report.warnings) lines.push(' warning: ' + warning);
297
+ if (report.reason !== undefined) lines.push(' reason: ' + report.reason);
298
+ if (report.nextAction !== undefined) lines.push(' next action: ' + report.nextAction);
299
+ console.log(lines.join('\n'));
300
+ }
301
+ if (report.status === 'degraded') process.exitCode = 1;
302
+ };
303
+
304
+ const refuse = (reason: string, nextAction: string, consentStateBefore: CodexIngestionConsentState = 'not_present'): void => {
305
+ finish({
306
+ generatedAt, host: 'codex', workspace, status: 'degraded',
307
+ consentStateBefore, consentState: consentStateBefore, disclosureVersion: CODEX_INGESTION_DISCLOSURE_VERSION,
308
+ ingestionFlag: { name: 'codex_conversation_ingestion', enabled: false, source: 'unknown' },
309
+ hostCodexFlagEnabled: false, warnings, reason, nextAction,
310
+ });
311
+ };
312
+
313
+ // cli-4: explicit mutual exclusion.
314
+ if (options.accept && options.decline) {
315
+ refuse('accept_decline_mutex', 'Pass either --accept or --decline, not both.');
316
+ return;
317
+ }
318
+
319
+ // Workspace must exist as a PD workspace (config present) before anything
320
+ // can be consented or mutated (cli-5).
321
+ const configPath = getPdConfigPath(workspace);
322
+ if (!fs.existsSync(configPath)) {
323
+ refuse('workspace_config_not_found', 'No .pd/config.yaml at ' + workspace + '. Run `pd runtime init --confirm` first, then re-run `pd codex setup`.');
324
+ return;
325
+ }
326
+ const configLoad = loadPdConfigForPlugin(workspace);
327
+ if (!configLoad.ok) {
328
+ refuse(
329
+ 'workspace_config_malformed: ' + (configLoad.errors[0]?.reason ?? 'unknown'),
330
+ 'Fix .pd/config.yaml first (' + (configLoad.errors[0]?.nextAction ?? 'fix YAML syntax') + '); PD will not consent or mutate a config it cannot validate.',
331
+ );
332
+ return;
333
+ }
334
+ const flags = computeFeatureFlagsFromConfig(configLoad.effective);
335
+ const ingestionEnabledBefore = isFeatureEnabled(flags, 'codex_conversation_ingestion');
336
+ const hostCodexEnabled = isFeatureEnabled(flags, 'host.codex');
337
+ const flagSource = configLoad.source;
338
+
339
+ const consentRead = readCodexIngestionConsent(workspace);
340
+ if (!consentRead.ok) {
341
+ refuse(consentRead.reason, consentRead.nextAction);
342
+ return;
343
+ }
344
+ const consentStateBefore = deriveCodexIngestionConsentState(consentRead.record, ingestionEnabledBefore);
345
+
346
+ // Resolve the decision.
347
+ let decision: 'granted' | 'declined' | 'aborted';
348
+ if (options.accept) decision = 'granted';
349
+ else if (options.decline) decision = 'declined';
350
+ else if (options.json) {
351
+ refuse('decision_required', 'Machine mode must state the decision explicitly: re-run with --accept or --decline (use --show-disclosure to print the frozen text for presentation first).');
352
+ return;
353
+ } else if (process.stdin.isTTY) {
354
+ decision = await promptExplicitDecision(getCodexIngestionDisclosureText(languageFrom(options.lang)) + '\n');
355
+ if (decision === 'aborted') {
356
+ refuse('decision_aborted', 'Nothing was changed or recorded. Re-run `pd codex setup` to see the disclosure again.');
357
+ return;
358
+ }
359
+ } else {
360
+ refuse('decision_required', 'No TTY available for the interactive prompt. Present the disclosure (`pd codex setup --show-disclosure`), then re-run with --accept or --decline.');
361
+ return;
362
+ }
363
+
364
+ // Consent state machine (review round 2): the record must always explain
365
+ // the flag. `granted` is NEVER recorded before the runtime activation has
366
+ // actually succeeded.
367
+ //
368
+ // ACCEPT: record 'pending' → enable flag → 'granted' on success, 'failed'
369
+ // (with the activation reason) on failure.
370
+ // DECLINE: force the flag OFF first → 'revoked' after the flag is off;
371
+ // if the flag-off write fails → 'failed' (a revoked record beside a live
372
+ // flag would be unexplainable). If the flag was already off, decline is a
373
+ // pure record write.
374
+ const recordConsent = (decisionState: CodexIngestionConsentDecision, failureReason?: string) =>
375
+ recordCodexIngestionConsent(workspace, {
376
+ decision: decisionState,
377
+ decidedVia: 'pd_codex_setup',
378
+ ...(failureReason !== undefined ? { failureReason } : {}),
379
+ });
380
+
381
+ if (decision === 'declined') {
382
+ let flagResult: FlagWriteResult = { ok: true, enabled: ingestionEnabledBefore };
383
+ if (ingestionEnabledBefore) {
384
+ flagResult = setCodexConversationIngestionFlag(workspace, false);
385
+ }
386
+ if (!flagResult.ok) {
387
+ const failed = recordConsent('failed', 'decline could not disable the ingestion flag: ' + flagResult.reason);
388
+ finish({
389
+ generatedAt, host: 'codex', workspace, status: 'degraded',
390
+ decision: 'failed', consentStateBefore,
391
+ consentState: failed.ok ? 'failed' : consentStateBefore,
392
+ disclosureVersion: CODEX_INGESTION_DISCLOSURE_VERSION,
393
+ ingestionFlag: { name: 'codex_conversation_ingestion', enabled: true, source: flagSource },
394
+ hostCodexFlagEnabled: hostCodexEnabled, warnings,
395
+ reason: flagResult.reason, nextAction: flagResult.nextAction,
396
+ });
397
+ return;
398
+ }
399
+ const revoked = recordConsent('revoked');
400
+ if (!revoked.ok) {
401
+ refuse(revoked.reason, revoked.nextAction, consentStateBefore);
402
+ return;
403
+ }
404
+ finish({
405
+ generatedAt, host: 'codex', workspace, status: 'ok',
406
+ decision: 'revoked',
407
+ consentStateBefore,
408
+ consentState: deriveCodexIngestionConsentState(revoked.record, false),
409
+ disclosureVersion: revoked.record.disclosureVersion,
410
+ ingestionFlag: { name: 'codex_conversation_ingestion', enabled: false, source: flagSource },
411
+ hostCodexFlagEnabled: hostCodexEnabled,
412
+ warnings,
413
+ nextAction: 'Ingestion stays off; prompt injection, RuleHost, and tool governance are unchanged. No transcript was or will be read.',
414
+ });
415
+ return;
416
+ }
417
+
418
+ // ACCEPT flow.
419
+ const pending = recordConsent('pending');
420
+ if (!pending.ok) {
421
+ refuse(pending.reason, pending.nextAction, consentStateBefore);
422
+ return;
423
+ }
424
+
425
+ let flagResult: FlagWriteResult = { ok: true, enabled: ingestionEnabledBefore };
426
+ if (!ingestionEnabledBefore) {
427
+ flagResult = setCodexConversationIngestionFlag(workspace, true);
428
+ }
429
+ if (!flagResult.ok) {
430
+ // Activation failed: consent must NOT be granted. Record the explained
431
+ // failure and report degraded — flag stays off, state stays interpretable.
432
+ const failed = recordConsent('failed', 'flag activation failed: ' + flagResult.reason);
433
+ finish({
434
+ generatedAt, host: 'codex', workspace, status: 'degraded',
435
+ decision: 'failed', consentStateBefore,
436
+ consentState: failed.ok ? 'failed' : 'pending',
437
+ disclosureVersion: CODEX_INGESTION_DISCLOSURE_VERSION,
438
+ ingestionFlag: { name: 'codex_conversation_ingestion', enabled: false, source: flagSource },
439
+ hostCodexFlagEnabled: hostCodexEnabled, warnings,
440
+ reason: flagResult.reason, nextAction: flagResult.nextAction,
441
+ });
442
+ return;
443
+ }
444
+
445
+ const granted = recordConsent('granted');
446
+ if (!granted.ok) {
447
+ // The flag IS enabled but the terminal write failed — this is exactly the
448
+ // 'pending + flag on' state: explainable, and health blocks on it.
449
+ finish({
450
+ generatedAt, host: 'codex', workspace, status: 'degraded',
451
+ decision: 'pending', consentStateBefore,
452
+ consentState: 'pending',
453
+ disclosureVersion: CODEX_INGESTION_DISCLOSURE_VERSION,
454
+ ingestionFlag: { name: 'codex_conversation_ingestion', enabled: true, source: flagSource },
455
+ hostCodexFlagEnabled: hostCodexEnabled, warnings,
456
+ reason: granted.reason, nextAction: granted.nextAction,
457
+ });
458
+ return;
459
+ }
460
+
461
+ const nextWarnings = [...warnings];
462
+ if (!hostCodexEnabled) {
463
+ nextWarnings.push('host.codex is disabled — ingestion stays inactive until features.host.codex.enabled=true; enable it explicitly if this workspace should run Codex governance at all.');
464
+ }
465
+
466
+ finish({
467
+ generatedAt, host: 'codex', workspace, status: 'ok',
468
+ decision: 'granted',
469
+ consentStateBefore,
470
+ consentState: deriveCodexIngestionConsentState(granted.record, true),
471
+ disclosureVersion: granted.record.disclosureVersion,
472
+ ingestionFlag: { name: 'codex_conversation_ingestion', enabled: true, source: flagSource },
473
+ hostCodexFlagEnabled: hostCodexEnabled,
474
+ warnings: nextWarnings,
475
+ });
476
+ }
@@ -699,8 +699,17 @@ export async function handleConsoleOpen(opts: ConsoleOpenOptions = {}): Promise<
699
699
  return;
700
700
  }
701
701
 
702
+ // PRI-695: the caller's token presence decides the REQUIRED mode, but a
703
+ // tokenless caller must not refuse a no_auth console — that combination is
704
+ // the default post-install state (the installer auto-launches with
705
+ // --no-auth) and the documented reopen command has no flags. Refusal is
706
+ // reserved for the genuinely incompatible combination: a caller that
707
+ // expects authenticated access against a no_auth server (governance writes
708
+ // would be unauthenticated without the user realizing it).
702
709
  const expectedAuthenticationMode = token?.trim() ? 'authenticated' : 'no_auth';
703
- if (readyAuthenticationMode !== expectedAuthenticationMode) {
710
+ const authMismatch = readyAuthenticationMode !== expectedAuthenticationMode;
711
+ const tokenlessCallerOnNoAuthServer = !token?.trim() && readyAuthenticationMode === 'no_auth';
712
+ if (authMismatch && !tokenlessCallerOnNoAuthServer) {
704
713
  try { child.kill('SIGTERM'); } catch { /* child may already have exited */ }
705
714
  const result: ConsoleLaunchResult = {
706
715
  status: 'refused',
@@ -712,7 +721,9 @@ export async function handleConsoleOpen(opts: ConsoleOpenOptions = {}): Promise<
712
721
  browserOpened: false,
713
722
  authenticationMode: readyAuthenticationMode,
714
723
  reason: 'console_authentication_mode_mismatch',
715
- nextAction: 'Stop the Console, verify PD_CONSOLE_TOKEN propagation, and retry.',
724
+ nextAction:
725
+ 'The Console is running without authentication (auto-launched default). ' +
726
+ 'Reopen it without a token to reuse it, or stop it and start with --token for authenticated access.',
716
727
  };
717
728
  if (opts.json) {
718
729
  console.log(JSON.stringify(result, null, 2));