@parall/codex-agent 1.44.0 → 1.46.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.
Files changed (43) hide show
  1. package/dist/app-server-process.d.ts +9 -0
  2. package/dist/app-server-process.d.ts.map +1 -0
  3. package/dist/app-server-process.js +58 -0
  4. package/dist/app-server-protocol.d.ts +19 -0
  5. package/dist/app-server-protocol.d.ts.map +1 -0
  6. package/dist/app-server-protocol.js +42 -0
  7. package/dist/dispatch.d.ts +88 -5
  8. package/dist/dispatch.d.ts.map +1 -1
  9. package/dist/dispatch.js +364 -196
  10. package/dist/index.js +27 -11
  11. package/dist/instructions-refresh.d.ts +136 -0
  12. package/dist/instructions-refresh.d.ts.map +1 -0
  13. package/dist/instructions-refresh.js +244 -0
  14. package/dist/jsonrpc-client.d.ts +49 -1
  15. package/dist/jsonrpc-client.d.ts.map +1 -1
  16. package/dist/jsonrpc-client.js +77 -5
  17. package/dist/legacy-workspace-config-migration.d.ts +112 -0
  18. package/dist/legacy-workspace-config-migration.d.ts.map +1 -0
  19. package/dist/legacy-workspace-config-migration.js +229 -0
  20. package/dist/server-requests.d.ts +10 -0
  21. package/dist/server-requests.d.ts.map +1 -0
  22. package/dist/server-requests.js +39 -0
  23. package/dist/session-manager.d.ts +12 -0
  24. package/dist/session-manager.d.ts.map +1 -1
  25. package/dist/session-manager.js +53 -3
  26. package/dist/turn-sink.d.ts +26 -0
  27. package/dist/turn-sink.d.ts.map +1 -0
  28. package/dist/turn-sink.js +45 -0
  29. package/dist/workspace.d.ts +23 -25
  30. package/dist/workspace.d.ts.map +1 -1
  31. package/dist/workspace.js +138 -138
  32. package/package.json +5 -5
  33. package/src/app-server-process.ts +59 -0
  34. package/src/app-server-protocol.ts +46 -0
  35. package/src/dispatch.ts +426 -204
  36. package/src/index.ts +35 -10
  37. package/src/instructions-refresh.ts +367 -0
  38. package/src/jsonrpc-client.ts +109 -7
  39. package/src/legacy-workspace-config-migration.ts +296 -0
  40. package/src/server-requests.ts +40 -0
  41. package/src/session-manager.ts +74 -6
  42. package/src/turn-sink.ts +54 -0
  43. package/src/workspace.ts +155 -155
package/src/workspace.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import { parse as parseToml } from 'smol-toml';
4
3
  import {
5
4
  BRIDGE_WORKSPACE_INSTRUCTIONS,
6
5
  PRLL_BEHAVIOR,
@@ -10,6 +9,8 @@ import {
10
9
  writeSkillFiles,
11
10
  } from '@parall/agent-core';
12
11
  import type { AgentIdentity } from '@parall/agent-core';
12
+ import type { AuthorshipProof } from './legacy-workspace-config-migration.js';
13
+ import { runLegacyWorkspaceConfigMigration } from './legacy-workspace-config-migration.js';
13
14
 
14
15
  /**
15
16
  * Config-lock timings, exported for tests.
@@ -37,10 +38,10 @@ function sleepSync(ms: number): void {
37
38
 
38
39
  /**
39
40
  * Advisory cross-process lock serializing `<codexHome>/config.toml`
40
- * read-modify-write. On a local daemon machine every runtime_auth codex
41
- * bridge child shares the operator's CODEX_HOME, and the daemon starts them
42
- * together — without a lock, a whole-file rewrite working from a stale read
43
- * can erase a trust entry another agent appended in between.
41
+ * read-modify-write. Several bridge children can share one CODEX_HOME (an
42
+ * operator-set PRLL_CODEX_HOME reaches every child on the machine), and the
43
+ * daemon starts them together — without a lock, concurrent whole-file
44
+ * rewrites working from stale reads lose updates.
44
45
  *
45
46
  * Queue design (why not wx-create + steal): any scheme that renames or
46
47
  * unlinks the SHARED lock path can, between its staleness check and the
@@ -83,11 +84,12 @@ function sleepSync(ms: number): void {
83
84
  * eviction and the process re-enqueues. On `waitMs` timeout
84
85
  * the mutation proceeds without the lock (warn) — blocking would wedge
85
86
  * bridge startup. The lock coordinates bridge processes only; codex itself
86
- * does not observe it, which is why bridge writes to a shared config are
87
- * additionally kept rare (the trust write is a no-op after the first boot
88
- * per workspace, and runtime_auth agents never write the provider block)
89
- * and whole-file writes are atomic (temp + rename) so codex never reads a
90
- * truncated file.
87
+ * does not observe it, which is why bridge writes to the global config are
88
+ * additionally kept rare the only remaining mutation is the parall
89
+ * provider block (runtime_auth agents, which share the operator's ~/.codex,
90
+ * write nothing at all: platform instructions ride the app-server
91
+ * `developerInstructions` param) — and whole-file writes are atomic
92
+ * (temp + rename) so codex never reads a truncated file.
91
93
  *
92
94
  * Exported for tests.
93
95
  */
@@ -322,9 +324,9 @@ function bakeryEnqueue(queueDir: string): string | null {
322
324
  /**
323
325
  * Whole-file config writes go through temp + rename so a concurrent reader
324
326
  * (including codex itself, which does not observe the advisory lock) never
325
- * sees a truncated file. The trust-append path intentionally keeps
326
- * appendFileSync O_APPEND is atomic for these small writes and cannot
327
- * clobber a concurrent append.
327
+ * sees a truncated file. This is now the only way the bridge writes the global
328
+ * config: the append-based trust path it used to share this file with is gone
329
+ * with `ensureWorkspaceTrusted`, and the provider block is a whole-file rewrite.
328
330
  *
329
331
  * The rename targets the file's REAL path: `config.toml` managed by a
330
332
  * dotfiles setup is often a symlink, and renaming onto the link path would
@@ -404,123 +406,6 @@ function resolveWriteTarget(filePath: string): string {
404
406
  throw new Error(`symlink chain deeper than 40 while resolving ${filePath}`);
405
407
  }
406
408
 
407
- /**
408
- * Ensure the workspace directory is marked as trusted in the global Codex
409
- * config so that project-level `developer_instructions` are loaded at
410
- * app-server startup.
411
- *
412
- * Uses smol-toml for structured reads (avoids substring false-positives),
413
- * but writes via raw text manipulation (preserves comments and formatting).
414
- * Failures are swallowed (warn-only) so a trust write issue never blocks
415
- * the bridge from starting.
416
- */
417
- export function ensureWorkspaceTrusted(
418
- codexHome: string,
419
- workspaceDir: string,
420
- log?: { warn: (msg: string) => void },
421
- ): void {
422
- withConfigLock(codexHome, log, () => ensureWorkspaceTrustedLocked(codexHome, workspaceDir, log));
423
- }
424
-
425
- function ensureWorkspaceTrustedLocked(
426
- codexHome: string,
427
- workspaceDir: string,
428
- log?: { warn: (msg: string) => void },
429
- ): void {
430
- const configPath = path.join(codexHome, 'config.toml');
431
- const normalizedPath = path.resolve(workspaceDir);
432
-
433
- try {
434
- let content = '';
435
- try {
436
- content = fs.readFileSync(configPath, 'utf8');
437
- } catch (err) {
438
- if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
439
- }
440
-
441
- // Structured read — know exactly what state we're in.
442
- let parsed: Record<string, unknown> | undefined;
443
- if (content) {
444
- try {
445
- parsed = parseToml(content) as Record<string, unknown>;
446
- } catch {
447
- // File is already broken TOML — don't make it worse.
448
- log?.warn(
449
- `Codex config.toml is not valid TOML; skipping trust write for ${normalizedPath}`,
450
- );
451
- return;
452
- }
453
- }
454
-
455
- const projects = parsed?.projects as Record<string, Record<string, unknown>> | undefined;
456
- const existingTrust = projects?.[normalizedPath]?.trust_level;
457
- if (existingTrust === 'trusted') return;
458
- if (existingTrust !== undefined) {
459
- // An explicit non-trusted value is a human decision — on a local
460
- // runtime_auth daemon this file IS the operator's own ~/.codex config,
461
- // and a workspace they deliberately marked untrusted must never be
462
- // silently flipped by an agent. Leave it and surface the consequence.
463
- log?.warn(
464
- `Codex config marks ${normalizedPath} as trust_level=${JSON.stringify(
465
- existingTrust,
466
- )}; respecting the explicit decision — project-level developer_instructions will not load for this workspace`,
467
- );
468
- return;
469
- }
470
-
471
- // TOML basic-string keys require backslash and double-quote escaping.
472
- const escapedPath = normalizedPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
473
- const sectionHeader = `[projects."${escapedPath}"]`;
474
- const trustLine = 'trust_level = "trusted"';
475
-
476
- const headerIdx = content.indexOf(sectionHeader);
477
- if (headerIdx !== -1) {
478
- // Section exists but trust_level isn't "trusted". Scan all lines
479
- // within the section (up to the next `[` header or EOF) for an
480
- // existing trust_level key — it may not be the first line after
481
- // the header if the user added comments or other keys.
482
- const headerLineEnd = content.indexOf('\n', headerIdx);
483
- if (headerLineEnd === -1) {
484
- content = `${content}\n${trustLine}\n`;
485
- } else {
486
- const afterHeader = headerLineEnd + 1;
487
- const sectionEnd = findSectionEnd(content, afterHeader);
488
- const sectionBody = content.substring(afterHeader, sectionEnd);
489
- const trustMatch = sectionBody.match(/^[ \t]*trust_level[ \t]*=.*$/m);
490
- if (trustMatch) {
491
- const matchStart = afterHeader + trustMatch.index!;
492
- const matchEnd = matchStart + trustMatch[0].length;
493
- content = content.substring(0, matchStart) + trustLine + content.substring(matchEnd);
494
- } else {
495
- content =
496
- content.substring(0, afterHeader) + trustLine + '\n' + content.substring(afterHeader);
497
- }
498
- }
499
- writeConfigAtomic(configPath, content);
500
- } else if (projects?.[normalizedPath] !== undefined) {
501
- // smol-toml found the section but indexOf missed it — the header
502
- // uses non-canonical TOML formatting. Appending would create a
503
- // duplicate table. Skip rather than corrupt the file.
504
- log?.warn(
505
- `Codex config.toml has non-canonical header for ${normalizedPath}; skipping trust write`,
506
- );
507
- } else if (projects && !content.includes('[projects.')) {
508
- // `projects` exists in parsed output but no `[projects.` table
509
- // headers in the raw text — it's an inline table. Appending a
510
- // standard table header would produce invalid TOML.
511
- log?.warn(
512
- `Codex config.toml uses inline table for projects; skipping trust write for ${normalizedPath}`,
513
- );
514
- } else {
515
- // Section doesn't exist — append.
516
- fs.mkdirSync(codexHome, { recursive: true });
517
- fs.appendFileSync(configPath, `\n${sectionHeader}\n${trustLine}\n`, 'utf8');
518
- }
519
- } catch (err) {
520
- log?.warn(`failed to write Codex project trust for ${normalizedPath}: ${String(err)}`);
521
- }
522
- }
523
-
524
409
  /**
525
410
  * Returns true when the agent is using the Parall LLM proxy (llm_source=parall)
526
411
  * rather than a BYO custom provider or runtime_auth. Detected by checking
@@ -547,15 +432,71 @@ export function isParallProxyMode(env: NodeJS.ProcessEnv = process.env): boolean
547
432
  *
548
433
  * Provider-managed: overwritten on every boot (env vars are the SSOT).
549
434
  */
435
+ /**
436
+ * Escape a value as a TOML basic (double-quoted) string. Needed for Windows
437
+ * paths (backslashes) in the auth command; harmless hardening everywhere else.
438
+ */
439
+ export function tomlBasicString(value: string): string {
440
+ let out = '';
441
+ for (const ch of value) {
442
+ const code = ch.codePointAt(0) ?? 0;
443
+ if (ch === '\\') out += '\\\\';
444
+ else if (ch === '"') out += '\\"';
445
+ else if (ch === '\b') out += '\\b';
446
+ else if (ch === '\t') out += '\\t';
447
+ else if (ch === '\n') out += '\\n';
448
+ else if (ch === '\f') out += '\\f';
449
+ else if (ch === '\r') out += '\\r';
450
+ else if (code < 0x20 || code === 0x7f) out += `\\u${code.toString(16).padStart(4, '0')}`;
451
+ else out += ch;
452
+ }
453
+ return `"${out}"`;
454
+ }
455
+
456
+ /** Injectable auth-command inputs — production callers pass nothing. */
457
+ export interface ParallProviderAuthOptions {
458
+ platform?: NodeJS.Platform;
459
+ nodeBin?: string;
460
+ }
461
+
462
+ /**
463
+ * The `[model_providers.parall.auth]` body. auth.command is the only path
464
+ * that actually sends the Bearer header on custom providers (see
465
+ * codex-responses-api-proxy.md), so both platforms stay in that shape:
466
+ * - POSIX keeps the empirically validated `printenv OPENAI_API_KEY`,
467
+ * byte-for-byte — do not touch it.
468
+ * - Windows has no printenv; reuse the bridge's own Node runtime
469
+ * (process.execPath — on disk by construction, and the config is
470
+ * provider-managed / rewritten every boot, so a moved Node self-heals).
471
+ * The -e script mirrors printenv exactly: missing var → exit 1, value →
472
+ * stdout with no trailing newline. Keep the script free of `"` and `\`
473
+ * so it stays inert inside a TOML basic string.
474
+ */
475
+ function parallAuthLines(opts?: ParallProviderAuthOptions): string[] {
476
+ const platform = opts?.platform ?? process.platform;
477
+ if (platform !== 'win32') {
478
+ return ['command = "printenv"', 'args = ["OPENAI_API_KEY"]'];
479
+ }
480
+ const nodeBin = opts?.nodeBin ?? process.execPath;
481
+ const script =
482
+ 'const v=process.env.OPENAI_API_KEY;if(v===undefined)process.exit(1);process.stdout.write(v)';
483
+ return [`command = ${tomlBasicString(nodeBin)}`, `args = ["-e", ${tomlBasicString(script)}]`];
484
+ }
485
+
550
486
  export function ensureParallProvider(
551
487
  codexHome: string,
552
488
  apiUrl: string,
553
489
  log?: { warn: (msg: string) => void },
490
+ opts?: ParallProviderAuthOptions,
554
491
  ): void {
555
- withConfigLock(codexHome, log, () => ensureParallProviderLocked(codexHome, apiUrl));
492
+ withConfigLock(codexHome, log, () => ensureParallProviderLocked(codexHome, apiUrl, opts));
556
493
  }
557
494
 
558
- function ensureParallProviderLocked(codexHome: string, apiUrl: string): void {
495
+ function ensureParallProviderLocked(
496
+ codexHome: string,
497
+ apiUrl: string,
498
+ opts?: ParallProviderAuthOptions,
499
+ ): void {
559
500
  const configPath = path.join(codexHome, 'config.toml');
560
501
  const baseUrl = apiUrl.replace(/\/$/, '') + '/api/llm/v1';
561
502
 
@@ -572,14 +513,13 @@ function ensureParallProviderLocked(codexHome: string, apiUrl: string): void {
572
513
  const providerBlock = [
573
514
  sectionHeader,
574
515
  'name = "Parall Proxy"',
575
- `base_url = "${baseUrl}"`,
516
+ `base_url = ${tomlBasicString(baseUrl)}`,
576
517
  'wire_api = "responses"',
577
518
  'supports_websockets = false',
578
519
  'requires_openai_auth = false',
579
520
  '',
580
521
  authHeader,
581
- 'command = "printenv"',
582
- 'args = ["OPENAI_API_KEY"]',
522
+ ...parallAuthLines(opts),
583
523
  ].join('\n');
584
524
 
585
525
  const headerIdx = content.indexOf(sectionHeader);
@@ -609,23 +549,33 @@ function findSectionEnd(content: string, fromIndex: number): number {
609
549
  return nextHeader === -1 ? content.length : nextHeader;
610
550
  }
611
551
 
612
- // writeCodexSystemPrompt (re)writes the standing platform prompt surfaces:
613
- // .parall/system-prompt.md (human-inspectable copy) and the workspace
614
- // .codex/config.toml developer_instructions (what the CLI actually loads).
552
+ /** Human-inspectable copy of the prompt; also the migration's authorship proof. */
553
+ function systemPromptCopyPath(workspaceDir: string): string {
554
+ return path.join(workspaceDir, '.parall', 'system-prompt.md');
555
+ }
556
+
557
+ // writeCodexSystemPrompt (re)builds the platform prompt and refreshes its
558
+ // human-inspectable reference copy (.parall/system-prompt.md). Delivery to
559
+ // the CLI is the app-server per-thread `developerInstructions` param — NOT
560
+ // a workspace config file.
615
561
  // capabilityFragments are platform-derived capability declarations
616
562
  // (agents.capabilities[].fragment) — placed after the platform reference
617
563
  // guide, before skill references; empty/absent = no capability section.
618
- // Split out from ensureCodexWorkspace so the bridge can rewrite on
619
- // platform-config heat-update; the app-server reads workspace config at
620
- // process start, so the caller pairs a CHANGED fragment set with
621
- // adapter.requestProcessRestart(). Throws on failure BY DESIGN at boot the
622
- // prompt is mandatory, so a write failure must fail startup loudly; the
623
- // refresh hot path wraps this in try/catch and retries next refresh.
564
+ // Split out from ensureCodexWorkspace so the bridge can rebuild the prompt on
565
+ // a platform-config heat-update and hand the new value to
566
+ // adapter.updateConfig. Delivery to threads is the adapter's job: baked into
567
+ // new threads at thread/start; converged onto the persisted main thread via
568
+ // the lazy restart's fresh-process thread/resume (canonical configuration)
569
+ // plus an explicit compaction (model-visible context) see
570
+ // src/instructions-refresh.ts for the two-plane semantics.
571
+ // Throws on failure BY DESIGN — at boot the prompt is mandatory, so a write
572
+ // failure must fail startup loudly; the refresh hot path wraps this in
573
+ // try/catch and retries next refresh.
624
574
  export function writeCodexSystemPrompt(
625
575
  workspaceDir: string,
626
576
  agentIdentity?: AgentIdentity,
627
577
  capabilityFragments?: string[],
628
- ): void {
578
+ ): string {
629
579
  const parts = [
630
580
  buildIdentity(agentIdentity),
631
581
  BRIDGE_WORKSPACE_INSTRUCTIONS,
@@ -638,14 +588,17 @@ export function writeCodexSystemPrompt(
638
588
  parts.push(buildSkillReferences(workspaceDir));
639
589
  const systemPrompt = parts.join('\n\n');
640
590
 
641
- const parallDir = path.join(workspaceDir, '.parall');
642
- fs.mkdirSync(parallDir, { recursive: true });
643
- fs.writeFileSync(path.join(parallDir, 'system-prompt.md'), systemPrompt, 'utf8');
644
-
645
- const codexConfigDir = path.join(workspaceDir, '.codex');
646
- fs.mkdirSync(codexConfigDir, { recursive: true });
647
- const toml = `developer_instructions = """\n${systemPrompt.replace(/\\/g, '\\\\').replace(/"""/g, '\\"""')}\n"""\n`;
648
- fs.writeFileSync(path.join(codexConfigDir, 'config.toml'), toml, 'utf8');
591
+ // Reference copy the agent can read as a file. The prompt itself is
592
+ // delivered per-thread via the app-server `developerInstructions` param —
593
+ // deliberately NOT via workspace `.codex/config.toml`, which codex only
594
+ // loads for workspaces marked trusted in the operator's global config:
595
+ // that coupled the platform prompt to codex's interactive workspace-trust
596
+ // concept and forced the bridge to write trust entries into the
597
+ // operator's own ~/.codex on shared homes.
598
+ fs.mkdirSync(path.join(workspaceDir, '.parall'), { recursive: true });
599
+ fs.writeFileSync(systemPromptCopyPath(workspaceDir), systemPrompt, 'utf8');
600
+
601
+ return systemPrompt;
649
602
  }
650
603
 
651
604
  export function ensureCodexWorkspace(
@@ -653,12 +606,59 @@ export function ensureCodexWorkspace(
653
606
  log?: { warn: (msg: string) => void },
654
607
  agentIdentity?: AgentIdentity,
655
608
  capabilityFragments?: string[],
656
- ): void {
609
+ ): string {
657
610
  fs.mkdirSync(workspaceDir, { recursive: true });
658
611
 
612
+ // The ONE ordering rule this bootstrap has to get right.
613
+ //
614
+ // `.parall/system-prompt.md` is the migration's authorship proof, and
615
+ // writeCodexSystemPrompt below overwrites it. It is therefore legacy-era
616
+ // evidence for exactly one boot, so the migration must take its one-way claim
617
+ // and consume the proof BEFORE that write. The migration refuses to delete
618
+ // anything once the claim exists, so no later boot can judge the legacy config
619
+ // against a prompt this bridge wrote itself. A claim that cannot be persisted
620
+ // throws from here — the boot dies with the evidence intact rather than
621
+ // manufacturing a prompt a later boot would mistake for it.
622
+ //
623
+ // The proof goes in as a THUNK: reading it warns when it exists but cannot be
624
+ // read, and a boot that already lost (or never had) the claim must stay
625
+ // silent. Handing over an evaluated value would fire that warning before the
626
+ // claim is even checked.
627
+ runLegacyWorkspaceConfigMigration(
628
+ workspaceDir,
629
+ () => readAuthorshipProof(workspaceDir, log),
630
+ log,
631
+ );
632
+
659
633
  // Boot: let a write failure PROPAGATE (fatal) — startup must not proceed
660
634
  // without the platform prompt. The refresh path tolerates failure.
661
- writeCodexSystemPrompt(workspaceDir, agentIdentity, capabilityFragments);
635
+ const systemPrompt = writeCodexSystemPrompt(workspaceDir, agentIdentity, capabilityFragments);
662
636
 
663
637
  writeSkillFiles(path.join(workspaceDir, '.parall', 'skills'));
638
+
639
+ return systemPrompt;
640
+ }
641
+
642
+ /**
643
+ * The previous boot's prompt copy — the migration's authorship proof.
644
+ *
645
+ * ENOENT is the ordinary first boot in a workspace: absent, no warning. Any
646
+ * other error means the proof EXISTS but could not be read, which is a
647
+ * different fact and must not be reported as "never bootstrapped here" — the
648
+ * migration would otherwise preserve a genuinely stale config while telling the
649
+ * operator the wrong reason. Both map to "don't delete"; only the diagnostic
650
+ * differs.
651
+ */
652
+ function readAuthorshipProof(
653
+ workspaceDir: string,
654
+ log?: { warn: (msg: string) => void },
655
+ ): AuthorshipProof {
656
+ const proofPath = systemPromptCopyPath(workspaceDir);
657
+ try {
658
+ return { kind: 'present', prompt: fs.readFileSync(proofPath, 'utf8') };
659
+ } catch (err) {
660
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'absent' };
661
+ log?.warn(`could not read the authorship proof ${proofPath}: ${String(err)}`);
662
+ return { kind: 'unreadable' };
663
+ }
664
664
  }