@parall/codex-agent 1.42.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/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/workspace.ts +421 -3
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.42.
|
|
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.42.
|
|
30
|
-
"@parall/cli": "1.42.
|
|
31
|
-
"@parall/sdk": "1.42.
|
|
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/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
|
}
|