@parall/codex-agent 1.44.0 → 1.45.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/app-server-process.d.ts +9 -0
- package/dist/app-server-process.d.ts.map +1 -0
- package/dist/app-server-process.js +58 -0
- package/dist/app-server-protocol.d.ts +19 -0
- package/dist/app-server-protocol.d.ts.map +1 -0
- package/dist/app-server-protocol.js +42 -0
- package/dist/dispatch.d.ts +43 -5
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +28 -136
- package/dist/index.js +24 -10
- package/dist/legacy-workspace-config-migration.d.ts +112 -0
- package/dist/legacy-workspace-config-migration.d.ts.map +1 -0
- package/dist/legacy-workspace-config-migration.js +229 -0
- package/dist/turn-sink.d.ts +23 -0
- package/dist/turn-sink.d.ts.map +1 -0
- package/dist/turn-sink.js +40 -0
- package/dist/workspace.d.ts +23 -25
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +139 -138
- package/package.json +5 -5
- package/src/app-server-process.ts +59 -0
- package/src/app-server-protocol.ts +46 -0
- package/src/dispatch.ts +57 -140
- package/src/index.ts +32 -10
- package/src/legacy-workspace-config-migration.ts +296 -0
- package/src/turn-sink.ts +48 -0
- package/src/workspace.ts +156 -155
package/src/turn-sink.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { RuntimeEvent } from '@parall/agent-core';
|
|
2
|
+
import { EventMapper } from './event-mapping.js';
|
|
3
|
+
|
|
4
|
+
export type TurnEventEnvelope =
|
|
5
|
+
| { kind: 'runtime'; event: RuntimeEvent }
|
|
6
|
+
| { kind: 'turn_end'; threadId?: string }
|
|
7
|
+
| { kind: 'error'; message: string };
|
|
8
|
+
|
|
9
|
+
/** Per-turn buffered sink backed by an unbounded promise queue. */
|
|
10
|
+
export class TurnSink {
|
|
11
|
+
readonly mapper = new EventMapper();
|
|
12
|
+
private readonly queue: TurnEventEnvelope[] = [];
|
|
13
|
+
private resolver: ((value: TurnEventEnvelope) => void) | null = null;
|
|
14
|
+
private closed = false;
|
|
15
|
+
|
|
16
|
+
push(envelope: TurnEventEnvelope) {
|
|
17
|
+
if (this.closed) return;
|
|
18
|
+
if (this.resolver) {
|
|
19
|
+
const r = this.resolver;
|
|
20
|
+
this.resolver = null;
|
|
21
|
+
r(envelope);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
this.queue.push(envelope);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
next(): Promise<TurnEventEnvelope> {
|
|
28
|
+
// Drain any queued envelopes first, even after close(). Otherwise a final
|
|
29
|
+
// error envelope enqueued right before close() (e.g. by
|
|
30
|
+
// handleSubprocessClose) is silently dropped because the consumer would
|
|
31
|
+
// see turn_end before it.
|
|
32
|
+
const pending = this.queue.shift();
|
|
33
|
+
if (pending) return Promise.resolve(pending);
|
|
34
|
+
if (this.closed) {
|
|
35
|
+
return Promise.resolve({ kind: 'turn_end' });
|
|
36
|
+
}
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
this.resolver = resolve;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
close() {
|
|
43
|
+
this.closed = true;
|
|
44
|
+
const r = this.resolver;
|
|
45
|
+
this.resolver = null;
|
|
46
|
+
r?.({ kind: 'turn_end' });
|
|
47
|
+
}
|
|
48
|
+
}
|
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.
|
|
41
|
-
*
|
|
42
|
-
* together — without a lock,
|
|
43
|
-
*
|
|
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
|
|
87
|
-
* additionally kept rare
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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.
|
|
326
|
-
*
|
|
327
|
-
*
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
582
|
-
'args = ["OPENAI_API_KEY"]',
|
|
522
|
+
...parallAuthLines(opts),
|
|
583
523
|
].join('\n');
|
|
584
524
|
|
|
585
525
|
const headerIdx = content.indexOf(sectionHeader);
|
|
@@ -609,23 +549,34 @@ function findSectionEnd(content: string, fromIndex: number): number {
|
|
|
609
549
|
return nextHeader === -1 ? content.length : nextHeader;
|
|
610
550
|
}
|
|
611
551
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
|
619
|
-
// platform-config heat-update
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
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, which carries it into the NEXT thread/start. A thread
|
|
567
|
+
// that is already open keeps the instructions it was started with — codex
|
|
568
|
+
// bakes them into the thread at start and neither thread/resume nor
|
|
569
|
+
// thread/fork replaces them (verified against a live 0.144.1 app-server; the
|
|
570
|
+
// retired workspace-config channel behaved identically). See
|
|
571
|
+
// docs/tech-debt/codex-persisted-thread-prompt-refresh.md.
|
|
572
|
+
// Throws on failure BY DESIGN — at boot the prompt is mandatory, so a write
|
|
573
|
+
// failure must fail startup loudly; the refresh hot path wraps this in
|
|
574
|
+
// try/catch and retries next refresh.
|
|
624
575
|
export function writeCodexSystemPrompt(
|
|
625
576
|
workspaceDir: string,
|
|
626
577
|
agentIdentity?: AgentIdentity,
|
|
627
578
|
capabilityFragments?: string[],
|
|
628
|
-
):
|
|
579
|
+
): string {
|
|
629
580
|
const parts = [
|
|
630
581
|
buildIdentity(agentIdentity),
|
|
631
582
|
BRIDGE_WORKSPACE_INSTRUCTIONS,
|
|
@@ -638,14 +589,17 @@ export function writeCodexSystemPrompt(
|
|
|
638
589
|
parts.push(buildSkillReferences(workspaceDir));
|
|
639
590
|
const systemPrompt = parts.join('\n\n');
|
|
640
591
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
fs.
|
|
592
|
+
// Reference copy the agent can read as a file. The prompt itself is
|
|
593
|
+
// delivered per-thread via the app-server `developerInstructions` param —
|
|
594
|
+
// deliberately NOT via workspace `.codex/config.toml`, which codex only
|
|
595
|
+
// loads for workspaces marked trusted in the operator's global config:
|
|
596
|
+
// that coupled the platform prompt to codex's interactive workspace-trust
|
|
597
|
+
// concept and forced the bridge to write trust entries into the
|
|
598
|
+
// operator's own ~/.codex on shared homes.
|
|
599
|
+
fs.mkdirSync(path.join(workspaceDir, '.parall'), { recursive: true });
|
|
600
|
+
fs.writeFileSync(systemPromptCopyPath(workspaceDir), systemPrompt, 'utf8');
|
|
601
|
+
|
|
602
|
+
return systemPrompt;
|
|
649
603
|
}
|
|
650
604
|
|
|
651
605
|
export function ensureCodexWorkspace(
|
|
@@ -653,12 +607,59 @@ export function ensureCodexWorkspace(
|
|
|
653
607
|
log?: { warn: (msg: string) => void },
|
|
654
608
|
agentIdentity?: AgentIdentity,
|
|
655
609
|
capabilityFragments?: string[],
|
|
656
|
-
):
|
|
610
|
+
): string {
|
|
657
611
|
fs.mkdirSync(workspaceDir, { recursive: true });
|
|
658
612
|
|
|
613
|
+
// The ONE ordering rule this bootstrap has to get right.
|
|
614
|
+
//
|
|
615
|
+
// `.parall/system-prompt.md` is the migration's authorship proof, and
|
|
616
|
+
// writeCodexSystemPrompt below overwrites it. It is therefore legacy-era
|
|
617
|
+
// evidence for exactly one boot, so the migration must take its one-way claim
|
|
618
|
+
// and consume the proof BEFORE that write. The migration refuses to delete
|
|
619
|
+
// anything once the claim exists, so no later boot can judge the legacy config
|
|
620
|
+
// against a prompt this bridge wrote itself. A claim that cannot be persisted
|
|
621
|
+
// throws from here — the boot dies with the evidence intact rather than
|
|
622
|
+
// manufacturing a prompt a later boot would mistake for it.
|
|
623
|
+
//
|
|
624
|
+
// The proof goes in as a THUNK: reading it warns when it exists but cannot be
|
|
625
|
+
// read, and a boot that already lost (or never had) the claim must stay
|
|
626
|
+
// silent. Handing over an evaluated value would fire that warning before the
|
|
627
|
+
// claim is even checked.
|
|
628
|
+
runLegacyWorkspaceConfigMigration(
|
|
629
|
+
workspaceDir,
|
|
630
|
+
() => readAuthorshipProof(workspaceDir, log),
|
|
631
|
+
log,
|
|
632
|
+
);
|
|
633
|
+
|
|
659
634
|
// Boot: let a write failure PROPAGATE (fatal) — startup must not proceed
|
|
660
635
|
// without the platform prompt. The refresh path tolerates failure.
|
|
661
|
-
writeCodexSystemPrompt(workspaceDir, agentIdentity, capabilityFragments);
|
|
636
|
+
const systemPrompt = writeCodexSystemPrompt(workspaceDir, agentIdentity, capabilityFragments);
|
|
662
637
|
|
|
663
638
|
writeSkillFiles(path.join(workspaceDir, '.parall', 'skills'));
|
|
639
|
+
|
|
640
|
+
return systemPrompt;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* The previous boot's prompt copy — the migration's authorship proof.
|
|
645
|
+
*
|
|
646
|
+
* ENOENT is the ordinary first boot in a workspace: absent, no warning. Any
|
|
647
|
+
* other error means the proof EXISTS but could not be read, which is a
|
|
648
|
+
* different fact and must not be reported as "never bootstrapped here" — the
|
|
649
|
+
* migration would otherwise preserve a genuinely stale config while telling the
|
|
650
|
+
* operator the wrong reason. Both map to "don't delete"; only the diagnostic
|
|
651
|
+
* differs.
|
|
652
|
+
*/
|
|
653
|
+
function readAuthorshipProof(
|
|
654
|
+
workspaceDir: string,
|
|
655
|
+
log?: { warn: (msg: string) => void },
|
|
656
|
+
): AuthorshipProof {
|
|
657
|
+
const proofPath = systemPromptCopyPath(workspaceDir);
|
|
658
|
+
try {
|
|
659
|
+
return { kind: 'present', prompt: fs.readFileSync(proofPath, 'utf8') };
|
|
660
|
+
} catch (err) {
|
|
661
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'absent' };
|
|
662
|
+
log?.warn(`could not read the authorship proof ${proofPath}: ${String(err)}`);
|
|
663
|
+
return { kind: 'unreadable' };
|
|
664
|
+
}
|
|
664
665
|
}
|