@parall/codex-agent 1.41.0 → 1.42.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.d.ts CHANGED
@@ -42,10 +42,18 @@ export declare function resolveCodexAgentConfig(env?: NodeJS.ProcessEnv): CodexA
42
42
  */
43
43
  export declare function normalizeSandbox(value: string): string;
44
44
  /**
45
- * Normalise an approval policy string to the kebab-case form the app-server
46
- * JSON-RPC enum expects (`never` / `on-request` / `on-failure` /
47
- * `unless-trusted`). Mirrors the sandbox enum serde rename_all kebab-case
48
- * applies across app-server protocol enums.
45
+ * Normalise an approval policy string to the enum form the app-server
46
+ * JSON-RPC surface accepts. Empirically verified against both pinned CLI
47
+ * generations: 0.144.x accepts `untrusted` / `on-request` / `granular` /
48
+ * `never`; 0.133.0 additionally accepted `on-failure`. Two legacy inputs are
49
+ * translated rather than passed through, because the server rejects the whole
50
+ * thread/start with "unknown variant" otherwise:
51
+ *
52
+ * - `on-failure` → `on-request` (removed from the enum in 0.144; the closest
53
+ * surviving escalation policy).
54
+ * - `unless-trusted` → `untrusted` (the wire constant was never
55
+ * `unless-trusted` on either generation — accepting-but-forwarding it made
56
+ * every thread/start fail for configs that set it).
49
57
  */
50
58
  export declare function normalizeApprovalPolicy(value: string): string;
51
59
  export declare function resolveWsUrl(apiUrl: string, explicitWsUrl?: string, swimlaneName?: string): string;
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,gBAAgB,GAAG;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;;OAOG;IACH,OAAO,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAcF,wBAAgB,uBAAuB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,gBAAgB,CAyC9F;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAUtD;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAW7D;AAED,wBAAgB,YAAY,CAC1B,MAAM,EAAE,MAAM,EACd,aAAa,CAAC,EAAE,MAAM,EACtB,YAAY,CAAC,EAAE,MAAM,GACpB,MAAM,CAMR;AAED,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAEhE;AAED,wBAAgB,8BAA8B,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAG3F;AAED,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAGtF;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAGrF"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,gBAAgB,GAAG;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;;OAOG;IACH,OAAO,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAcF,wBAAgB,uBAAuB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,gBAAgB,CAyC9F;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAUtD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAa7D;AAED,wBAAgB,YAAY,CAC1B,MAAM,EAAE,MAAM,EACd,aAAa,CAAC,EAAE,MAAM,EACtB,YAAY,CAAC,EAAE,MAAM,GACpB,MAAM,CAMR;AAED,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAEhE;AAED,wBAAgB,8BAA8B,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAG3F;AAED,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAGtF;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAGrF"}
package/dist/config.js CHANGED
@@ -69,20 +69,30 @@ export function normalizeSandbox(value) {
69
69
  return map[value.toLowerCase()] ?? value;
70
70
  }
71
71
  /**
72
- * Normalise an approval policy string to the kebab-case form the app-server
73
- * JSON-RPC enum expects (`never` / `on-request` / `on-failure` /
74
- * `unless-trusted`). Mirrors the sandbox enum serde rename_all kebab-case
75
- * applies across app-server protocol enums.
72
+ * Normalise an approval policy string to the enum form the app-server
73
+ * JSON-RPC surface accepts. Empirically verified against both pinned CLI
74
+ * generations: 0.144.x accepts `untrusted` / `on-request` / `granular` /
75
+ * `never`; 0.133.0 additionally accepted `on-failure`. Two legacy inputs are
76
+ * translated rather than passed through, because the server rejects the whole
77
+ * thread/start with "unknown variant" otherwise:
78
+ *
79
+ * - `on-failure` → `on-request` (removed from the enum in 0.144; the closest
80
+ * surviving escalation policy).
81
+ * - `unless-trusted` → `untrusted` (the wire constant was never
82
+ * `unless-trusted` on either generation — accepting-but-forwarding it made
83
+ * every thread/start fail for configs that set it).
76
84
  */
77
85
  export function normalizeApprovalPolicy(value) {
78
86
  const map = {
79
87
  never: 'never',
80
88
  'on-request': 'on-request',
81
89
  onrequest: 'on-request',
82
- 'unless-trusted': 'unless-trusted',
83
- unlesstrusted: 'unless-trusted',
84
- 'on-failure': 'on-failure',
85
- onfailure: 'on-failure',
90
+ untrusted: 'untrusted',
91
+ 'unless-trusted': 'untrusted',
92
+ unlesstrusted: 'untrusted',
93
+ granular: 'granular',
94
+ 'on-failure': 'on-request',
95
+ onfailure: 'on-request',
86
96
  };
87
97
  return map[value.toLowerCase()] ?? value;
88
98
  }
@@ -1 +1 @@
1
- {"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EACV,eAAe,EACf,eAAe,EACf,YAAY,EACZ,QAAQ,EACR,iBAAiB,EACjB,aAAa,EACb,YAAY,EACb,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAIpD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAEhE,KAAK,4BAA4B,GAAG,IAAI,CACtC,gBAAgB,EACd,gBAAgB,GAChB,UAAU,GACV,WAAW,GACX,OAAO,GACP,iBAAiB,GACjB,SAAS,GACT,cAAc,CACjB,GAAG;IACF,cAAc,EAAE,mBAAmB,CAAC;IACpC,GAAG,CAAC,EAAE,aAAa,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B,CAAC;AAqCF,qBAAa,qBAAsB,YAAW,eAAe;IA+B/C,OAAO,CAAC,QAAQ,CAAC,IAAI;IA9BjC,OAAO,CAAC,MAAM,CAAmC;IACjD,OAAO,CAAC,IAAI,CAA+C;IAC3D,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,YAAY,CAA8B;IAClD,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA+B;IAC3D,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA6B;IAC3D,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAA6B;IAC/D,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAqB;IACtD,OAAO,CAAC,QAAQ,CAAS;IAEzB;;;;;;;OAOG;IACH,OAAO,CAAC,aAAa;gBAYQ,IAAI,EAAE,4BAA4B;IAE/D,YAAY,CAAC,MAAM,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI;IAMhF,qBAAqB,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAqB/E,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAUvC,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO;IAI1C,QAAQ,CAAC,EACd,KAAK,EACL,YAAY,EACZ,UAAU,EACV,OAAO,GACR,EAAE,YAAY,GAAG,aAAa,CAAC,YAAY,CAAC;IA0O7C,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAMjD,WAAW,CAAC,EAAE,UAAU,EAAE,gBAAgB,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;IA+BhG,WAAW,CAAC,EAAE,IAAI,EAAE,EAAE,eAAe;IAIrC,OAAO,CAAC,cAAc;IAKhB,IAAI;YAiBI,aAAa;YA2Bb,OAAO;IAuFrB,OAAO,CAAC,qBAAqB;YAmCf,UAAU;IA2CxB,OAAO,CAAC,iBAAiB;CA2B1B"}
1
+ {"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EACV,eAAe,EACf,eAAe,EACf,YAAY,EACZ,QAAQ,EACR,iBAAiB,EACjB,aAAa,EACb,YAAY,EACb,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAIpD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAEhE,KAAK,4BAA4B,GAAG,IAAI,CACtC,gBAAgB,EACd,gBAAgB,GAChB,UAAU,GACV,WAAW,GACX,OAAO,GACP,iBAAiB,GACjB,SAAS,GACT,cAAc,CACjB,GAAG;IACF,cAAc,EAAE,mBAAmB,CAAC;IACpC,GAAG,CAAC,EAAE,aAAa,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B,CAAC;AAqCF,qBAAa,qBAAsB,YAAW,eAAe;IA+B/C,OAAO,CAAC,QAAQ,CAAC,IAAI;IA9BjC,OAAO,CAAC,MAAM,CAAmC;IACjD,OAAO,CAAC,IAAI,CAA+C;IAC3D,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,YAAY,CAA8B;IAClD,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA+B;IAC3D,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA6B;IAC3D,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAA6B;IAC/D,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAqB;IACtD,OAAO,CAAC,QAAQ,CAAS;IAEzB;;;;;;;OAOG;IACH,OAAO,CAAC,aAAa;gBAYQ,IAAI,EAAE,4BAA4B;IAE/D,YAAY,CAAC,MAAM,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI;IAMhF,qBAAqB,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAqB/E,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAUvC,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO;IAI1C,QAAQ,CAAC,EACd,KAAK,EACL,YAAY,EACZ,UAAU,EACV,OAAO,GACR,EAAE,YAAY,GAAG,aAAa,CAAC,YAAY,CAAC;IA6P7C,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAMjD,WAAW,CAAC,EAAE,UAAU,EAAE,gBAAgB,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;IAgChG,WAAW,CAAC,EAAE,IAAI,EAAE,EAAE,eAAe;IAIrC,OAAO,CAAC,cAAc;IAKhB,IAAI;YAiBI,aAAa;YA2Bb,OAAO;IAuFrB,OAAO,CAAC,qBAAqB;YAmCf,UAAU;IA6CxB,OAAO,CAAC,iBAAiB;CA2B1B"}
package/dist/dispatch.js CHANGED
@@ -215,10 +215,32 @@ export class CodexAppServerAdapter {
215
215
  // this contract, we want a hard error surfaced to the operator, not a
216
216
  // silent fallback that hides real protocol drift.
217
217
  const turnInput = buildTurnInput(preparedBody, preparedImages);
218
- const startTurn = (targetThreadId) => client.sendRequest('turn/start', {
219
- threadId: targetThreadId,
220
- input: turnInput,
221
- });
218
+ // Per-turn overrides, first-class on both supported CLI generations
219
+ // (0.133 / 0.144) with "for this turn and subsequent turns" semantics.
220
+ // Sending the CURRENT config on every turn is what makes
221
+ // agent_config.update (model / thinking-effort hot updates) take
222
+ // effect on the next turn: thread-level config only applies at
223
+ // thread/start-resume, which an already-open thread never re-runs,
224
+ // and a prior turn's override would otherwise stay live for the rest
225
+ // of the subprocess lifetime.
226
+ //
227
+ // The fields are only sent when a value is configured: on the wire
228
+ // they are plain serde Options, so JSON null is indistinguishable
229
+ // from omission — there is no "reset to default" encoding. CLEARING
230
+ // a previously-set pin therefore keeps the old sticky value until
231
+ // the subprocess restarts (pod restart / redeploy / idle reap);
232
+ // an explicit restart-on-clear path is a tracked follow-up.
233
+ const startTurn = (targetThreadId) => {
234
+ const turnParams = {
235
+ threadId: targetThreadId,
236
+ input: turnInput,
237
+ };
238
+ if (this.opts.model)
239
+ turnParams.model = this.opts.model;
240
+ if (this.opts.reasoningEffort)
241
+ turnParams.effort = this.opts.reasoningEffort;
242
+ return client.sendRequest('turn/start', turnParams);
243
+ };
222
244
  // Start the turn. If the stored threadId is dead, we retry once with a fresh thread.
223
245
  let turnStartResult;
224
246
  try {
@@ -360,7 +382,8 @@ export class CodexAppServerAdapter {
360
382
  if (this.opts.model)
361
383
  forkParams.model = this.opts.model;
362
384
  if (this.opts.reasoningEffort) {
363
- forkParams.config = { modelReasoningEffort: this.opts.reasoningEffort };
385
+ // Raw config.toml key see openThread for the snake_case rationale.
386
+ forkParams.config = { model_reasoning_effort: this.opts.reasoningEffort };
364
387
  }
365
388
  const result = await client.sendRequest('thread/fork', forkParams);
366
389
  const forkedThreadId = extractThreadId(result);
@@ -555,12 +578,14 @@ export class CodexAppServerAdapter {
555
578
  if (this.opts.model)
556
579
  commonParams.model = this.opts.model;
557
580
  if (this.opts.reasoningEffort) {
558
- // The app-server JSON-RPC surface uses camelCase for overrides
559
- // `modelReasoningEffort` parallels `approvalPolicy` / `sandbox` on the
560
- // top-level params. The corresponding config.toml key is
561
- // `model_reasoning_effort` (snake_case), but the nested `config` on
562
- // `thread/start` / `thread/resume` takes the camelCase form.
563
- commonParams.config = { modelReasoningEffort: this.opts.reasoningEffort };
581
+ // The nested `config` object is raw config.toml overrides and keeps the
582
+ // TOML key names `model_reasoning_effort` (snake_case) — unlike the
583
+ // typed top-level params (`approvalPolicy` / `sandbox`), which are
584
+ // camelCase. Empirically verified against both pinned CLI generations
585
+ // (0.133.0 and 0.144.1): the camelCase key is silently ignored
586
+ // (thread/start echoes reasoningEffort: null), the snake_case key is
587
+ // applied and echoed back.
588
+ commonParams.config = { model_reasoning_effort: this.opts.reasoningEffort };
564
589
  }
565
590
  const method = opts.resumeId ? 'thread/resume' : 'thread/start';
566
591
  const params = opts.resumeId
@@ -1,4 +1,89 @@
1
1
  import type { AgentIdentity } from '@parall/agent-core';
2
+ /**
3
+ * Config-lock timings, exported for tests.
4
+ *
5
+ * `staleMs` — a queue ticket untouched this long is treated as a crashed
6
+ * owner (live contenders refresh their ticket's mtime every `retryMs`, so
7
+ * the margin is ~400×). `waitMs` deliberately exceeds `staleMs`: a waiter
8
+ * facing a crashed head-of-queue outlasts the staleness threshold and skips
9
+ * it instead of falling through to a lockless write — the lockless fallback
10
+ * is a last resort, not a normal path.
11
+ */
12
+ export declare const CONFIG_LOCK_TIMINGS: {
13
+ staleMs: number;
14
+ waitMs: number;
15
+ retryMs: number;
16
+ };
17
+ /**
18
+ * Advisory cross-process lock serializing `<codexHome>/config.toml`
19
+ * read-modify-write. On a local daemon machine every runtime_auth codex
20
+ * bridge child shares the operator's CODEX_HOME, and the daemon starts them
21
+ * together — without a lock, a whole-file rewrite working from a stale read
22
+ * can erase a trust entry another agent appended in between.
23
+ *
24
+ * Queue design (why not wx-create + steal): any scheme that renames or
25
+ * unlinks the SHARED lock path can, between its staleness check and the
26
+ * destructive op, hit a fresh lock that replaced the stale one — deleting a
27
+ * live holder's lock and overlapping critical sections (reproduced under
28
+ * 12-process contention). Here contenders queue in `config.toml.lock.d/`
29
+ * using Lamport's bakery protocol, and the smallest live ticket holds the
30
+ * lock. Two phases per enqueue: (1) create a `choosing-<token>` marker,
31
+ * (2) take seq = max(existing ticket seqs) + 1 and publish by atomically
32
+ * RENAMING the marker into `t-<seq>-<token>`. Evaluators wait while any
33
+ * live choosing marker exists, so a contender that read the queue but
34
+ * hasn't published yet can never be missed — the classic bakery guarantee.
35
+ * (A naive self-chosen timestamp order would race: a process descheduled
36
+ * between choosing its stamp and writing its ticket could insert itself
37
+ * before an already-running holder.) The rename-publish closes the
38
+ * stale-mid-choosing hole: once a frozen chooser's marker has been GC'd,
39
+ * its publish fails and it must re-enqueue fresh — it can never surface an
40
+ * old low seq under a holder elected in its absence. ENTRY is the same
41
+ * pattern: the elected head atomically renames its ticket into a
42
+ * `held-<entry-ts>-…` entry, racing any staleness eviction of that ticket
43
+ * on the same path — a contender revived at the staleness boundary loses
44
+ * the rename and re-enqueues rather than entering behind an eviction. The
45
+ * holder's freshness is embedded in the held name (atomic with entry), so
46
+ * evicting a holder needs no stat. Evaluators mirror the atomicity: after
47
+ * any GC attempt, or on a marker that vanished mid-scan, they take a fresh
48
+ * snapshot instead of electing from the old one. Ticket order is
49
+ * (seq, token), identical for every observer.
50
+ *
51
+ * No process ever mutates a shared path: release unlinks only the caller's
52
+ * own ticket, and the only cross-process destructive op is GC of entries
53
+ * untouched for `staleMs` — safe because live contenders refresh their
54
+ * ticket's mtime every `retryMs`, and names embed a per-acquisition random
55
+ * token so they are never reused (no identity switch on unlink).
56
+ *
57
+ * Residual (documented, not fixable without OS-level flock, which Node core
58
+ * does not expose): a HOLDER frozen inside `fn` for longer than `staleMs`
59
+ * looks crashed, gets evicted, and the next head may overlap it — `fn` is a
60
+ * millisecond-scale sync config write, 400× within margin. Freezes anywhere
61
+ * else (mid-choosing, at entry) are safe: the atomic renames fail after an
62
+ * eviction and the process re-enqueues. On `waitMs` timeout
63
+ * the mutation proceeds without the lock (warn) — blocking would wedge
64
+ * bridge startup. The lock coordinates bridge processes only; codex itself
65
+ * does not observe it, which is why bridge writes to a shared config are
66
+ * additionally kept rare (the trust write is a no-op after the first boot
67
+ * per workspace, and runtime_auth agents never write the provider block)
68
+ * and whole-file writes are atomic (temp + rename) so codex never reads a
69
+ * truncated file.
70
+ *
71
+ * Exported for tests.
72
+ */
73
+ export declare function withConfigLock(codexHome: string, log: {
74
+ warn: (msg: string) => void;
75
+ } | undefined, fn: () => void): void;
76
+ /**
77
+ * Test-only scheduling hooks for deterministic race tests. Production code
78
+ * never sets these; tests use them to pause a contender at the two points
79
+ * where another process can act in between (barrier files stand in for the
80
+ * OS scheduler).
81
+ */
82
+ export declare const CONFIG_LOCK_TEST_HOOKS: {
83
+ beforeChoosingMarker?: () => void;
84
+ beforeTicketPublish?: () => void;
85
+ beforeTicketEntry?: () => void;
86
+ };
2
87
  /**
3
88
  * Ensure the workspace directory is marked as trusted in the global Codex
4
89
  * config so that project-level `developer_instructions` are loaded at
@@ -1 +1 @@
1
- {"version":3,"file":"workspace.d.ts","sourceRoot":"","sources":["../src/workspace.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAExD;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,MAAM,EACpB,GAAG,CAAC,EAAE;IAAE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,GACpC,IAAI,CAgFN;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,OAAO,CAK/E;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,EACd,GAAG,CAAC,EAAE;IAAE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,GACpC,IAAI,CA6CN;AASD,wBAAgB,oBAAoB,CAClC,YAAY,EAAE,MAAM,EACpB,GAAG,CAAC,EAAE;IAAE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,EACrC,aAAa,CAAC,EAAE,aAAa,GAC5B,IAAI,CAqBN"}
1
+ {"version":3,"file":"workspace.d.ts","sourceRoot":"","sources":["../src/workspace.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAExD;;;;;;;;;GASG;AACH,eAAO,MAAM,mBAAmB;;;;CAI/B,CAAC;AAUF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AACH,wBAAgB,cAAc,CAC5B,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE;IAAE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,GAAG,SAAS,EAChD,EAAE,EAAE,MAAM,IAAI,GACb,IAAI,CAkJN;AAED;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,EAAE;IACnC,oBAAoB,CAAC,EAAE,MAAM,IAAI,CAAC;IAClC,mBAAmB,CAAC,EAAE,MAAM,IAAI,CAAC;IACjC,iBAAiB,CAAC,EAAE,MAAM,IAAI,CAAC;CAC3B,CAAC;AAuJP;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,MAAM,EACpB,GAAG,CAAC,EAAE;IAAE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,GACpC,IAAI,CAEN;AAqGD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,OAAO,CAK/E;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,EACd,GAAG,CAAC,EAAE;IAAE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,GACpC,IAAI,CAEN;AAwDD,wBAAgB,oBAAoB,CAClC,YAAY,EAAE,MAAM,EACpB,GAAG,CAAC,EAAE;IAAE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,EACrC,aAAa,CAAC,EAAE,aAAa,GAC5B,IAAI,CAqBN"}
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
- if (projects?.[normalizedPath]?.trust_level === 'trusted')
443
+ const existingTrust = projects?.[normalizedPath]?.trust_level;
444
+ if (existingTrust === 'trusted')
41
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`);
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
- fs.writeFileSync(configPath, content, 'utf8');
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
- fs.writeFileSync(configPath, content, 'utf8');
576
+ writeConfigAtomic(configPath, content);
162
577
  }
163
578
  catch (err) {
164
579
  throw new Error(`failed to write Parall provider config: ${String(err)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/codex-agent",
3
- "version": "1.41.0",
3
+ "version": "1.42.1",
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.41.0",
30
- "@parall/sdk": "1.41.0",
31
- "@parall/cli": "1.41.0"
29
+ "@parall/agent-core": "1.42.1",
30
+ "@parall/cli": "1.42.1",
31
+ "@parall/sdk": "1.42.1"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/node": "^22.0.0",
package/src/config.ts CHANGED
@@ -112,20 +112,30 @@ export function normalizeSandbox(value: string): string {
112
112
  }
113
113
 
114
114
  /**
115
- * Normalise an approval policy string to the kebab-case form the app-server
116
- * JSON-RPC enum expects (`never` / `on-request` / `on-failure` /
117
- * `unless-trusted`). Mirrors the sandbox enum serde rename_all kebab-case
118
- * applies across app-server protocol enums.
115
+ * Normalise an approval policy string to the enum form the app-server
116
+ * JSON-RPC surface accepts. Empirically verified against both pinned CLI
117
+ * generations: 0.144.x accepts `untrusted` / `on-request` / `granular` /
118
+ * `never`; 0.133.0 additionally accepted `on-failure`. Two legacy inputs are
119
+ * translated rather than passed through, because the server rejects the whole
120
+ * thread/start with "unknown variant" otherwise:
121
+ *
122
+ * - `on-failure` → `on-request` (removed from the enum in 0.144; the closest
123
+ * surviving escalation policy).
124
+ * - `unless-trusted` → `untrusted` (the wire constant was never
125
+ * `unless-trusted` on either generation — accepting-but-forwarding it made
126
+ * every thread/start fail for configs that set it).
119
127
  */
120
128
  export function normalizeApprovalPolicy(value: string): string {
121
129
  const map: Record<string, string> = {
122
130
  never: 'never',
123
131
  'on-request': 'on-request',
124
132
  onrequest: 'on-request',
125
- 'unless-trusted': 'unless-trusted',
126
- unlesstrusted: 'unless-trusted',
127
- 'on-failure': 'on-failure',
128
- onfailure: 'on-failure',
133
+ untrusted: 'untrusted',
134
+ 'unless-trusted': 'untrusted',
135
+ unlesstrusted: 'untrusted',
136
+ granular: 'granular',
137
+ 'on-failure': 'on-request',
138
+ onfailure: 'on-request',
129
139
  };
130
140
  return map[value.toLowerCase()] ?? value;
131
141
  }
package/src/dispatch.ts CHANGED
@@ -264,11 +264,30 @@ export class CodexAppServerAdapter implements DispatchAdapter {
264
264
  // this contract, we want a hard error surfaced to the operator, not a
265
265
  // silent fallback that hides real protocol drift.
266
266
  const turnInput = buildTurnInput(preparedBody, preparedImages);
267
- const startTurn = (targetThreadId: string): Promise<unknown> =>
268
- client.sendRequest('turn/start', {
267
+ // Per-turn overrides, first-class on both supported CLI generations
268
+ // (0.133 / 0.144) with "for this turn and subsequent turns" semantics.
269
+ // Sending the CURRENT config on every turn is what makes
270
+ // agent_config.update (model / thinking-effort hot updates) take
271
+ // effect on the next turn: thread-level config only applies at
272
+ // thread/start-resume, which an already-open thread never re-runs,
273
+ // and a prior turn's override would otherwise stay live for the rest
274
+ // of the subprocess lifetime.
275
+ //
276
+ // The fields are only sent when a value is configured: on the wire
277
+ // they are plain serde Options, so JSON null is indistinguishable
278
+ // from omission — there is no "reset to default" encoding. CLEARING
279
+ // a previously-set pin therefore keeps the old sticky value until
280
+ // the subprocess restarts (pod restart / redeploy / idle reap);
281
+ // an explicit restart-on-clear path is a tracked follow-up.
282
+ const startTurn = (targetThreadId: string): Promise<unknown> => {
283
+ const turnParams: Record<string, unknown> = {
269
284
  threadId: targetThreadId,
270
285
  input: turnInput,
271
- });
286
+ };
287
+ if (this.opts.model) turnParams.model = this.opts.model;
288
+ if (this.opts.reasoningEffort) turnParams.effort = this.opts.reasoningEffort;
289
+ return client.sendRequest('turn/start', turnParams);
290
+ };
272
291
 
273
292
  // Start the turn. If the stored threadId is dead, we retry once with a fresh thread.
274
293
  let turnStartResult: unknown;
@@ -409,7 +428,8 @@ export class CodexAppServerAdapter implements DispatchAdapter {
409
428
  }
410
429
  if (this.opts.model) forkParams.model = this.opts.model;
411
430
  if (this.opts.reasoningEffort) {
412
- forkParams.config = { modelReasoningEffort: this.opts.reasoningEffort };
431
+ // Raw config.toml key see openThread for the snake_case rationale.
432
+ forkParams.config = { model_reasoning_effort: this.opts.reasoningEffort };
413
433
  }
414
434
  const result = await client.sendRequest('thread/fork', forkParams);
415
435
  const forkedThreadId = extractThreadId(result);
@@ -621,12 +641,14 @@ export class CodexAppServerAdapter implements DispatchAdapter {
621
641
  commonParams.sandbox = normalizeSandbox(this.opts.sandbox);
622
642
  if (this.opts.model) commonParams.model = this.opts.model;
623
643
  if (this.opts.reasoningEffort) {
624
- // The app-server JSON-RPC surface uses camelCase for overrides
625
- // `modelReasoningEffort` parallels `approvalPolicy` / `sandbox` on the
626
- // top-level params. The corresponding config.toml key is
627
- // `model_reasoning_effort` (snake_case), but the nested `config` on
628
- // `thread/start` / `thread/resume` takes the camelCase form.
629
- commonParams.config = { modelReasoningEffort: this.opts.reasoningEffort };
644
+ // The nested `config` object is raw config.toml overrides and keeps the
645
+ // TOML key names `model_reasoning_effort` (snake_case) — unlike the
646
+ // typed top-level params (`approvalPolicy` / `sandbox`), which are
647
+ // camelCase. Empirically verified against both pinned CLI generations
648
+ // (0.133.0 and 0.144.1): the camelCase key is silently ignored
649
+ // (thread/start echoes reasoningEffort: null), the snake_case key is
650
+ // applied and echoed back.
651
+ commonParams.config = { model_reasoning_effort: this.opts.reasoningEffort };
630
652
  }
631
653
 
632
654
  const method = opts.resumeId ? 'thread/resume' : 'thread/start';
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
  }