@parall/codex-agent 1.42.0 → 1.43.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/dispatch.d.ts +18 -0
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +42 -0
- package/dist/index.js +41 -4
- package/dist/workspace.d.ts +87 -1
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +445 -9
- package/package.json +4 -4
- package/src/dispatch.ts +54 -0
- package/src/index.ts +41 -2
- package/src/workspace.ts +457 -12
package/dist/workspace.js
CHANGED
|
@@ -2,6 +2,406 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { parse as parseToml } from 'smol-toml';
|
|
4
4
|
import { BRIDGE_WORKSPACE_INSTRUCTIONS, PRLL_BEHAVIOR, PRLL_REFERENCE_GUIDE, buildIdentity, buildSkillReferences, writeSkillFiles, } from '@parall/agent-core';
|
|
5
|
+
/**
|
|
6
|
+
* Config-lock timings, exported for tests.
|
|
7
|
+
*
|
|
8
|
+
* `staleMs` — a queue ticket untouched this long is treated as a crashed
|
|
9
|
+
* owner (live contenders refresh their ticket's mtime every `retryMs`, so
|
|
10
|
+
* the margin is ~400×). `waitMs` deliberately exceeds `staleMs`: a waiter
|
|
11
|
+
* facing a crashed head-of-queue outlasts the staleness threshold and skips
|
|
12
|
+
* it instead of falling through to a lockless write — the lockless fallback
|
|
13
|
+
* is a last resort, not a normal path.
|
|
14
|
+
*/
|
|
15
|
+
export const CONFIG_LOCK_TIMINGS = {
|
|
16
|
+
staleMs: 10_000,
|
|
17
|
+
waitMs: 15_000,
|
|
18
|
+
retryMs: 25,
|
|
19
|
+
};
|
|
20
|
+
// Reused wait signal: sleepSync fires every retryMs under contention, and a
|
|
21
|
+
// fresh SharedArrayBuffer per call is avoidable allocation churn.
|
|
22
|
+
const SLEEP_SIGNAL = new Int32Array(new SharedArrayBuffer(4));
|
|
23
|
+
function sleepSync(ms) {
|
|
24
|
+
Atomics.wait(SLEEP_SIGNAL, 0, 0, ms);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Advisory cross-process lock serializing `<codexHome>/config.toml`
|
|
28
|
+
* read-modify-write. On a local daemon machine every runtime_auth codex
|
|
29
|
+
* bridge child shares the operator's CODEX_HOME, and the daemon starts them
|
|
30
|
+
* together — without a lock, a whole-file rewrite working from a stale read
|
|
31
|
+
* can erase a trust entry another agent appended in between.
|
|
32
|
+
*
|
|
33
|
+
* Queue design (why not wx-create + steal): any scheme that renames or
|
|
34
|
+
* unlinks the SHARED lock path can, between its staleness check and the
|
|
35
|
+
* destructive op, hit a fresh lock that replaced the stale one — deleting a
|
|
36
|
+
* live holder's lock and overlapping critical sections (reproduced under
|
|
37
|
+
* 12-process contention). Here contenders queue in `config.toml.lock.d/`
|
|
38
|
+
* using Lamport's bakery protocol, and the smallest live ticket holds the
|
|
39
|
+
* lock. Two phases per enqueue: (1) create a `choosing-<token>` marker,
|
|
40
|
+
* (2) take seq = max(existing ticket seqs) + 1 and publish by atomically
|
|
41
|
+
* RENAMING the marker into `t-<seq>-<token>`. Evaluators wait while any
|
|
42
|
+
* live choosing marker exists, so a contender that read the queue but
|
|
43
|
+
* hasn't published yet can never be missed — the classic bakery guarantee.
|
|
44
|
+
* (A naive self-chosen timestamp order would race: a process descheduled
|
|
45
|
+
* between choosing its stamp and writing its ticket could insert itself
|
|
46
|
+
* before an already-running holder.) The rename-publish closes the
|
|
47
|
+
* stale-mid-choosing hole: once a frozen chooser's marker has been GC'd,
|
|
48
|
+
* its publish fails and it must re-enqueue fresh — it can never surface an
|
|
49
|
+
* old low seq under a holder elected in its absence. ENTRY is the same
|
|
50
|
+
* pattern: the elected head atomically renames its ticket into a
|
|
51
|
+
* `held-<entry-ts>-…` entry, racing any staleness eviction of that ticket
|
|
52
|
+
* on the same path — a contender revived at the staleness boundary loses
|
|
53
|
+
* the rename and re-enqueues rather than entering behind an eviction. The
|
|
54
|
+
* holder's freshness is embedded in the held name (atomic with entry), so
|
|
55
|
+
* evicting a holder needs no stat. Evaluators mirror the atomicity: after
|
|
56
|
+
* any GC attempt, or on a marker that vanished mid-scan, they take a fresh
|
|
57
|
+
* snapshot instead of electing from the old one. Ticket order is
|
|
58
|
+
* (seq, token), identical for every observer.
|
|
59
|
+
*
|
|
60
|
+
* No process ever mutates a shared path: release unlinks only the caller's
|
|
61
|
+
* own ticket, and the only cross-process destructive op is GC of entries
|
|
62
|
+
* untouched for `staleMs` — safe because live contenders refresh their
|
|
63
|
+
* ticket's mtime every `retryMs`, and names embed a per-acquisition random
|
|
64
|
+
* token so they are never reused (no identity switch on unlink).
|
|
65
|
+
*
|
|
66
|
+
* Residual (documented, not fixable without OS-level flock, which Node core
|
|
67
|
+
* does not expose): a HOLDER frozen inside `fn` for longer than `staleMs`
|
|
68
|
+
* looks crashed, gets evicted, and the next head may overlap it — `fn` is a
|
|
69
|
+
* millisecond-scale sync config write, 400× within margin. Freezes anywhere
|
|
70
|
+
* else (mid-choosing, at entry) are safe: the atomic renames fail after an
|
|
71
|
+
* eviction and the process re-enqueues. On `waitMs` timeout
|
|
72
|
+
* the mutation proceeds without the lock (warn) — blocking would wedge
|
|
73
|
+
* bridge startup. The lock coordinates bridge processes only; codex itself
|
|
74
|
+
* does not observe it, which is why bridge writes to a shared config are
|
|
75
|
+
* additionally kept rare (the trust write is a no-op after the first boot
|
|
76
|
+
* per workspace, and runtime_auth agents never write the provider block)
|
|
77
|
+
* and whole-file writes are atomic (temp + rename) so codex never reads a
|
|
78
|
+
* truncated file.
|
|
79
|
+
*
|
|
80
|
+
* Exported for tests.
|
|
81
|
+
*/
|
|
82
|
+
export function withConfigLock(codexHome, log, fn) {
|
|
83
|
+
const queueDir = path.join(codexHome, 'config.toml.lock.d');
|
|
84
|
+
let ticketName = bakeryEnqueue(queueDir);
|
|
85
|
+
let ticketPath = ticketName ? path.join(queueDir, ticketName) : '';
|
|
86
|
+
let acquired = false;
|
|
87
|
+
let heldPath = '';
|
|
88
|
+
const deadline = Date.now() + CONFIG_LOCK_TIMINGS.waitMs;
|
|
89
|
+
while (ticketName && Date.now() < deadline) {
|
|
90
|
+
let names;
|
|
91
|
+
try {
|
|
92
|
+
names = fs.readdirSync(queueDir).sort();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
break; // dir vanished under us — lockless fallback
|
|
96
|
+
}
|
|
97
|
+
if (!names.includes(ticketName)) {
|
|
98
|
+
// Our ticket was GC'd (we looked frozen) or wiped — re-run the full
|
|
99
|
+
// bakery enqueue rather than silently proceeding without a position.
|
|
100
|
+
ticketName = bakeryEnqueue(queueDir);
|
|
101
|
+
if (!ticketName)
|
|
102
|
+
break;
|
|
103
|
+
ticketPath = path.join(queueDir, ticketName);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
let rescan = false; // snapshot invalidated — re-readdir before electing
|
|
108
|
+
let blocked = false; // a live chooser/holder is ahead of us
|
|
109
|
+
let head;
|
|
110
|
+
for (const name of names) {
|
|
111
|
+
const entryPath = path.join(queueDir, name);
|
|
112
|
+
if (name.startsWith('held-')) {
|
|
113
|
+
// The holder's freshness is embedded in the name at entry time
|
|
114
|
+
// (atomic with the entry rename), so there is no stat window here.
|
|
115
|
+
const enteredAt = Number.parseInt(name.slice(5, 20), 10);
|
|
116
|
+
if (Number.isFinite(enteredAt) && now - enteredAt > CONFIG_LOCK_TIMINGS.staleMs) {
|
|
117
|
+
// Holder frozen inside fn beyond staleMs — the documented
|
|
118
|
+
// residual. Evict and rescan.
|
|
119
|
+
try {
|
|
120
|
+
fs.unlinkSync(entryPath);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// released or GC'd concurrently
|
|
124
|
+
}
|
|
125
|
+
rescan = true;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
blocked = true; // live holder — the lock is taken
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
let mtimeMs;
|
|
132
|
+
try {
|
|
133
|
+
mtimeMs = fs.statSync(entryPath).mtimeMs;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// The entry vanished between readdir and stat — it did not merely
|
|
137
|
+
// leave, it may have TRANSITIONED via an atomic rename this snapshot
|
|
138
|
+
// cannot see: a choosing marker into a published ticket, or a head
|
|
139
|
+
// ticket into a live held entry (caught overlapping under the
|
|
140
|
+
// 12-process barrier test when this path skipped tickets). Never
|
|
141
|
+
// elect from a snapshot that missed a transition — rescan.
|
|
142
|
+
rescan = true;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
if (now - mtimeMs > CONFIG_LOCK_TIMINGS.staleMs) {
|
|
146
|
+
// Crashed contender (live ones refresh every retryMs; choosing is
|
|
147
|
+
// microsecond-scale). Unique never-reused names make this unlink
|
|
148
|
+
// safe — it cannot hit a different file than the one just observed.
|
|
149
|
+
try {
|
|
150
|
+
fs.unlinkSync(entryPath);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// Lost to a concurrent GC — or to the owner's atomic rename
|
|
154
|
+
// (marker → ticket publish, or ticket → held entry).
|
|
155
|
+
}
|
|
156
|
+
// Whether the unlink won or lost, the snapshot no longer reflects
|
|
157
|
+
// the queue (a stale marker may have become a live ticket) — rescan
|
|
158
|
+
// instead of electing from stale names.
|
|
159
|
+
rescan = true;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
// Sorted names put `choosing-*` before `held-*` before `t-*`, so
|
|
163
|
+
// blockers are seen before any head candidate.
|
|
164
|
+
if (name.startsWith('choosing-')) {
|
|
165
|
+
blocked = true;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
if (name.startsWith('t-')) {
|
|
169
|
+
head = name;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
// Foreign file in the queue dir — ignore it.
|
|
173
|
+
}
|
|
174
|
+
if (rescan)
|
|
175
|
+
continue; // GC/publish made progress; take a fresh snapshot
|
|
176
|
+
if (!blocked && head === ticketName) {
|
|
177
|
+
// ENTRY is an atomic rename of our own ticket into a `held-<now>-…`
|
|
178
|
+
// entry. It races any GC eviction of the ticket on the same path, so
|
|
179
|
+
// exactly one side wins: if a GC saw us stale at the boundary and
|
|
180
|
+
// evicted first, our rename fails and we re-enqueue instead of
|
|
181
|
+
// entering — a revived contender can never slip into the critical
|
|
182
|
+
// section behind an eviction. The entry timestamp rides in the name,
|
|
183
|
+
// atomic with the transition itself.
|
|
184
|
+
CONFIG_LOCK_TEST_HOOKS.beforeTicketEntry?.();
|
|
185
|
+
const heldName = `held-${String(Date.now()).padStart(15, '0')}-${ticketName.slice(2)}`;
|
|
186
|
+
const candidateHeldPath = path.join(queueDir, heldName);
|
|
187
|
+
try {
|
|
188
|
+
fs.renameSync(ticketPath, candidateHeldPath);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
continue; // evicted at the boundary — the missing-ticket branch re-enqueues
|
|
192
|
+
}
|
|
193
|
+
heldPath = candidateHeldPath;
|
|
194
|
+
acquired = true;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const t = new Date();
|
|
199
|
+
fs.utimesSync(ticketPath, t, t); // keep our ticket visibly live
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// ticket missing — the next iteration re-enqueues
|
|
203
|
+
}
|
|
204
|
+
sleepSync(CONFIG_LOCK_TIMINGS.retryMs);
|
|
205
|
+
}
|
|
206
|
+
if (!acquired) {
|
|
207
|
+
log?.warn(`could not acquire ${queueDir}; writing config.toml without lock`);
|
|
208
|
+
// Leave the queue before writing lockless so our abandoned ticket does
|
|
209
|
+
// not block other contenders for another staleMs.
|
|
210
|
+
if (ticketName) {
|
|
211
|
+
try {
|
|
212
|
+
fs.unlinkSync(ticketPath);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// already GC'd
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
fn();
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
if (acquired) {
|
|
224
|
+
try {
|
|
225
|
+
fs.unlinkSync(heldPath); // own unique name — no identity race
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
// evicted by a contender that saw us frozen inside fn — nothing to release
|
|
229
|
+
}
|
|
230
|
+
// The queue dir is deliberately NEVER removed: a contender may have
|
|
231
|
+
// finished mkdir but not yet created its choosing marker, and
|
|
232
|
+
// deleting the dir in that gap would send it down the lockless
|
|
233
|
+
// fallback. An empty config.toml.lock.d on disk is expected.
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Test-only scheduling hooks for deterministic race tests. Production code
|
|
239
|
+
* never sets these; tests use them to pause a contender at the two points
|
|
240
|
+
* where another process can act in between (barrier files stand in for the
|
|
241
|
+
* OS scheduler).
|
|
242
|
+
*/
|
|
243
|
+
export const CONFIG_LOCK_TEST_HOOKS = {};
|
|
244
|
+
/**
|
|
245
|
+
* Bakery enqueue: announce with a choosing marker, take
|
|
246
|
+
* seq = max(visible tickets) + 1, then PUBLISH BY RENAMING THE MARKER INTO
|
|
247
|
+
* THE TICKET. The rename is the linchpin against the stale-mid-choosing
|
|
248
|
+
* race: if this process froze after creating the marker and a contender
|
|
249
|
+
* GC'd it as stale (allowing others to elect and enter), the rename fails
|
|
250
|
+
* with ENOENT — the stale seq is discarded and we re-enqueue under a fresh
|
|
251
|
+
* token. Publishing and retiring the marker are one atomic step, so at no
|
|
252
|
+
* instant can an evaluator observe "no marker, and no ticket either" for an
|
|
253
|
+
* in-flight enqueue.
|
|
254
|
+
*
|
|
255
|
+
* Returns the ticket name, or null when the queue dir is unusable (caller
|
|
256
|
+
* falls back to a lockless warn-logged write).
|
|
257
|
+
*/
|
|
258
|
+
function bakeryEnqueue(queueDir) {
|
|
259
|
+
// A GC'd marker aborts the attempt; retry under a FRESH token so names
|
|
260
|
+
// are never reused. Being GC'd requires a >staleMs freeze mid-choosing,
|
|
261
|
+
// so two spare attempts are already generous.
|
|
262
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
263
|
+
const token = `${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
|
|
264
|
+
const markerPath = path.join(queueDir, `choosing-${token}`);
|
|
265
|
+
try {
|
|
266
|
+
fs.mkdirSync(queueDir, { recursive: true });
|
|
267
|
+
CONFIG_LOCK_TEST_HOOKS.beforeChoosingMarker?.();
|
|
268
|
+
fs.writeFileSync(markerPath, '', { flag: 'wx' });
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
let maxSeq = 0;
|
|
275
|
+
for (const name of fs.readdirSync(queueDir)) {
|
|
276
|
+
if (!name.startsWith('t-'))
|
|
277
|
+
continue;
|
|
278
|
+
const seq = Number.parseInt(name.slice(2, 12), 10);
|
|
279
|
+
if (Number.isFinite(seq) && seq > maxSeq)
|
|
280
|
+
maxSeq = seq;
|
|
281
|
+
}
|
|
282
|
+
const ticketName = `t-${String(maxSeq + 1).padStart(10, '0')}-${token}`;
|
|
283
|
+
const ticketPath = path.join(queueDir, ticketName);
|
|
284
|
+
CONFIG_LOCK_TEST_HOOKS.beforeTicketPublish?.();
|
|
285
|
+
fs.renameSync(markerPath, ticketPath);
|
|
286
|
+
try {
|
|
287
|
+
// The ticket inherits the marker's mtime; refresh it so a slow
|
|
288
|
+
// choose does not hand evaluators a stale-at-birth ticket. Runs
|
|
289
|
+
// before our first head evaluation, so if a GC wins the race on the
|
|
290
|
+
// stale-born ticket we simply re-enqueue — never elect on it.
|
|
291
|
+
const t = new Date();
|
|
292
|
+
fs.utimesSync(ticketPath, t, t);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
// GC'd already — the eval loop re-enqueues on the missing ticket.
|
|
296
|
+
}
|
|
297
|
+
return ticketName;
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
// Retire our marker if it still exists (e.g. readdir failed).
|
|
301
|
+
try {
|
|
302
|
+
fs.unlinkSync(markerPath);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
// already renamed or GC'd
|
|
306
|
+
}
|
|
307
|
+
if (err.code === 'ENOENT')
|
|
308
|
+
continue; // marker GC'd — fresh token
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Whole-file config writes go through temp + rename so a concurrent reader
|
|
316
|
+
* (including codex itself, which does not observe the advisory lock) never
|
|
317
|
+
* sees a truncated file. The trust-append path intentionally keeps
|
|
318
|
+
* appendFileSync — O_APPEND is atomic for these small writes and cannot
|
|
319
|
+
* clobber a concurrent append.
|
|
320
|
+
*
|
|
321
|
+
* The rename targets the file's REAL path: `config.toml` managed by a
|
|
322
|
+
* dotfiles setup is often a symlink, and renaming onto the link path would
|
|
323
|
+
* replace the link with a regular file while the real target keeps the old
|
|
324
|
+
* content. The temp file is born 0600 (never a world-readable window, even
|
|
325
|
+
* pre-chmod) and created with `wx` so a colliding path is never truncated;
|
|
326
|
+
* an existing file's mode is then restored explicitly (chmod, not
|
|
327
|
+
* open-mode, so umask cannot mask bits off), while a brand-new config stays
|
|
328
|
+
* at the restrictive 0600.
|
|
329
|
+
*/
|
|
330
|
+
function writeConfigAtomic(filePath, content) {
|
|
331
|
+
const realPath = resolveWriteTarget(filePath);
|
|
332
|
+
let mode;
|
|
333
|
+
try {
|
|
334
|
+
mode = fs.statSync(realPath).mode & 0o777;
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
// New file — keep the restrictive 0600 creation mode.
|
|
338
|
+
}
|
|
339
|
+
const tmpPath = `${realPath}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
340
|
+
let tmpCreated = false;
|
|
341
|
+
try {
|
|
342
|
+
fs.writeFileSync(tmpPath, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
343
|
+
tmpCreated = true;
|
|
344
|
+
if (mode !== undefined)
|
|
345
|
+
fs.chmodSync(tmpPath, mode);
|
|
346
|
+
fs.renameSync(tmpPath, realPath);
|
|
347
|
+
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
// Clean up only what THIS call put on disk: on a wx EEXIST the path
|
|
350
|
+
// belongs to someone else and must not be unlinked. (A create that
|
|
351
|
+
// failed mid-write still left our file — code ≠ EEXIST — so it is
|
|
352
|
+
// removed.)
|
|
353
|
+
if (tmpCreated || err.code !== 'EEXIST') {
|
|
354
|
+
try {
|
|
355
|
+
fs.unlinkSync(tmpPath);
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
// tmp never made it to disk
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
throw err;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Follow symlinks (including dangling ones) to the path a write should land
|
|
366
|
+
* on. Never returns an unresolved symlink: a cycle or an over-deep chain
|
|
367
|
+
* throws instead, so the atomic rename can never silently replace a link.
|
|
368
|
+
*/
|
|
369
|
+
function resolveWriteTarget(filePath) {
|
|
370
|
+
try {
|
|
371
|
+
return fs.realpathSync(filePath);
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
// ELOOP (cycle among existing links) and every other real error must
|
|
375
|
+
// surface; only a missing target falls through to the manual walk.
|
|
376
|
+
if (err.code !== 'ENOENT')
|
|
377
|
+
throw err;
|
|
378
|
+
}
|
|
379
|
+
// Some component is missing — the path may still be a dangling symlink
|
|
380
|
+
// chain; walk it manually so the file is created where the links point.
|
|
381
|
+
const seen = new Set();
|
|
382
|
+
let p = path.resolve(filePath);
|
|
383
|
+
for (let depth = 0; depth < 40; depth++) {
|
|
384
|
+
if (seen.has(p)) {
|
|
385
|
+
throw new Error(`symlink cycle at ${p} while resolving ${filePath}`);
|
|
386
|
+
}
|
|
387
|
+
seen.add(p);
|
|
388
|
+
let link;
|
|
389
|
+
try {
|
|
390
|
+
link = fs.readlinkSync(p);
|
|
391
|
+
}
|
|
392
|
+
catch (err) {
|
|
393
|
+
const code = err.code;
|
|
394
|
+
// ENOENT (nothing here yet) and EINVAL (a real non-link file) are the
|
|
395
|
+
// two legitimate ends of a chain — create/replace at this path. Any
|
|
396
|
+
// other error is a real failure and must surface.
|
|
397
|
+
if (code === 'ENOENT' || code === 'EINVAL')
|
|
398
|
+
return p;
|
|
399
|
+
throw err;
|
|
400
|
+
}
|
|
401
|
+
p = path.resolve(path.dirname(p), link);
|
|
402
|
+
}
|
|
403
|
+
throw new Error(`symlink chain deeper than 40 while resolving ${filePath}`);
|
|
404
|
+
}
|
|
5
405
|
/**
|
|
6
406
|
* Ensure the workspace directory is marked as trusted in the global Codex
|
|
7
407
|
* config so that project-level `developer_instructions` are loaded at
|
|
@@ -13,6 +413,9 @@ import { BRIDGE_WORKSPACE_INSTRUCTIONS, PRLL_BEHAVIOR, PRLL_REFERENCE_GUIDE, bui
|
|
|
13
413
|
* the bridge from starting.
|
|
14
414
|
*/
|
|
15
415
|
export function ensureWorkspaceTrusted(codexHome, workspaceDir, log) {
|
|
416
|
+
withConfigLock(codexHome, log, () => ensureWorkspaceTrustedLocked(codexHome, workspaceDir, log));
|
|
417
|
+
}
|
|
418
|
+
function ensureWorkspaceTrustedLocked(codexHome, workspaceDir, log) {
|
|
16
419
|
const configPath = path.join(codexHome, 'config.toml');
|
|
17
420
|
const normalizedPath = path.resolve(workspaceDir);
|
|
18
421
|
try {
|
|
@@ -37,8 +440,17 @@ export function ensureWorkspaceTrusted(codexHome, workspaceDir, log) {
|
|
|
37
440
|
}
|
|
38
441
|
}
|
|
39
442
|
const projects = parsed?.projects;
|
|
40
|
-
|
|
443
|
+
const existingTrust = projects?.[normalizedPath]?.trust_level;
|
|
444
|
+
if (existingTrust === 'trusted')
|
|
445
|
+
return;
|
|
446
|
+
if (existingTrust !== undefined) {
|
|
447
|
+
// An explicit non-trusted value is a human decision — on a local
|
|
448
|
+
// runtime_auth daemon this file IS the operator's own ~/.codex config,
|
|
449
|
+
// and a workspace they deliberately marked untrusted must never be
|
|
450
|
+
// silently flipped by an agent. Leave it and surface the consequence.
|
|
451
|
+
log?.warn(`Codex config marks ${normalizedPath} as trust_level=${JSON.stringify(existingTrust)}; respecting the explicit decision — project-level developer_instructions will not load for this workspace`);
|
|
41
452
|
return;
|
|
453
|
+
}
|
|
42
454
|
// TOML basic-string keys require backslash and double-quote escaping.
|
|
43
455
|
const escapedPath = normalizedPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
44
456
|
const sectionHeader = `[projects."${escapedPath}"]`;
|
|
@@ -68,7 +480,7 @@ export function ensureWorkspaceTrusted(codexHome, workspaceDir, log) {
|
|
|
68
480
|
content.substring(0, afterHeader) + trustLine + '\n' + content.substring(afterHeader);
|
|
69
481
|
}
|
|
70
482
|
}
|
|
71
|
-
|
|
483
|
+
writeConfigAtomic(configPath, content);
|
|
72
484
|
}
|
|
73
485
|
else if (projects?.[normalizedPath] !== undefined) {
|
|
74
486
|
// smol-toml found the section but indexOf missed it — the header
|
|
@@ -119,6 +531,9 @@ export function isParallProxyMode(env = process.env) {
|
|
|
119
531
|
* Provider-managed: overwritten on every boot (env vars are the SSOT).
|
|
120
532
|
*/
|
|
121
533
|
export function ensureParallProvider(codexHome, apiUrl, log) {
|
|
534
|
+
withConfigLock(codexHome, log, () => ensureParallProviderLocked(codexHome, apiUrl));
|
|
535
|
+
}
|
|
536
|
+
function ensureParallProviderLocked(codexHome, apiUrl) {
|
|
122
537
|
const configPath = path.join(codexHome, 'config.toml');
|
|
123
538
|
const baseUrl = apiUrl.replace(/\/$/, '') + '/api/llm/v1';
|
|
124
539
|
try {
|
|
@@ -158,7 +573,7 @@ export function ensureParallProvider(codexHome, apiUrl, log) {
|
|
|
158
573
|
content = content.trimEnd() + '\n\n' + providerBlock + '\n';
|
|
159
574
|
}
|
|
160
575
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
161
|
-
|
|
576
|
+
writeConfigAtomic(configPath, content);
|
|
162
577
|
}
|
|
163
578
|
catch (err) {
|
|
164
579
|
throw new Error(`failed to write Parall provider config: ${String(err)}`);
|
|
@@ -170,21 +585,42 @@ function findSectionEnd(content, fromIndex) {
|
|
|
170
585
|
const nextHeader = content.indexOf('\n[', fromIndex);
|
|
171
586
|
return nextHeader === -1 ? content.length : nextHeader;
|
|
172
587
|
}
|
|
173
|
-
|
|
174
|
-
|
|
588
|
+
// writeCodexSystemPrompt (re)writes the standing platform prompt surfaces:
|
|
589
|
+
// .parall/system-prompt.md (human-inspectable copy) and the workspace
|
|
590
|
+
// .codex/config.toml developer_instructions (what the CLI actually loads).
|
|
591
|
+
// capabilityFragments are platform-derived capability declarations
|
|
592
|
+
// (agents.capabilities[].fragment) — placed after the platform reference
|
|
593
|
+
// guide, before skill references; empty/absent = no capability section.
|
|
594
|
+
// Split out from ensureCodexWorkspace so the bridge can rewrite on
|
|
595
|
+
// platform-config heat-update; the app-server reads workspace config at
|
|
596
|
+
// process start, so the caller pairs a CHANGED fragment set with
|
|
597
|
+
// adapter.requestProcessRestart(). Throws on failure BY DESIGN — at boot the
|
|
598
|
+
// prompt is mandatory, so a write failure must fail startup loudly; the
|
|
599
|
+
// refresh hot path wraps this in try/catch and retries next refresh.
|
|
600
|
+
export function writeCodexSystemPrompt(workspaceDir, agentIdentity, capabilityFragments) {
|
|
601
|
+
const parts = [
|
|
175
602
|
buildIdentity(agentIdentity),
|
|
176
603
|
BRIDGE_WORKSPACE_INSTRUCTIONS,
|
|
177
604
|
PRLL_BEHAVIOR,
|
|
178
605
|
PRLL_REFERENCE_GUIDE,
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
606
|
+
];
|
|
607
|
+
if (capabilityFragments && capabilityFragments.length > 0) {
|
|
608
|
+
parts.push(capabilityFragments.join('\n\n'));
|
|
609
|
+
}
|
|
610
|
+
parts.push(buildSkillReferences(workspaceDir));
|
|
611
|
+
const systemPrompt = parts.join('\n\n');
|
|
182
612
|
const parallDir = path.join(workspaceDir, '.parall');
|
|
183
613
|
fs.mkdirSync(parallDir, { recursive: true });
|
|
184
614
|
fs.writeFileSync(path.join(parallDir, 'system-prompt.md'), systemPrompt, 'utf8');
|
|
185
|
-
writeSkillFiles(path.join(parallDir, 'skills'));
|
|
186
615
|
const codexConfigDir = path.join(workspaceDir, '.codex');
|
|
187
616
|
fs.mkdirSync(codexConfigDir, { recursive: true });
|
|
188
617
|
const toml = `developer_instructions = """\n${systemPrompt.replace(/\\/g, '\\\\').replace(/"""/g, '\\"""')}\n"""\n`;
|
|
189
618
|
fs.writeFileSync(path.join(codexConfigDir, 'config.toml'), toml, 'utf8');
|
|
190
619
|
}
|
|
620
|
+
export function ensureCodexWorkspace(workspaceDir, log, agentIdentity, capabilityFragments) {
|
|
621
|
+
fs.mkdirSync(workspaceDir, { recursive: true });
|
|
622
|
+
// Boot: let a write failure PROPAGATE (fatal) — startup must not proceed
|
|
623
|
+
// without the platform prompt. The refresh path tolerates failure.
|
|
624
|
+
writeCodexSystemPrompt(workspaceDir, agentIdentity, capabilityFragments);
|
|
625
|
+
writeSkillFiles(path.join(workspaceDir, '.parall', 'skills'));
|
|
626
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parall/codex-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.43.0",
|
|
4
4
|
"description": "Codex CLI bridge runtime for self-hosted Parall agents",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"smol-toml": "^1.6.1",
|
|
29
|
-
"@parall/agent-core": "1.
|
|
30
|
-
"@parall/cli": "1.
|
|
31
|
-
"@parall/sdk": "1.
|
|
29
|
+
"@parall/agent-core": "1.43.0",
|
|
30
|
+
"@parall/cli": "1.43.0",
|
|
31
|
+
"@parall/sdk": "1.43.0"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/node": "^22.0.0",
|
package/src/dispatch.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execSync, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import * as fs from 'node:fs';
|
|
4
|
+
import * as path from 'node:path';
|
|
4
5
|
import {
|
|
5
6
|
appendPreparedLocalAttachmentRefs,
|
|
6
7
|
ensureLocalAttachmentGitExclude,
|
|
@@ -37,6 +38,14 @@ type CodexAppServerAdapterOptions = Pick<
|
|
|
37
38
|
contextFilePath?: string;
|
|
38
39
|
contextDirPath?: string;
|
|
39
40
|
useParallProvider?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Channel-capability shim directory (agent-core's capabilityBinDir),
|
|
43
|
+
* prepended to the subprocess PATH unconditionally. The DIRECTORY is
|
|
44
|
+
* constant for the bridge's lifetime; its CONTENT tracks capability
|
|
45
|
+
* grants, so a grant or revocation reaches even a long-lived app-server
|
|
46
|
+
* on its next shell command — no respawn needed.
|
|
47
|
+
*/
|
|
48
|
+
capabilityBinDir?: string;
|
|
40
49
|
};
|
|
41
50
|
|
|
42
51
|
type TurnEventEnvelope =
|
|
@@ -174,6 +183,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
174
183
|
);
|
|
175
184
|
}
|
|
176
185
|
|
|
186
|
+
await this.applyPendingRestart(context.log);
|
|
177
187
|
await this.ensureStarted(context.log);
|
|
178
188
|
// After ensureStarted resolves the subprocess could still die before we
|
|
179
189
|
// capture the client (handleSubprocessClose nulls this.client). Yield a
|
|
@@ -453,6 +463,28 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
453
463
|
return null;
|
|
454
464
|
}
|
|
455
465
|
|
|
466
|
+
/**
|
|
467
|
+
* Lazily restart the app-server before the NEXT turn: the workspace
|
|
468
|
+
* config.toml (developer_instructions, carrying capability fragments) is
|
|
469
|
+
* loaded at process start, so a changed fragment set needs a respawn to
|
|
470
|
+
* reach the prompt. Deferred to the next dispatch with no active turns —
|
|
471
|
+
* never kills an in-flight turn; thread state survives via thread/resume.
|
|
472
|
+
*/
|
|
473
|
+
requestProcessRestart(): void {
|
|
474
|
+
this.restartRequested = true;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private restartRequested = false;
|
|
478
|
+
|
|
479
|
+
private async applyPendingRestart(log?: GatewayLogger): Promise<void> {
|
|
480
|
+
if (!this.restartRequested || this.activeTurns.size > 0) return;
|
|
481
|
+
this.restartRequested = false;
|
|
482
|
+
(log ?? this.opts.log)?.info?.(
|
|
483
|
+
'restarting codex app-server to pick up updated developer_instructions',
|
|
484
|
+
);
|
|
485
|
+
await this.stop();
|
|
486
|
+
}
|
|
487
|
+
|
|
456
488
|
async stop() {
|
|
457
489
|
this.stopping = true;
|
|
458
490
|
const proc = this.proc;
|
|
@@ -462,6 +494,12 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
462
494
|
this.initialized = false;
|
|
463
495
|
this.activeTurnIds.clear();
|
|
464
496
|
this.pendingInjections.clear();
|
|
497
|
+
// Resume markers are process-local facts. Clear them HERE, synchronously:
|
|
498
|
+
// the old child's close callback sees the replaced refs and skips its
|
|
499
|
+
// cleanup as stale, so a marker surviving stop() would make the next
|
|
500
|
+
// dispatch treat the persisted thread as already resumed in the NEW
|
|
501
|
+
// process and lose conversation continuity when turn/start rejects.
|
|
502
|
+
this.resumedThreadIds.clear();
|
|
465
503
|
if (client) client.dispose(new Error('adapter stopped'));
|
|
466
504
|
if (proc && proc.exitCode === null && proc.signalCode === null) {
|
|
467
505
|
if (!IS_WIN32 || !proc.pid || !killWin32Tree(proc.pid)) {
|
|
@@ -515,6 +553,22 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
515
553
|
FORCE_COLOR: '0',
|
|
516
554
|
NO_COLOR: '1',
|
|
517
555
|
};
|
|
556
|
+
// Channel-capability shims resolve ahead of any globally-installed CLI of
|
|
557
|
+
// the same name (the shim owns auth; the real binary is found further
|
|
558
|
+
// down the PATH by the shim itself). On Windows env keys are
|
|
559
|
+
// case-insensitive and the host key is usually `Path`; after the
|
|
560
|
+
// plain-object spread a literal `PATH` write would SPLIT the key
|
|
561
|
+
// (undefined which wins in the child), so reuse the parent's casing. On
|
|
562
|
+
// POSIX keys are case-SENSITIVE — match `PATH` exactly.
|
|
563
|
+
if (this.opts.capabilityBinDir) {
|
|
564
|
+
const pathKey = IS_WIN32
|
|
565
|
+
? (Object.keys(env).find((k) => k.toUpperCase() === 'PATH') ?? 'PATH')
|
|
566
|
+
: 'PATH';
|
|
567
|
+
const existing = env[pathKey];
|
|
568
|
+
env[pathKey] = existing
|
|
569
|
+
? `${this.opts.capabilityBinDir}${path.delimiter}${existing}`
|
|
570
|
+
: this.opts.capabilityBinDir;
|
|
571
|
+
}
|
|
518
572
|
if (this.opts.contextFilePath) {
|
|
519
573
|
env.PRLL_CONTEXT_FILE = this.opts.contextFilePath;
|
|
520
574
|
}
|