@withtruing/companion 0.0.0-stage → 0.13.1

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.
@@ -0,0 +1,536 @@
1
+ /**
2
+ * Model-only Codex CLI transport. The companion owns every filesystem read and sends Codex text;
3
+ * Codex is invoked in a fresh empty directory with every known tool surface disabled.
4
+ */
5
+ import spawn from 'cross-spawn';
6
+ import { access, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
7
+ import { homedir, tmpdir } from 'node:os';
8
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
9
+ import { createInterface } from 'node:readline';
10
+ import { StringDecoder } from 'node:string_decoder';
11
+ import { assertSingleLineArguments } from './cli-arguments.js';
12
+ import { gitErrorMessage } from './sandbox.js';
13
+ import { stripIntroducedNulls, toCodexStrictSchema } from './strict-schema.js';
14
+ const WALL_CLOCK_TIMEOUT_MS = 10 * 60 * 1000;
15
+ const STDOUT_SAMPLE_CHARS = 200;
16
+ const UNPARSEABLE_LINE_SAMPLE_CHARS = 120;
17
+ const TEMP_DIRECTORY_PREFIX = 'product-console-codex-empty-';
18
+ const TEMP_CLEANUP_MAX_RETRIES = 2;
19
+ const TEMP_CLEANUP_RETRY_DELAY_MS = 50;
20
+ export const CODEX_DISABLED_FEATURES = [
21
+ 'shell_tool',
22
+ 'unified_exec',
23
+ 'view_image',
24
+ 'sleep_tool',
25
+ 'browser_use',
26
+ 'browser_use_external',
27
+ 'computer_use',
28
+ 'in_app_browser',
29
+ 'image_generation',
30
+ 'apps',
31
+ 'plugins',
32
+ 'remote_plugin',
33
+ 'multi_agent',
34
+ 'goals',
35
+ 'hooks',
36
+ 'code_mode_host',
37
+ 'tool_suggest',
38
+ 'skill_search',
39
+ 'personality',
40
+ 'memories',
41
+ 'recommended_plugins',
42
+ 'workspace_dependencies',
43
+ 'in_app_local_automation',
44
+ 'skill_mcp_dependency_install',
45
+ ];
46
+ /** A Codex version bump requires re-verifying that each model still routes all local tools
47
+ * through code mode and fails closed when code_mode_host is disabled. */
48
+ export const CODEX_CODE_MODE_MODELS = [
49
+ 'gpt-6-astra',
50
+ 'gpt-5.6-sol',
51
+ 'gpt-5.6-terra',
52
+ 'gpt-5.6-luna',
53
+ ];
54
+ export const CODEX_CREDENTIAL_ENV_SUBSTRINGS = [
55
+ 'KEY',
56
+ 'TOKEN',
57
+ 'SECRET',
58
+ 'PASSWORD',
59
+ 'PASSWD',
60
+ 'CREDENTIAL',
61
+ 'AUTH',
62
+ ];
63
+ /** No credential-shaped parent variable is needed by the child today. */
64
+ export const CODEX_ENVIRONMENT_ALLOWLIST = [];
65
+ function sampleForError(value) {
66
+ return gitErrorMessage(JSON.stringify(value));
67
+ }
68
+ function promptWriteFailure(error) {
69
+ const code = error.code ? ` (${error.code})` : '';
70
+ const wrapped = new Error(`Failed to write the prompt to codex CLI stdin${code}: ${gitErrorMessage(error)}`, { cause: error });
71
+ wrapped.code = error.code;
72
+ wrapped.errno = error.errno;
73
+ wrapped.syscall = error.syscall;
74
+ return wrapped;
75
+ }
76
+ function terminateChild(child) {
77
+ if (process.platform === 'win32' && child.pid) {
78
+ const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
79
+ killer.on('error', () => child.kill());
80
+ return;
81
+ }
82
+ child.kill();
83
+ }
84
+ function isWithin(base, candidate) {
85
+ const relation = relative(resolve(base), resolve(candidate));
86
+ return relation === '' || (!relation.startsWith('..') && !isAbsolute(relation));
87
+ }
88
+ function occurrences(args, value) {
89
+ return args.flatMap((argument, index) => argument === value ? [index] : []);
90
+ }
91
+ function samePath(left, right) {
92
+ const resolvedLeft = resolve(left);
93
+ const resolvedRight = resolve(right);
94
+ return process.platform === 'win32'
95
+ ? resolvedLeft.toLowerCase() === resolvedRight.toLowerCase()
96
+ : resolvedLeft === resolvedRight;
97
+ }
98
+ async function removeTemporaryDirectory(directory, removeDirectory) {
99
+ try {
100
+ await removeDirectory(directory, {
101
+ recursive: true,
102
+ force: true,
103
+ maxRetries: TEMP_CLEANUP_MAX_RETRIES,
104
+ retryDelay: TEMP_CLEANUP_RETRY_DELAY_MS,
105
+ });
106
+ }
107
+ catch (error) {
108
+ console.warn(`Failed to remove Codex temporary directory ${directory}: ${gitErrorMessage(error)}`);
109
+ }
110
+ }
111
+ /** Validate the complete safety contract immediately before spawn. */
112
+ export function assertSafeInvocation(args, tempRoot = tmpdir()) {
113
+ const forbidden = [
114
+ '--add-dir',
115
+ '--dangerously-bypass-approvals-and-sandbox',
116
+ '--approve-for-me',
117
+ '--full-auto',
118
+ ];
119
+ for (const argument of args) {
120
+ const lower = argument.toLowerCase();
121
+ if (lower.includes('windows.sandbox')) {
122
+ throw new Error('Unsafe codex invocation contains forbidden windows.sandbox setting.');
123
+ }
124
+ for (const flag of forbidden) {
125
+ if (lower === flag || lower.startsWith(`${flag}=`)) {
126
+ throw new Error(`Unsafe codex invocation contains forbidden flag ${flag}.`);
127
+ }
128
+ }
129
+ }
130
+ const sandboxArguments = args.flatMap((argument, index) => argument === '--sandbox' || argument.startsWith('--sandbox=') ? [index] : []);
131
+ if (sandboxArguments.length !== 1 || args[sandboxArguments[0]] !== '--sandbox' || args[sandboxArguments[0] + 1] !== 'read-only') {
132
+ throw new Error('Codex invocation must contain exactly one --sandbox read-only pair.');
133
+ }
134
+ const ignoreUserConfigArguments = args.filter((argument) => argument === '--ignore-user-config' || argument.startsWith('--ignore-user-config='));
135
+ if (ignoreUserConfigArguments.length !== 1 || ignoreUserConfigArguments[0] !== '--ignore-user-config') {
136
+ throw new Error('Codex invocation must contain exactly one --ignore-user-config flag.');
137
+ }
138
+ if (args.at(-1) !== '-') {
139
+ throw new Error('Codex invocation must use the final - stdin marker and must not contain a positional prompt.');
140
+ }
141
+ for (const feature of CODEX_DISABLED_FEATURES) {
142
+ const present = args.some((argument, index) => argument === '--disable' && args[index + 1] === feature);
143
+ if (!present)
144
+ throw new Error(`Codex invocation must disable ${feature}.`);
145
+ }
146
+ for (const setting of [
147
+ 'web_search_mode="disabled"',
148
+ 'shell_environment_policy.inherit=core',
149
+ 'project_doc_max_bytes=0',
150
+ ]) {
151
+ const present = args.some((argument, index) => argument === '-c' && args[index + 1] === setting);
152
+ if (!present)
153
+ throw new Error(`Codex invocation must contain -c ${setting}.`);
154
+ }
155
+ const instructions = args.filter((argument) => argument.startsWith('model_instructions_file='));
156
+ if (instructions.length !== 1 || !args.some((argument, index) => argument === '-c' && args[index + 1] === instructions[0])) {
157
+ throw new Error('Codex invocation must contain exactly one model_instructions_file setting.');
158
+ }
159
+ const cwdIndexes = occurrences(args, '-C');
160
+ if (cwdIndexes.length !== 1 || !args[cwdIndexes[0] + 1]) {
161
+ throw new Error('Codex invocation must contain exactly one -C directory.');
162
+ }
163
+ const invocationCwd = resolve(args[cwdIndexes[0] + 1]);
164
+ if (!samePath(dirname(invocationCwd), tempRoot) || !basename(invocationCwd).startsWith(TEMP_DIRECTORY_PREFIX)) {
165
+ throw new Error(`Codex -C directory must be an immediate child of the OS temporary directory ` +
166
+ `with a basename starting ${TEMP_DIRECTORY_PREFIX}.`);
167
+ }
168
+ }
169
+ async function validatedCodexHome(codexHome) {
170
+ const configured = resolve(codexHome);
171
+ let canonical = configured;
172
+ try {
173
+ canonical = await realpath(configured);
174
+ }
175
+ catch {
176
+ // A first login may create this stable directory.
177
+ }
178
+ if (isWithin(tmpdir(), canonical)) {
179
+ throw new Error(`Refusing CODEX_HOME ${JSON.stringify(configured)} because it resolves under the OS temporary directory; ` +
180
+ 'the companion Codex home must be a stable path so Codex can retain its login and state.');
181
+ }
182
+ return configured;
183
+ }
184
+ export function defaultCodexHome() {
185
+ return join(homedir(), '.product-console-companion', 'codex-home');
186
+ }
187
+ export function buildCodexEnvironment(codexHome) {
188
+ const allow = new Set(CODEX_ENVIRONMENT_ALLOWLIST);
189
+ const env = {};
190
+ for (const [name, value] of Object.entries(process.env)) {
191
+ const credentialShaped = CODEX_CREDENTIAL_ENV_SUBSTRINGS.some((part) => name.toUpperCase().includes(part));
192
+ if (!credentialShaped || allow.has(name))
193
+ env[name] = value;
194
+ }
195
+ env.CODEX_HOME = codexHome;
196
+ return env;
197
+ }
198
+ function assertAllowedModel(model) {
199
+ if (CODEX_CODE_MODE_MODELS.includes(model))
200
+ return;
201
+ throw new Error(`Refusing Codex model ${JSON.stringify(model)}. Allowed models: ${CODEX_CODE_MODE_MODELS.join(', ')}. ` +
202
+ 'Only these models were verified to fail closed when a tool is attempted with code_mode_host disabled.');
203
+ }
204
+ function eventMessage(event) {
205
+ if (typeof event.message === 'string')
206
+ return event.message;
207
+ if (typeof event.error === 'string')
208
+ return event.error;
209
+ if (event.error && typeof event.error === 'object') {
210
+ const message = event.error.message;
211
+ if (typeof message === 'string')
212
+ return message;
213
+ }
214
+ if (event.item && typeof event.item === 'object') {
215
+ const message = event.item.message;
216
+ if (typeof message === 'string')
217
+ return message;
218
+ }
219
+ try {
220
+ return JSON.stringify(event);
221
+ }
222
+ catch {
223
+ return String(event);
224
+ }
225
+ }
226
+ function namedCodexFailure(message, codexHome) {
227
+ if (/401 Unauthorized/i.test(message)) {
228
+ return new Error(`Codex companion login is missing or expired (401 Unauthorized). Set CODEX_HOME to ` +
229
+ `"${codexHome}" and run "codex login", then retry.`);
230
+ }
231
+ if (/invalid_json_schema/i.test(message) || /HTTP\s*400/i.test(message) && /schema/i.test(message)) {
232
+ return new Error(`Codex rejected the output schema (invalid_json_schema, HTTP 400). This is a Truing schema defect: ${gitErrorMessage(message)}`);
233
+ }
234
+ if (/you(?:'|’)?ve hit your usage limit/i.test(message) ||
235
+ /usage limit reached/i.test(message) ||
236
+ /rate limit exceeded/i.test(message) ||
237
+ /quota exceeded/i.test(message)) {
238
+ return new Error(`Codex usage limit reached: ${gitErrorMessage(message)}`);
239
+ }
240
+ return null;
241
+ }
242
+ function usageFrom(value) {
243
+ if (!value || typeof value !== 'object' || Array.isArray(value))
244
+ return null;
245
+ const usage = {};
246
+ for (const [key, candidate] of Object.entries(value)) {
247
+ if (typeof candidate === 'number' && Number.isFinite(candidate))
248
+ usage[key] = candidate;
249
+ }
250
+ return Object.keys(usage).length > 0 ? usage : null;
251
+ }
252
+ function toolUseAttempt(type) {
253
+ return new Error(`CodexToolUseAttempted: Codex attempted a tool (${type}) in a no-tools call; the result was discarded.`);
254
+ }
255
+ export function buildArgs(request, options, cwd, outputPath, schemaPath, instructionsPath) {
256
+ const args = [
257
+ 'exec',
258
+ '--json',
259
+ ...(schemaPath ? ['--output-schema', schemaPath] : []),
260
+ '-o',
261
+ outputPath,
262
+ '--sandbox',
263
+ 'read-only',
264
+ '--skip-git-repo-check',
265
+ '--ephemeral',
266
+ '--color',
267
+ 'never',
268
+ '--ignore-user-config',
269
+ '-m',
270
+ options.model,
271
+ '-c',
272
+ `model_reasoning_effort=${request.effort}`,
273
+ '-c',
274
+ 'project_doc_max_bytes=0',
275
+ '-c',
276
+ 'web_search_mode="disabled"',
277
+ '-c',
278
+ 'shell_environment_policy.inherit=core',
279
+ '-c',
280
+ `model_instructions_file=${instructionsPath}`,
281
+ ...CODEX_DISABLED_FEATURES.flatMap((feature) => ['--disable', feature]),
282
+ '-C',
283
+ cwd,
284
+ '-',
285
+ ];
286
+ assertSingleLineArguments('codex', args);
287
+ assertSafeInvocation(args);
288
+ return args;
289
+ }
290
+ function diagnostic(exitCode, stderrTail, stdoutBytes, stdoutLines, parsedLines, unparseableLines, stdoutFirst, stdoutLast, firstUnparseableLineLength, firstUnparseableLineFirst, firstUnparseableLineLast, typeCounts, toleratedErrorItems, purpose) {
291
+ const types = [...typeCounts.entries()]
292
+ .map(([type, count]) => `${gitErrorMessage(type) || '(empty)'}=${count}`)
293
+ .join(', ') || '(none)';
294
+ const stdoutSample = stdoutBytes === 0
295
+ ? '(none)'
296
+ : `first ${STDOUT_SAMPLE_CHARS} chars=${sampleForError(stdoutFirst)}, last ${STDOUT_SAMPLE_CHARS} chars=${sampleForError(stdoutLast)}`;
297
+ const unparseableSample = firstUnparseableLineLength === null
298
+ ? ''
299
+ : `; first unparseable line: ${firstUnparseableLineLength} chars, first ${UNPARSEABLE_LINE_SAMPLE_CHARS} chars=${sampleForError(firstUnparseableLineFirst)}, last ${UNPARSEABLE_LINE_SAMPLE_CHARS} chars=${sampleForError(firstUnparseableLineLast)}`;
300
+ return (`codex CLI exited (code ${exitCode}) without a final message — ` +
301
+ `stderr: ${stderrTail ? gitErrorMessage(stderrTail) : '(none)'}; ` +
302
+ `stdout: ${stdoutLines} line${stdoutLines === 1 ? '' : 's'} (${parsedLines} JSON, ${unparseableLines} unparseable), ${stdoutBytes} bytes; ` +
303
+ `types: ${types}; tolerated error items: ${toleratedErrorItems}; purpose: ${gitErrorMessage(purpose)}; ` +
304
+ `raw stdout: ${stdoutSample}${unparseableSample}`);
305
+ }
306
+ /** Invoke Codex as a model transport. maxTokens is intentionally not passed because codex exec
307
+ * exposes no enforceable output-token cap; the ten-minute wall-clock timeout is the hard bound. */
308
+ export async function runViaCodex(request, options,
309
+ /** Test seam; production uses node:fs/promises rm. */
310
+ removeDirectory = rm) {
311
+ if (request.documentBase64) {
312
+ throw new Error("This task includes a PDF PRD, which the Codex backend can't read yet. Pair with " +
313
+ '--backend api-key instead, or re-upload the PRD as .md/.txt/.docx.');
314
+ }
315
+ assertAllowedModel(options.model);
316
+ const codexHome = await validatedCodexHome(options.codexHome ?? defaultCodexHome());
317
+ const invocationCwd = await mkdtemp(join(tmpdir(), 'product-console-codex-empty-'));
318
+ let callDir = null;
319
+ try {
320
+ callDir = await mkdtemp(join(tmpdir(), 'product-console-codex-call-'));
321
+ const outputPath = join(callDir, 'last-message.txt');
322
+ const instructionsPath = join(callDir, 'model-instructions.txt');
323
+ await writeFile(instructionsPath, request.system, { encoding: 'utf8', mode: 0o600 });
324
+ let schemaPath = null;
325
+ let schemaRecord = null;
326
+ if (request.schema) {
327
+ const strict = toCodexStrictSchema(request.schema, { enforcedAfter: options.enforcedAfter });
328
+ schemaRecord = strict.record;
329
+ schemaPath = join(callDir, 'schema.json');
330
+ await writeFile(schemaPath, JSON.stringify(strict.schema), { encoding: 'utf8', mode: 0o600 });
331
+ }
332
+ const args = buildArgs(request, options, invocationCwd, outputPath, schemaPath, instructionsPath);
333
+ let stderrTail = '';
334
+ let timedOut = false;
335
+ let stdinError = null;
336
+ let fatalError = null;
337
+ let turnFailure = '';
338
+ let usage = null;
339
+ let toleratedErrorItems = 0;
340
+ let stdoutBytes = 0;
341
+ let stdoutLines = 0;
342
+ let parsedLines = 0;
343
+ let unparseableLines = 0;
344
+ let stdoutFirst = '';
345
+ let stdoutLast = '';
346
+ let firstUnparseableLineLength = null;
347
+ let firstUnparseableLineFirst = '';
348
+ let firstUnparseableLineLast = '';
349
+ const typeCounts = new Map();
350
+ const child = spawn('codex', args, {
351
+ cwd: invocationCwd,
352
+ env: buildCodexEnvironment(codexHome),
353
+ windowsHide: true,
354
+ });
355
+ const stdoutDecoder = new StringDecoder('utf8');
356
+ const recordStdout = (text) => {
357
+ if (!text)
358
+ return;
359
+ if (stdoutFirst.length < STDOUT_SAMPLE_CHARS) {
360
+ stdoutFirst += text.slice(0, STDOUT_SAMPLE_CHARS - stdoutFirst.length);
361
+ }
362
+ stdoutLast = (stdoutLast + text).slice(-STDOUT_SAMPLE_CHARS);
363
+ };
364
+ child.stdout.on('data', (chunk) => {
365
+ stdoutBytes += chunk.length;
366
+ recordStdout(stdoutDecoder.write(chunk));
367
+ });
368
+ child.stdout.on('end', () => recordStdout(stdoutDecoder.end()));
369
+ child.stdin.on('error', (error) => {
370
+ stdinError ??= error;
371
+ terminateChild(child);
372
+ });
373
+ child.stderr.on('data', (chunk) => {
374
+ stderrTail = (stderrTail + chunk.toString()).slice(-2000);
375
+ });
376
+ const rl = createInterface({ input: child.stdout });
377
+ rl.on('line', (line) => {
378
+ stdoutLines++;
379
+ let event;
380
+ try {
381
+ event = JSON.parse(line);
382
+ }
383
+ catch {
384
+ unparseableLines++;
385
+ if (firstUnparseableLineLength === null) {
386
+ firstUnparseableLineLength = line.length;
387
+ firstUnparseableLineFirst = line.slice(0, UNPARSEABLE_LINE_SAMPLE_CHARS);
388
+ firstUnparseableLineLast = line.slice(-UNPARSEABLE_LINE_SAMPLE_CHARS);
389
+ }
390
+ return;
391
+ }
392
+ parsedLines++;
393
+ const eventType = typeof event.type === 'string' ? event.type : '(missing)';
394
+ typeCounts.set(eventType, (typeCounts.get(eventType) ?? 0) + 1);
395
+ if (eventType === 'item.started' || eventType === 'item.completed') {
396
+ const itemType = event.item && typeof event.item === 'object'
397
+ ? event.item.type
398
+ : undefined;
399
+ const classified = typeof itemType === 'string' ? itemType : '(missing)';
400
+ if (classified === 'error') {
401
+ toleratedErrorItems++;
402
+ }
403
+ else if (classified !== 'agent_message' && classified !== 'reasoning') {
404
+ fatalError ??= toolUseAttempt(classified);
405
+ terminateChild(child);
406
+ }
407
+ }
408
+ if (eventType === 'turn.completed')
409
+ usage = usageFrom(event.usage);
410
+ if (eventType === 'error' || eventType === 'turn.failed') {
411
+ const message = eventMessage(event);
412
+ const named = namedCodexFailure(message, codexHome);
413
+ if (named) {
414
+ fatalError ??= named;
415
+ if (/401 Unauthorized/i.test(message))
416
+ terminateChild(child);
417
+ }
418
+ else if (eventType === 'turn.failed') {
419
+ turnFailure = message;
420
+ }
421
+ }
422
+ });
423
+ const timer = setTimeout(() => {
424
+ timedOut = true;
425
+ terminateChild(child);
426
+ }, options.timeoutMs ?? WALL_CLOCK_TIMEOUT_MS);
427
+ const exitPromise = new Promise((resolvePromise, reject) => {
428
+ child.on('error', reject);
429
+ child.on('close', (code) => resolvePromise(code ?? 1));
430
+ });
431
+ try {
432
+ if (request.stableText)
433
+ child.stdin.write(`${request.stableText}\n\n`);
434
+ child.stdin.write(request.text);
435
+ child.stdin.end();
436
+ }
437
+ catch (error) {
438
+ stdinError ??= error;
439
+ terminateChild(child);
440
+ }
441
+ let exitCode;
442
+ try {
443
+ exitCode = await exitPromise;
444
+ }
445
+ finally {
446
+ clearTimeout(timer);
447
+ }
448
+ options.onDiagnostic?.({ toleratedErrorItems });
449
+ if (/codex_core::tools::router/i.test(stderrTail) && /(?:spawn|exec_command)/i.test(stderrTail)) {
450
+ throw toolUseAttempt(/exec_command/i.test(stderrTail) ? 'exec_command' : 'spawn');
451
+ }
452
+ if (fatalError)
453
+ throw fatalError;
454
+ if (timedOut) {
455
+ throw new Error(`codex CLI took longer than ${(options.timeoutMs ?? WALL_CLOCK_TIMEOUT_MS) / 60_000} minutes and was killed.`);
456
+ }
457
+ let finalText = null;
458
+ try {
459
+ finalText = await readFile(outputPath, 'utf8');
460
+ }
461
+ catch {
462
+ // The bounded stream diagnostic below carries the relevant exit context.
463
+ }
464
+ const capturedStdinError = stdinError;
465
+ if (turnFailure) {
466
+ const named = namedCodexFailure(turnFailure, codexHome);
467
+ throw named ?? new Error(`Codex turn failed: ${gitErrorMessage(turnFailure)}; ${diagnostic(exitCode, stderrTail, stdoutBytes, stdoutLines, parsedLines, unparseableLines, stdoutFirst, stdoutLast, firstUnparseableLineLength, firstUnparseableLineFirst, firstUnparseableLineLast, typeCounts, toleratedErrorItems, request.purpose)}`);
468
+ }
469
+ if (finalText === null || finalText.length === 0) {
470
+ if (capturedStdinError)
471
+ throw promptWriteFailure(capturedStdinError);
472
+ throw new Error(diagnostic(exitCode, stderrTail, stdoutBytes, stdoutLines, parsedLines, unparseableLines, stdoutFirst, stdoutLast, firstUnparseableLineLength, firstUnparseableLineFirst, firstUnparseableLineLast, typeCounts, toleratedErrorItems, request.purpose));
473
+ }
474
+ if (capturedStdinError)
475
+ throw promptWriteFailure(capturedStdinError);
476
+ if (request.schema) {
477
+ let result;
478
+ try {
479
+ result = JSON.parse(finalText);
480
+ }
481
+ catch (error) {
482
+ throw new Error(`Codex wrote an invalid JSON final message: ${gitErrorMessage(error)}`);
483
+ }
484
+ stripIntroducedNulls(result, schemaRecord);
485
+ finalText = JSON.stringify(result);
486
+ }
487
+ options.onUsage?.(usage);
488
+ return { text: finalText, usage };
489
+ }
490
+ finally {
491
+ await removeTemporaryDirectory(invocationCwd, removeDirectory);
492
+ if (callDir)
493
+ await removeTemporaryDirectory(callDir, removeDirectory);
494
+ }
495
+ }
496
+ export function codexTextModel(options) {
497
+ return (request) => runViaCodex(request, options);
498
+ }
499
+ /** The reader result contract re-checks allOf/if/then after the reply, so only this path may
500
+ * remove those three schema keywords for Codex strict-output compatibility. */
501
+ export async function runStructuredModelCallViaCodex(request, options) {
502
+ const reply = await runViaCodex({
503
+ purpose: 'investigation',
504
+ model: request.model,
505
+ effort: request.effort,
506
+ maxTokens: request.maxTokens,
507
+ system: request.system,
508
+ stableText: '',
509
+ text: request.prompt,
510
+ schema: request.schema,
511
+ }, { ...options, enforcedAfter: ['allOf', 'if', 'then'] });
512
+ return JSON.parse(reply.text);
513
+ }
514
+ export async function checkCodexLogin(codexHome) {
515
+ const validatedHome = await validatedCodexHome(codexHome);
516
+ try {
517
+ await access(validatedHome);
518
+ }
519
+ catch (error) {
520
+ if (error.code === 'ENOENT')
521
+ return 'not-logged-in';
522
+ throw error;
523
+ }
524
+ return new Promise((resolvePromise) => {
525
+ const child = spawn('codex', ['login', 'status'], {
526
+ env: buildCodexEnvironment(validatedHome),
527
+ windowsHide: true,
528
+ });
529
+ child.stdout?.resume();
530
+ child.stderr?.resume();
531
+ child.on('error', (error) => {
532
+ resolvePromise(error.code === 'ENOENT' ? 'not-installed' : 'not-logged-in');
533
+ });
534
+ child.on('close', (code) => resolvePromise(code === 0 ? 'logged-in' : 'not-logged-in'));
535
+ });
536
+ }
package/dist/config.js ADDED
@@ -0,0 +1,119 @@
1
+ /** Reads/writes ~/.product-console-companion/config.json. */
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ // Kept on purpose from the old name so existing pairings continue to load after upgrading.
6
+ const CONFIG_DIR = join(homedir(), '.product-console-companion');
7
+ const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
8
+ function trimUrl(url) {
9
+ return url.replace(/\/+$/, '');
10
+ }
11
+ export function normalizeConsoleUrl(url) {
12
+ const trimmed = trimUrl(url);
13
+ const parsed = /^([a-z][a-z\d+.-]*):\/\/([^/?#]+)(.*)$/i.exec(trimmed);
14
+ return parsed ? `${parsed[1].toLowerCase()}://${parsed[2].toLowerCase()}${parsed[3]}` : trimmed;
15
+ }
16
+ export function samePairing(a, b) {
17
+ return normalizeConsoleUrl(a.url) === normalizeConsoleUrl(b.url) && a.workspaceId === b.workspaceId;
18
+ }
19
+ /** v1 files are adapted in memory and are not rewritten until a mutating operation saves them. */
20
+ export function loadConfig(path = CONFIG_PATH) {
21
+ if (!existsSync(path))
22
+ return null;
23
+ try {
24
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
25
+ if ('pairings' in parsed && Array.isArray(parsed.pairings))
26
+ return parsed;
27
+ const legacy = parsed;
28
+ return {
29
+ version: 2,
30
+ pairings: [{
31
+ url: trimUrl(legacy.url),
32
+ token: legacy.token,
33
+ connections: legacy.connections ?? [],
34
+ pollWorkspaceTasks: legacy.pollWorkspaceTasks,
35
+ workspaceBackend: legacy.workspaceBackend,
36
+ codexModel: legacy.codexModel,
37
+ }],
38
+ apiKey: legacy.apiKey,
39
+ };
40
+ }
41
+ catch {
42
+ throw new Error(`${path} exists but isn't valid JSON — fix or delete it, then run "connect" again.`);
43
+ }
44
+ }
45
+ function save(config, path = CONFIG_PATH) {
46
+ mkdirSync(dirname(path), { recursive: true });
47
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
48
+ }
49
+ export function addConnection(args) {
50
+ const existing = loadConfig(args.configFile) ?? { version: 2, pairings: [] };
51
+ for (const pairing of existing.pairings) {
52
+ pairing.connections = pairing.connections.filter((candidate) => candidate.connectionId !== args.connection.connectionId);
53
+ }
54
+ let pairing = existing.pairings.find((candidate) => samePairing(candidate, args));
55
+ if (!pairing) {
56
+ pairing = { url: trimUrl(args.url), token: args.token, workspaceId: args.workspaceId, connections: [] };
57
+ existing.pairings.push(pairing);
58
+ }
59
+ pairing.token = args.token;
60
+ pairing.connections.push(args.connection);
61
+ const config = {
62
+ version: 2,
63
+ pairings: existing.pairings,
64
+ apiKey: args.apiKey || existing.apiKey,
65
+ };
66
+ save(config, args.configFile);
67
+ return config;
68
+ }
69
+ export function enableWorkspacePolling(args) {
70
+ const existing = loadConfig(args.configFile) ?? { version: 2, pairings: [] };
71
+ let pairing = existing.pairings.find((candidate) => samePairing(candidate, args));
72
+ if (!pairing) {
73
+ pairing = { url: trimUrl(args.url), token: args.token, workspaceId: args.workspaceId, connections: [] };
74
+ existing.pairings.push(pairing);
75
+ }
76
+ pairing.token = args.token;
77
+ pairing.pollWorkspaceTasks = true;
78
+ pairing.workspaceBackend = args.backend || pairing.workspaceBackend;
79
+ pairing.codexModel = args.backend === 'codex' ? args.codexModel : pairing.codexModel;
80
+ const config = {
81
+ version: 2,
82
+ pairings: existing.pairings,
83
+ apiKey: args.apiKey || existing.apiKey,
84
+ };
85
+ save(config, args.configFile);
86
+ return config;
87
+ }
88
+ export function recordWorkspaceId(url, token, workspaceId, configFile) {
89
+ const config = loadConfig(configFile);
90
+ if (!config)
91
+ return null;
92
+ const pairing = config.pairings.find((candidate) => (normalizeConsoleUrl(candidate.url) === normalizeConsoleUrl(url)
93
+ && candidate.workspaceId === undefined
94
+ && candidate.token === token));
95
+ if (!pairing)
96
+ return config;
97
+ pairing.workspaceId = workspaceId;
98
+ save(config, configFile);
99
+ return config;
100
+ }
101
+ export function removePairing(url, workspaceId, configFile) {
102
+ const config = loadConfig(configFile);
103
+ if (!config)
104
+ return [];
105
+ const normalized = normalizeConsoleUrl(url);
106
+ const removed = config.pairings.filter((pairing) => (normalizeConsoleUrl(pairing.url) === normalized
107
+ && (workspaceId === undefined || pairing.workspaceId === workspaceId)));
108
+ if (removed.length === 0)
109
+ return [];
110
+ config.pairings = config.pairings.filter((pairing) => !removed.includes(pairing));
111
+ save(config, configFile);
112
+ return removed;
113
+ }
114
+ export const configPath = CONFIG_PATH;
115
+ /** Where `connect --repo` keeps the companion's own copies: a `repos` folder next to config.json,
116
+ * so a test that points configFile at a temp folder gets temp copies too. */
117
+ export function managedReposDir(configFile = CONFIG_PATH) {
118
+ return join(dirname(configFile), 'repos');
119
+ }