@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 +12 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +18 -8
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +36 -11
- package/dist/workspace.d.ts +85 -0
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +418 -3
- package/package.json +4 -4
- package/src/config.ts +18 -8
- package/src/dispatch.ts +32 -10
- package/src/workspace.ts +421 -3
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
|
|
46
|
-
* JSON-RPC
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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;
|
package/dist/config.d.ts.map
CHANGED
|
@@ -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
|
|
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
|
|
73
|
-
* JSON-RPC
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
}
|
package/dist/dispatch.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
|
559
|
-
//
|
|
560
|
-
// top-level params
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
|
|
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
|
package/dist/workspace.d.ts
CHANGED
|
@@ -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
|
package/dist/workspace.d.ts.map
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
483
|
+
writeConfigAtomic(configPath, content);
|
|
72
484
|
}
|
|
73
485
|
else if (projects?.[normalizedPath] !== undefined) {
|
|
74
486
|
// smol-toml found the section but indexOf missed it — the header
|
|
@@ -119,6 +531,9 @@ export function isParallProxyMode(env = process.env) {
|
|
|
119
531
|
* Provider-managed: overwritten on every boot (env vars are the SSOT).
|
|
120
532
|
*/
|
|
121
533
|
export function ensureParallProvider(codexHome, apiUrl, log) {
|
|
534
|
+
withConfigLock(codexHome, log, () => ensureParallProviderLocked(codexHome, apiUrl));
|
|
535
|
+
}
|
|
536
|
+
function ensureParallProviderLocked(codexHome, apiUrl) {
|
|
122
537
|
const configPath = path.join(codexHome, 'config.toml');
|
|
123
538
|
const baseUrl = apiUrl.replace(/\/$/, '') + '/api/llm/v1';
|
|
124
539
|
try {
|
|
@@ -158,7 +573,7 @@ export function ensureParallProvider(codexHome, apiUrl, log) {
|
|
|
158
573
|
content = content.trimEnd() + '\n\n' + providerBlock + '\n';
|
|
159
574
|
}
|
|
160
575
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
161
|
-
|
|
576
|
+
writeConfigAtomic(configPath, content);
|
|
162
577
|
}
|
|
163
578
|
catch (err) {
|
|
164
579
|
throw new Error(`failed to write Parall provider config: ${String(err)}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parall/codex-agent",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
30
|
-
"@parall/
|
|
31
|
-
"@parall/
|
|
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
|
|
116
|
-
* JSON-RPC
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
|
625
|
-
//
|
|
626
|
-
// top-level params
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
599
|
+
writeConfigAtomic(configPath, content);
|
|
182
600
|
} catch (err) {
|
|
183
601
|
throw new Error(`failed to write Parall provider config: ${String(err)}`);
|
|
184
602
|
}
|