@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/src/index.ts CHANGED
@@ -3,11 +3,13 @@
3
3
  import * as os from 'node:os';
4
4
  import {
5
5
  ParallAgentGateway,
6
+ capabilityBinDir,
6
7
  createPlatformConfigManager,
7
8
  createLogger,
8
9
  createOtelLogger,
9
10
  childLogger,
10
11
  deriveModelIsPin,
12
+ materializeChannelCapabilities,
11
13
  resolveRuntimeModel,
12
14
  parseShutdownDeadlineMs,
13
15
  parseForkDeadlineMs,
@@ -34,6 +36,7 @@ import {
34
36
  ensureParallProvider,
35
37
  ensureWorkspaceTrusted,
36
38
  isParallProxyMode,
39
+ writeCodexSystemPrompt,
37
40
  } from './workspace.js';
38
41
 
39
42
  const log = createLogger('codex-agent');
@@ -88,11 +91,11 @@ async function main() {
88
91
  ensureParallProvider(config.codexHome, config.apiUrl, agentLog);
89
92
  agentLog.info('parall custom provider configured (Responses API HTTP/SSE mode)');
90
93
  }
91
- ensureCodexWorkspace(config.workspaceDir, agentLog, {
94
+ const agentIdentity = {
92
95
  userId: agentUserId,
93
96
  displayName: me.display_name,
94
97
  description: me.agent_profile?.description ?? undefined,
95
- });
98
+ };
96
99
  const runtimeKey = config.runtimeKey || buildCodexRuntimeKey(agentUserId);
97
100
  const mainContextFilePath = contextFilePathForSession(config.stateDir, runtimeKey);
98
101
  const sessionStateFilePath = sessionStateFilePathForRuntime(config.stateDir, runtimeKey);
@@ -111,6 +114,21 @@ async function main() {
111
114
  log: agentLog,
112
115
  });
113
116
  const platformDefaults = await configMgr.fetch();
117
+ // Channel capabilities: ONE call materializes the local artifacts (the
118
+ // lark-cli auth shim under capabilityBinDir) and yields the system-prompt
119
+ // fragments, so declaration and shim can never diverge within a refresh.
120
+ // No freshness gate — a stale (LKG-cached) declaration fail-closes at the
121
+ // mint endpoint, which re-evaluates the grant on every token request.
122
+ const applyChannelCapabilities = () => {
123
+ const caps = configMgr.capabilities();
124
+ materializeChannelCapabilities(config.stateDir, caps, agentLog);
125
+ return caps.map((c) => c.fragment);
126
+ };
127
+ // Assemble the workspace AFTER the first config fetch so
128
+ // developer_instructions carries the capability declarations from boot.
129
+ const bootCapabilityFragments = applyChannelCapabilities();
130
+ let lastCapabilityFragments = bootCapabilityFragments.join('\n\n');
131
+ ensureCodexWorkspace(config.workspaceDir, agentLog, agentIdentity, bootCapabilityFragments);
114
132
  // Model precedence: operator PIN (override) > env > server FLOOR. The server
115
133
  // now says which it is via model_is_pin (deriveModelIsPin presence-gates the
116
134
  // dual-read: old servers omit it → fall back to model_management). A PIN beats
@@ -143,6 +161,7 @@ async function main() {
143
161
  contextFilePath: mainContextFilePath,
144
162
  contextDirPath: dispatchContextDirPath(config.stateDir),
145
163
  useParallProvider,
164
+ capabilityBinDir: capabilityBinDir(config.stateDir),
146
165
  });
147
166
 
148
167
  // Shared by onConfigUpdate + onSessionReady. /agents/me is only consumed as
@@ -172,6 +191,23 @@ async function main() {
172
191
  model: resolveRuntimeModel(isPin, updated.model, config.model) ?? null,
173
192
  reasoningEffort: updated.thinkingEffort ?? config.reasoningEffort ?? null,
174
193
  });
194
+ // Capability heat-update: re-materialize shims + rewrite the prompt
195
+ // surfaces. The write is refresh-tolerant (warn + retry next refresh);
196
+ // only a SUCCESSFUL write with a changed fragment set schedules the
197
+ // lazy app-server restart (developer_instructions loads at spawn).
198
+ const fragments = applyChannelCapabilities();
199
+ const joinedFragments = fragments.join('\n\n');
200
+ let promptWritten = true;
201
+ try {
202
+ writeCodexSystemPrompt(config.workspaceDir, agentIdentity, fragments);
203
+ } catch (err) {
204
+ promptWritten = false;
205
+ agentLog.warn(`system prompt refresh write failed (retrying next refresh): ${String(err)}`);
206
+ }
207
+ if (promptWritten && joinedFragments !== lastCapabilityFragments) {
208
+ lastCapabilityFragments = joinedFragments;
209
+ adapter.requestProcessRestart();
210
+ }
175
211
  };
176
212
 
177
213
  const gateway = new ParallAgentGateway({
@@ -196,6 +232,9 @@ async function main() {
196
232
  },
197
233
  dispatchAdapter: adapter,
198
234
  log: agentLog,
235
+ // Live capability view for hint routing: the channel reply hint points
236
+ // at the vendor CLI only while the grant is active.
237
+ getCapabilityKeys: () => configMgr.capabilities().map((c) => c.key),
199
238
  shutdownDeadlineMs: parseShutdownDeadlineMs(process.env.PRLL_SHUTDOWN_DEADLINE_MS),
200
239
  forkDeadlineMs: parseForkDeadlineMs(process.env.PRLL_FORK_DEADLINE_MS),
201
240
  dispatchDeadlineMs: parseDispatchDeadlineMs(process.env.PRLL_DISPATCH_DEADLINE_MS),
package/src/workspace.ts CHANGED
@@ -11,6 +11,399 @@ import {
11
11
  } from '@parall/agent-core';
12
12
  import type { AgentIdentity } from '@parall/agent-core';
13
13
 
14
+ /**
15
+ * Config-lock timings, exported for tests.
16
+ *
17
+ * `staleMs` — a queue ticket untouched this long is treated as a crashed
18
+ * owner (live contenders refresh their ticket's mtime every `retryMs`, so
19
+ * the margin is ~400×). `waitMs` deliberately exceeds `staleMs`: a waiter
20
+ * facing a crashed head-of-queue outlasts the staleness threshold and skips
21
+ * it instead of falling through to a lockless write — the lockless fallback
22
+ * is a last resort, not a normal path.
23
+ */
24
+ export const CONFIG_LOCK_TIMINGS = {
25
+ staleMs: 10_000,
26
+ waitMs: 15_000,
27
+ retryMs: 25,
28
+ };
29
+
30
+ // Reused wait signal: sleepSync fires every retryMs under contention, and a
31
+ // fresh SharedArrayBuffer per call is avoidable allocation churn.
32
+ const SLEEP_SIGNAL = new Int32Array(new SharedArrayBuffer(4));
33
+
34
+ function sleepSync(ms: number): void {
35
+ Atomics.wait(SLEEP_SIGNAL, 0, 0, ms);
36
+ }
37
+
38
+ /**
39
+ * 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.
44
+ *
45
+ * Queue design (why not wx-create + steal): any scheme that renames or
46
+ * unlinks the SHARED lock path can, between its staleness check and the
47
+ * destructive op, hit a fresh lock that replaced the stale one — deleting a
48
+ * live holder's lock and overlapping critical sections (reproduced under
49
+ * 12-process contention). Here contenders queue in `config.toml.lock.d/`
50
+ * using Lamport's bakery protocol, and the smallest live ticket holds the
51
+ * lock. Two phases per enqueue: (1) create a `choosing-<token>` marker,
52
+ * (2) take seq = max(existing ticket seqs) + 1 and publish by atomically
53
+ * RENAMING the marker into `t-<seq>-<token>`. Evaluators wait while any
54
+ * live choosing marker exists, so a contender that read the queue but
55
+ * hasn't published yet can never be missed — the classic bakery guarantee.
56
+ * (A naive self-chosen timestamp order would race: a process descheduled
57
+ * between choosing its stamp and writing its ticket could insert itself
58
+ * before an already-running holder.) The rename-publish closes the
59
+ * stale-mid-choosing hole: once a frozen chooser's marker has been GC'd,
60
+ * its publish fails and it must re-enqueue fresh — it can never surface an
61
+ * old low seq under a holder elected in its absence. ENTRY is the same
62
+ * pattern: the elected head atomically renames its ticket into a
63
+ * `held-<entry-ts>-…` entry, racing any staleness eviction of that ticket
64
+ * on the same path — a contender revived at the staleness boundary loses
65
+ * the rename and re-enqueues rather than entering behind an eviction. The
66
+ * holder's freshness is embedded in the held name (atomic with entry), so
67
+ * evicting a holder needs no stat. Evaluators mirror the atomicity: after
68
+ * any GC attempt, or on a marker that vanished mid-scan, they take a fresh
69
+ * snapshot instead of electing from the old one. Ticket order is
70
+ * (seq, token), identical for every observer.
71
+ *
72
+ * No process ever mutates a shared path: release unlinks only the caller's
73
+ * own ticket, and the only cross-process destructive op is GC of entries
74
+ * untouched for `staleMs` — safe because live contenders refresh their
75
+ * ticket's mtime every `retryMs`, and names embed a per-acquisition random
76
+ * token so they are never reused (no identity switch on unlink).
77
+ *
78
+ * Residual (documented, not fixable without OS-level flock, which Node core
79
+ * does not expose): a HOLDER frozen inside `fn` for longer than `staleMs`
80
+ * looks crashed, gets evicted, and the next head may overlap it — `fn` is a
81
+ * millisecond-scale sync config write, 400× within margin. Freezes anywhere
82
+ * else (mid-choosing, at entry) are safe: the atomic renames fail after an
83
+ * eviction and the process re-enqueues. On `waitMs` timeout
84
+ * the mutation proceeds without the lock (warn) — blocking would wedge
85
+ * 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.
91
+ *
92
+ * Exported for tests.
93
+ */
94
+ export function withConfigLock(
95
+ codexHome: string,
96
+ log: { warn: (msg: string) => void } | undefined,
97
+ fn: () => void,
98
+ ): void {
99
+ const queueDir = path.join(codexHome, 'config.toml.lock.d');
100
+
101
+ let ticketName = bakeryEnqueue(queueDir);
102
+ let ticketPath = ticketName ? path.join(queueDir, ticketName) : '';
103
+
104
+ let acquired = false;
105
+ let heldPath = '';
106
+ const deadline = Date.now() + CONFIG_LOCK_TIMINGS.waitMs;
107
+ while (ticketName && Date.now() < deadline) {
108
+ let names: string[];
109
+ try {
110
+ names = fs.readdirSync(queueDir).sort();
111
+ } catch {
112
+ break; // dir vanished under us — lockless fallback
113
+ }
114
+ if (!names.includes(ticketName)) {
115
+ // Our ticket was GC'd (we looked frozen) or wiped — re-run the full
116
+ // bakery enqueue rather than silently proceeding without a position.
117
+ ticketName = bakeryEnqueue(queueDir);
118
+ if (!ticketName) break;
119
+ ticketPath = path.join(queueDir, ticketName);
120
+ continue;
121
+ }
122
+ const now = Date.now();
123
+ let rescan = false; // snapshot invalidated — re-readdir before electing
124
+ let blocked = false; // a live chooser/holder is ahead of us
125
+ let head: string | undefined;
126
+ for (const name of names) {
127
+ const entryPath = path.join(queueDir, name);
128
+ if (name.startsWith('held-')) {
129
+ // The holder's freshness is embedded in the name at entry time
130
+ // (atomic with the entry rename), so there is no stat window here.
131
+ const enteredAt = Number.parseInt(name.slice(5, 20), 10);
132
+ if (Number.isFinite(enteredAt) && now - enteredAt > CONFIG_LOCK_TIMINGS.staleMs) {
133
+ // Holder frozen inside fn beyond staleMs — the documented
134
+ // residual. Evict and rescan.
135
+ try {
136
+ fs.unlinkSync(entryPath);
137
+ } catch {
138
+ // released or GC'd concurrently
139
+ }
140
+ rescan = true;
141
+ break;
142
+ }
143
+ blocked = true; // live holder — the lock is taken
144
+ break;
145
+ }
146
+ let mtimeMs: number;
147
+ try {
148
+ mtimeMs = fs.statSync(entryPath).mtimeMs;
149
+ } catch {
150
+ // The entry vanished between readdir and stat — it did not merely
151
+ // leave, it may have TRANSITIONED via an atomic rename this snapshot
152
+ // cannot see: a choosing marker into a published ticket, or a head
153
+ // ticket into a live held entry (caught overlapping under the
154
+ // 12-process barrier test when this path skipped tickets). Never
155
+ // elect from a snapshot that missed a transition — rescan.
156
+ rescan = true;
157
+ break;
158
+ }
159
+ if (now - mtimeMs > CONFIG_LOCK_TIMINGS.staleMs) {
160
+ // Crashed contender (live ones refresh every retryMs; choosing is
161
+ // microsecond-scale). Unique never-reused names make this unlink
162
+ // safe — it cannot hit a different file than the one just observed.
163
+ try {
164
+ fs.unlinkSync(entryPath);
165
+ } catch {
166
+ // Lost to a concurrent GC — or to the owner's atomic rename
167
+ // (marker → ticket publish, or ticket → held entry).
168
+ }
169
+ // Whether the unlink won or lost, the snapshot no longer reflects
170
+ // the queue (a stale marker may have become a live ticket) — rescan
171
+ // instead of electing from stale names.
172
+ rescan = true;
173
+ break;
174
+ }
175
+ // Sorted names put `choosing-*` before `held-*` before `t-*`, so
176
+ // blockers are seen before any head candidate.
177
+ if (name.startsWith('choosing-')) {
178
+ blocked = true;
179
+ break;
180
+ }
181
+ if (name.startsWith('t-')) {
182
+ head = name;
183
+ break;
184
+ }
185
+ // Foreign file in the queue dir — ignore it.
186
+ }
187
+ if (rescan) continue; // GC/publish made progress; take a fresh snapshot
188
+ if (!blocked && head === ticketName) {
189
+ // ENTRY is an atomic rename of our own ticket into a `held-<now>-…`
190
+ // entry. It races any GC eviction of the ticket on the same path, so
191
+ // exactly one side wins: if a GC saw us stale at the boundary and
192
+ // evicted first, our rename fails and we re-enqueue instead of
193
+ // entering — a revived contender can never slip into the critical
194
+ // section behind an eviction. The entry timestamp rides in the name,
195
+ // atomic with the transition itself.
196
+ CONFIG_LOCK_TEST_HOOKS.beforeTicketEntry?.();
197
+ const heldName = `held-${String(Date.now()).padStart(15, '0')}-${ticketName.slice(2)}`;
198
+ const candidateHeldPath = path.join(queueDir, heldName);
199
+ try {
200
+ fs.renameSync(ticketPath, candidateHeldPath);
201
+ } catch {
202
+ continue; // evicted at the boundary — the missing-ticket branch re-enqueues
203
+ }
204
+ heldPath = candidateHeldPath;
205
+ acquired = true;
206
+ break;
207
+ }
208
+ try {
209
+ const t = new Date();
210
+ fs.utimesSync(ticketPath, t, t); // keep our ticket visibly live
211
+ } catch {
212
+ // ticket missing — the next iteration re-enqueues
213
+ }
214
+ sleepSync(CONFIG_LOCK_TIMINGS.retryMs);
215
+ }
216
+
217
+ if (!acquired) {
218
+ log?.warn(`could not acquire ${queueDir}; writing config.toml without lock`);
219
+ // Leave the queue before writing lockless so our abandoned ticket does
220
+ // not block other contenders for another staleMs.
221
+ if (ticketName) {
222
+ try {
223
+ fs.unlinkSync(ticketPath);
224
+ } catch {
225
+ // already GC'd
226
+ }
227
+ }
228
+ }
229
+ try {
230
+ fn();
231
+ } finally {
232
+ if (acquired) {
233
+ try {
234
+ fs.unlinkSync(heldPath); // own unique name — no identity race
235
+ } catch {
236
+ // evicted by a contender that saw us frozen inside fn — nothing to release
237
+ }
238
+ // The queue dir is deliberately NEVER removed: a contender may have
239
+ // finished mkdir but not yet created its choosing marker, and
240
+ // deleting the dir in that gap would send it down the lockless
241
+ // fallback. An empty config.toml.lock.d on disk is expected.
242
+ }
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Test-only scheduling hooks for deterministic race tests. Production code
248
+ * never sets these; tests use them to pause a contender at the two points
249
+ * where another process can act in between (barrier files stand in for the
250
+ * OS scheduler).
251
+ */
252
+ export const CONFIG_LOCK_TEST_HOOKS: {
253
+ beforeChoosingMarker?: () => void;
254
+ beforeTicketPublish?: () => void;
255
+ beforeTicketEntry?: () => void;
256
+ } = {};
257
+
258
+ /**
259
+ * Bakery enqueue: announce with a choosing marker, take
260
+ * seq = max(visible tickets) + 1, then PUBLISH BY RENAMING THE MARKER INTO
261
+ * THE TICKET. The rename is the linchpin against the stale-mid-choosing
262
+ * race: if this process froze after creating the marker and a contender
263
+ * GC'd it as stale (allowing others to elect and enter), the rename fails
264
+ * with ENOENT — the stale seq is discarded and we re-enqueue under a fresh
265
+ * token. Publishing and retiring the marker are one atomic step, so at no
266
+ * instant can an evaluator observe "no marker, and no ticket either" for an
267
+ * in-flight enqueue.
268
+ *
269
+ * Returns the ticket name, or null when the queue dir is unusable (caller
270
+ * falls back to a lockless warn-logged write).
271
+ */
272
+ function bakeryEnqueue(queueDir: string): string | null {
273
+ // A GC'd marker aborts the attempt; retry under a FRESH token so names
274
+ // are never reused. Being GC'd requires a >staleMs freeze mid-choosing,
275
+ // so two spare attempts are already generous.
276
+ for (let attempt = 0; attempt < 3; attempt++) {
277
+ const token = `${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
278
+ const markerPath = path.join(queueDir, `choosing-${token}`);
279
+ try {
280
+ fs.mkdirSync(queueDir, { recursive: true });
281
+ CONFIG_LOCK_TEST_HOOKS.beforeChoosingMarker?.();
282
+ fs.writeFileSync(markerPath, '', { flag: 'wx' });
283
+ } catch {
284
+ return null;
285
+ }
286
+ try {
287
+ let maxSeq = 0;
288
+ for (const name of fs.readdirSync(queueDir)) {
289
+ if (!name.startsWith('t-')) continue;
290
+ const seq = Number.parseInt(name.slice(2, 12), 10);
291
+ if (Number.isFinite(seq) && seq > maxSeq) maxSeq = seq;
292
+ }
293
+ const ticketName = `t-${String(maxSeq + 1).padStart(10, '0')}-${token}`;
294
+ const ticketPath = path.join(queueDir, ticketName);
295
+ CONFIG_LOCK_TEST_HOOKS.beforeTicketPublish?.();
296
+ fs.renameSync(markerPath, ticketPath);
297
+ try {
298
+ // The ticket inherits the marker's mtime; refresh it so a slow
299
+ // choose does not hand evaluators a stale-at-birth ticket. Runs
300
+ // before our first head evaluation, so if a GC wins the race on the
301
+ // stale-born ticket we simply re-enqueue — never elect on it.
302
+ const t = new Date();
303
+ fs.utimesSync(ticketPath, t, t);
304
+ } catch {
305
+ // GC'd already — the eval loop re-enqueues on the missing ticket.
306
+ }
307
+ return ticketName;
308
+ } catch (err) {
309
+ // Retire our marker if it still exists (e.g. readdir failed).
310
+ try {
311
+ fs.unlinkSync(markerPath);
312
+ } catch {
313
+ // already renamed or GC'd
314
+ }
315
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') continue; // marker GC'd — fresh token
316
+ return null;
317
+ }
318
+ }
319
+ return null;
320
+ }
321
+
322
+ /**
323
+ * Whole-file config writes go through temp + rename so a concurrent reader
324
+ * (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.
328
+ *
329
+ * The rename targets the file's REAL path: `config.toml` managed by a
330
+ * dotfiles setup is often a symlink, and renaming onto the link path would
331
+ * replace the link with a regular file while the real target keeps the old
332
+ * content. The temp file is born 0600 (never a world-readable window, even
333
+ * pre-chmod) and created with `wx` so a colliding path is never truncated;
334
+ * an existing file's mode is then restored explicitly (chmod, not
335
+ * open-mode, so umask cannot mask bits off), while a brand-new config stays
336
+ * at the restrictive 0600.
337
+ */
338
+ function writeConfigAtomic(filePath: string, content: string): void {
339
+ const realPath = resolveWriteTarget(filePath);
340
+ let mode: number | undefined;
341
+ try {
342
+ mode = fs.statSync(realPath).mode & 0o777;
343
+ } catch {
344
+ // New file — keep the restrictive 0600 creation mode.
345
+ }
346
+ const tmpPath = `${realPath}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
347
+ let tmpCreated = false;
348
+ try {
349
+ fs.writeFileSync(tmpPath, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
350
+ tmpCreated = true;
351
+ if (mode !== undefined) fs.chmodSync(tmpPath, mode);
352
+ fs.renameSync(tmpPath, realPath);
353
+ } catch (err) {
354
+ // Clean up only what THIS call put on disk: on a wx EEXIST the path
355
+ // belongs to someone else and must not be unlinked. (A create that
356
+ // failed mid-write still left our file — code ≠ EEXIST — so it is
357
+ // removed.)
358
+ if (tmpCreated || (err as NodeJS.ErrnoException).code !== 'EEXIST') {
359
+ try {
360
+ fs.unlinkSync(tmpPath);
361
+ } catch {
362
+ // tmp never made it to disk
363
+ }
364
+ }
365
+ throw err;
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Follow symlinks (including dangling ones) to the path a write should land
371
+ * on. Never returns an unresolved symlink: a cycle or an over-deep chain
372
+ * throws instead, so the atomic rename can never silently replace a link.
373
+ */
374
+ function resolveWriteTarget(filePath: string): string {
375
+ try {
376
+ return fs.realpathSync(filePath);
377
+ } catch (err) {
378
+ // ELOOP (cycle among existing links) and every other real error must
379
+ // surface; only a missing target falls through to the manual walk.
380
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
381
+ }
382
+ // Some component is missing — the path may still be a dangling symlink
383
+ // chain; walk it manually so the file is created where the links point.
384
+ const seen = new Set<string>();
385
+ let p = path.resolve(filePath);
386
+ for (let depth = 0; depth < 40; depth++) {
387
+ if (seen.has(p)) {
388
+ throw new Error(`symlink cycle at ${p} while resolving ${filePath}`);
389
+ }
390
+ seen.add(p);
391
+ let link: string;
392
+ try {
393
+ link = fs.readlinkSync(p);
394
+ } catch (err) {
395
+ const code = (err as NodeJS.ErrnoException).code;
396
+ // ENOENT (nothing here yet) and EINVAL (a real non-link file) are the
397
+ // two legitimate ends of a chain — create/replace at this path. Any
398
+ // other error is a real failure and must surface.
399
+ if (code === 'ENOENT' || code === 'EINVAL') return p;
400
+ throw err;
401
+ }
402
+ p = path.resolve(path.dirname(p), link);
403
+ }
404
+ throw new Error(`symlink chain deeper than 40 while resolving ${filePath}`);
405
+ }
406
+
14
407
  /**
15
408
  * Ensure the workspace directory is marked as trusted in the global Codex
16
409
  * config so that project-level `developer_instructions` are loaded at
@@ -25,6 +418,14 @@ export function ensureWorkspaceTrusted(
25
418
  codexHome: string,
26
419
  workspaceDir: string,
27
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 },
28
429
  ): void {
29
430
  const configPath = path.join(codexHome, 'config.toml');
30
431
  const normalizedPath = path.resolve(workspaceDir);
@@ -52,7 +453,20 @@ export function ensureWorkspaceTrusted(
52
453
  }
53
454
 
54
455
  const projects = parsed?.projects as Record<string, Record<string, unknown>> | undefined;
55
- if (projects?.[normalizedPath]?.trust_level === 'trusted') return;
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
+ }
56
470
 
57
471
  // TOML basic-string keys require backslash and double-quote escaping.
58
472
  const escapedPath = normalizedPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
@@ -82,7 +496,7 @@ export function ensureWorkspaceTrusted(
82
496
  content.substring(0, afterHeader) + trustLine + '\n' + content.substring(afterHeader);
83
497
  }
84
498
  }
85
- fs.writeFileSync(configPath, content, 'utf8');
499
+ writeConfigAtomic(configPath, content);
86
500
  } else if (projects?.[normalizedPath] !== undefined) {
87
501
  // smol-toml found the section but indexOf missed it — the header
88
502
  // uses non-canonical TOML formatting. Appending would create a
@@ -138,6 +552,10 @@ export function ensureParallProvider(
138
552
  apiUrl: string,
139
553
  log?: { warn: (msg: string) => void },
140
554
  ): void {
555
+ withConfigLock(codexHome, log, () => ensureParallProviderLocked(codexHome, apiUrl));
556
+ }
557
+
558
+ function ensureParallProviderLocked(codexHome: string, apiUrl: string): void {
141
559
  const configPath = path.join(codexHome, 'config.toml');
142
560
  const baseUrl = apiUrl.replace(/\/$/, '') + '/api/llm/v1';
143
561
 
@@ -178,7 +596,7 @@ export function ensureParallProvider(
178
596
  }
179
597
 
180
598
  fs.mkdirSync(codexHome, { recursive: true });
181
- fs.writeFileSync(configPath, content, 'utf8');
599
+ writeConfigAtomic(configPath, content);
182
600
  } catch (err) {
183
601
  throw new Error(`failed to write Parall provider config: ${String(err)}`);
184
602
  }
@@ -191,29 +609,56 @@ function findSectionEnd(content: string, fromIndex: number): number {
191
609
  return nextHeader === -1 ? content.length : nextHeader;
192
610
  }
193
611
 
194
- export function ensureCodexWorkspace(
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).
615
+ // capabilityFragments are platform-derived capability declarations
616
+ // (agents.capabilities[].fragment) — placed after the platform reference
617
+ // 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.
624
+ export function writeCodexSystemPrompt(
195
625
  workspaceDir: string,
196
- log?: { warn: (msg: string) => void },
197
626
  agentIdentity?: AgentIdentity,
627
+ capabilityFragments?: string[],
198
628
  ): void {
199
- const systemPrompt = [
629
+ const parts = [
200
630
  buildIdentity(agentIdentity),
201
631
  BRIDGE_WORKSPACE_INSTRUCTIONS,
202
632
  PRLL_BEHAVIOR,
203
633
  PRLL_REFERENCE_GUIDE,
204
- buildSkillReferences(workspaceDir),
205
- ].join('\n\n');
206
-
207
- fs.mkdirSync(workspaceDir, { recursive: true });
634
+ ];
635
+ if (capabilityFragments && capabilityFragments.length > 0) {
636
+ parts.push(capabilityFragments.join('\n\n'));
637
+ }
638
+ parts.push(buildSkillReferences(workspaceDir));
639
+ const systemPrompt = parts.join('\n\n');
208
640
 
209
641
  const parallDir = path.join(workspaceDir, '.parall');
210
642
  fs.mkdirSync(parallDir, { recursive: true });
211
643
  fs.writeFileSync(path.join(parallDir, 'system-prompt.md'), systemPrompt, 'utf8');
212
644
 
213
- writeSkillFiles(path.join(parallDir, 'skills'));
214
-
215
645
  const codexConfigDir = path.join(workspaceDir, '.codex');
216
646
  fs.mkdirSync(codexConfigDir, { recursive: true });
217
647
  const toml = `developer_instructions = """\n${systemPrompt.replace(/\\/g, '\\\\').replace(/"""/g, '\\"""')}\n"""\n`;
218
648
  fs.writeFileSync(path.join(codexConfigDir, 'config.toml'), toml, 'utf8');
219
649
  }
650
+
651
+ export function ensureCodexWorkspace(
652
+ workspaceDir: string,
653
+ log?: { warn: (msg: string) => void },
654
+ agentIdentity?: AgentIdentity,
655
+ capabilityFragments?: string[],
656
+ ): void {
657
+ fs.mkdirSync(workspaceDir, { recursive: true });
658
+
659
+ // Boot: let a write failure PROPAGATE (fatal) — startup must not proceed
660
+ // without the platform prompt. The refresh path tolerates failure.
661
+ writeCodexSystemPrompt(workspaceDir, agentIdentity, capabilityFragments);
662
+
663
+ writeSkillFiles(path.join(workspaceDir, '.parall', 'skills'));
664
+ }