@lannguyensi/harness 0.58.0 → 0.58.2

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.
@@ -1,14 +1,58 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import { parse as parseTomlDocument, TomlError } from "smol-toml";
4
5
  import { atomicWriteFile } from "../../io/atomic-write.js";
6
+ import { EX_FAIL, HarnessExitError } from "../exit-codes.js";
5
7
  export const CODEX_MANAGED_SOURCE_PREFIX = "# Harness Codex hook wiring.";
6
8
  export const CODEX_MANAGED_BEGIN = "# BEGIN harness-managed codex hooks";
7
9
  export const CODEX_MANAGED_END = "# END harness-managed codex hooks";
10
+ /**
11
+ * Thrown instead of a plain `Error` when the install refuses rather than
12
+ * guesses (a duplicate/moved BEGIN marker, or a foreign table wedged
13
+ * inside the managed block). Extends `HarnessExitError` so it carries the
14
+ * repo's own exit-code convention (`EX_FAIL`) through `run()`'s top-level
15
+ * catch without the generic "return 70" branch, and the CLI action can
16
+ * recognize it by type to emit a structured `--json` error the same way
17
+ * the other apply refusals (`target-exists-refuse`, `drift-refuse`) do.
18
+ */
19
+ export class CodexInstallRefusalError extends HarnessExitError {
20
+ configPath;
21
+ constructor(message, configPath) {
22
+ super(message, EX_FAIL);
23
+ this.configPath = configPath;
24
+ this.name = "CodexInstallRefusalError";
25
+ }
26
+ }
8
27
  const GENERATED_HEADER = "# Generated by harness apply --runtime codex.";
9
28
  const LEGACY_HOOK_TABLE_RE = /^\[\[hooks\.(pre_tool_use|post_tool_use|user_prompt_submit|session_start|stop)\]\]$/;
10
29
  const CURRENT_HOOK_TABLE_RE = /^\[\[hooks\.[A-Za-z][A-Za-z0-9_]*\]\]$/;
11
- const NON_HOOK_TABLE_RE = /^\[(?!\[hooks\.)/;
30
+ // A single, unquoted-or-quoted TOML bare/quoted key: `foo-bar_1`, a basic
31
+ // `"..."` string (escapes allowed), or a literal `'...'` string (no
32
+ // escapes, so a literal key may itself contain `[`, `]`, `"` or `#`
33
+ // verbatim, e.g. `'a[b'`).
34
+ const TOML_KEY = String.raw `(?:[A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')`;
35
+ // A single-line TOML table header only: `[key]`, `[[key]]`, or a dotted
36
+ // path of keys (`[a.b."c d".'e[f']`), optionally surrounded by whitespace
37
+ // around the brackets and each dot, and optionally followed by whitespace
38
+ // and a trailing comment. Quote-aware so a key that itself contains `[`,
39
+ // `]`, `.` or `#` (a real path or filename used as a TOML key, e.g.
40
+ // `[projects."/Users/x/Photos [2024]"]`, `[plugins.'a[b']`) is still
41
+ // recognized as one header, instead of the bracket/hash inside the quotes
42
+ // being mistaken for the header's own delimiters. Deliberately excludes an
43
+ // array-continuation line such as ` [1, 2],` (no trailing comma allowed,
44
+ // and a bare numeric-looking segment such as `1` only ever matches as a
45
+ // single key with nothing after it, never as an array element) so a
46
+ // multi-line array/value sitting inside a still-owned harness hook table
47
+ // is never misread as a foreign table header ending the owned region
48
+ // early (task 6a037359).
49
+ const NON_HOOK_TABLE_RE = new RegExp(String.raw `^\[(?!\[hooks\.)\[?\s*${TOML_KEY}(?:\s*\.\s*${TOML_KEY})*\s*\]\]?\s*(?:#.*)?$`);
50
+ // Every emitted hook table is preceded by exactly this comment shape
51
+ // (generate-codex-config.ts's `emitHook`); used both to name removed hook
52
+ // blocks in the install summary and to detect a foreign table wedged
53
+ // between two harness hook tables (see `assertNoSplitBlock`).
54
+ const HARNESS_HOOK_COMMENT_PREFIX = "# harness hook: ";
55
+ const HOOK_ID_LINE_RE = /^# harness hook: (.+) \(budget_ms=\d+\)$/;
12
56
  export function defaultCodexConfigPath(homeDir) {
13
57
  return path.join(homeDir ?? os.homedir(), ".codex", "config.toml");
14
58
  }
@@ -27,44 +71,461 @@ function previousLine(text, lineStart) {
27
71
  const start = lineStartAt(text, end);
28
72
  return { start, text: text.slice(start, end) };
29
73
  }
30
- function findLegacyRangeEnd(text, start) {
31
- let pos = start;
74
+ /** 1-based line number of `index` within `text`, for operator-facing error text. */
75
+ function lineNumberAt(text, index) {
76
+ let n = 1;
77
+ for (let i = 0; i < index && i < text.length; i += 1) {
78
+ if (text[i] === "\n")
79
+ n += 1;
80
+ }
81
+ return n;
82
+ }
83
+ /** True when the character at `index` is escaped by an odd number of
84
+ * immediately preceding backslashes (`\"` is escaped, `\\"` is not: the
85
+ * first backslash escapes the second, leaving the quote bare). Only basic
86
+ * (double-quoted) strings support this escape; literal (single-quoted)
87
+ * strings never do. */
88
+ function isEscapedAt(line, index) {
89
+ let backslashes = 0;
90
+ let j = index - 1;
91
+ while (j >= 0 && line[j] === "\\") {
92
+ backslashes += 1;
93
+ j -= 1;
94
+ }
95
+ return backslashes % 2 === 1;
96
+ }
97
+ /**
98
+ * Walks one physical line of TOML, tracking only what a caller needs to
99
+ * decide whether the NEXT line is opaque multi-line-string content: which
100
+ * triple-quote delimiter (if any) is open once this line ends. `delimIn`
101
+ * is the delimiter already open when this line STARTS (from a preceding
102
+ * multi-line string that has not yet closed); when non-null, this line's
103
+ * own content is scanned only for that SAME delimiter closing it (a basic
104
+ * `"""` string is closed only by another `"""`, never by `'''`, and vice
105
+ * versa -- naively counting either delimiter's raw
106
+ * occurrences, as the pre-fix code did, misreads a `'''` sitting inside an
107
+ * open `"""` string, or a comment/single-line-string containing `'''` or
108
+ * `"""`, as a real close).
109
+ *
110
+ * When `delimIn` is null, the line is scanned left to right: a bare `#`
111
+ * (outside any string) starts a line comment and ends scanning (its
112
+ * content, including any quote characters, is never code); a single-line
113
+ * basic (`"`) or literal (`'`) string is skipped to its own closing quote
114
+ * (escapes honored only for `"`) without inspecting its contents for
115
+ * delimiters; a `"""`/`'''` not preceded by an escaped quote opens a
116
+ * multi-line string of that type.
117
+ */
118
+ function nextTripleDelim(rawLine, delimIn) {
119
+ let i = 0;
120
+ const n = rawLine.length;
121
+ let delim = delimIn;
122
+ while (i < n) {
123
+ if (delim !== null) {
124
+ if (rawLine.startsWith(delim, i)) {
125
+ if (delim === '"""' && isEscapedAt(rawLine, i)) {
126
+ i += 1;
127
+ continue;
128
+ }
129
+ delim = null;
130
+ i += 3;
131
+ continue;
132
+ }
133
+ if (delim === '"""' && rawLine[i] === "\\") {
134
+ i += 2; // an escaped char inside a basic string can never itself
135
+ continue; // start a real closing delimiter.
136
+ }
137
+ i += 1;
138
+ continue;
139
+ }
140
+ const ch = rawLine[i];
141
+ if (ch === "#")
142
+ break; // line comment: nothing after it is code.
143
+ if (rawLine.startsWith('"""', i)) {
144
+ delim = '"""';
145
+ i += 3;
146
+ continue;
147
+ }
148
+ if (rawLine.startsWith("'''", i)) {
149
+ delim = "'''";
150
+ i += 3;
151
+ continue;
152
+ }
153
+ if (ch === '"') {
154
+ i += 1;
155
+ while (i < n && rawLine[i] !== '"') {
156
+ if (rawLine[i] === "\\")
157
+ i += 1;
158
+ i += 1;
159
+ }
160
+ i += 1; // consume the closing quote (or run to end if unterminated)
161
+ continue;
162
+ }
163
+ if (ch === "'") {
164
+ i += 1;
165
+ while (i < n && rawLine[i] !== "'")
166
+ i += 1;
167
+ i += 1;
168
+ continue;
169
+ }
170
+ i += 1;
171
+ }
172
+ return delim;
173
+ }
174
+ /**
175
+ * The first line, at or after `from`, whose TRIMMED content is byte-equal
176
+ * to `target` (not a raw substring search). A raw `text.indexOf(target,
177
+ * from)` would also match `target` sitting mid-line inside unrelated
178
+ * content; matching only whole trimmed lines rules that out. A line that
179
+ * BEGAN inside an open TOML multi-line string (tracked via
180
+ * `nextTripleDelim`, correctly distinguishing `"""` from `'''` and
181
+ * ignoring delimiters inside a comment or a single-line string) is never
182
+ * treated as a possible match, regardless of its trimmed text: a foreign
183
+ * table's own multi-line string value may legitimately contain a line
184
+ * that reads exactly like the harness END marker (task 6a037359) without
185
+ * that being a real stray END. The triple-quote state is tracked only
186
+ * from `from` onward (a string open before `from` is not detected), which
187
+ * is correct for the caller's one use: `from` is always the start of the
188
+ * first foreign table in the zone being searched, so any multi-line
189
+ * string that matters here opens inside that zone, not before it.
190
+ */
191
+ function findExactLine(text, target, from) {
192
+ let pos = from;
193
+ let delim = null;
32
194
  while (pos < text.length) {
33
- const end = lineEndAfter(text, pos);
34
- const line = text.slice(pos, end).trim();
35
- if (pos > start && NON_HOOK_TABLE_RE.test(line))
195
+ const lineEnd = lineEndAfter(text, pos);
196
+ const rawLine = text.slice(pos, lineEnd);
197
+ const startedInsideString = delim !== null;
198
+ delim = nextTripleDelim(rawLine, delim);
199
+ if (!startedInsideString && rawLine.trim() === target)
36
200
  return pos;
37
- pos = end;
201
+ pos = lineEnd;
202
+ }
203
+ return -1;
204
+ }
205
+ /** The first non-blank, non-comment line at or after `from` (bounded by
206
+ * `limit`), trimmed. Used to name the offending foreign header in a
207
+ * refusal message. */
208
+ function firstContentLine(text, from, limit) {
209
+ let pos = Math.max(from, 0);
210
+ const end = Math.max(limit, from);
211
+ while (pos < end) {
212
+ const lineEnd = Math.min(lineEndAfter(text, pos), end);
213
+ const trimmed = text.slice(pos, lineEnd).trim();
214
+ if (trimmed !== "" && !trimmed.startsWith("#"))
215
+ return trimmed;
216
+ pos = lineEnd;
217
+ }
218
+ return null;
219
+ }
220
+ /**
221
+ * Scans forward from `start` classifying each line as either still-owned
222
+ * harness content or the start of foreign (non-harness) content, WITHOUT
223
+ * relying on where (or whether) a `CODEX_MANAGED_END` marker sits. This is
224
+ * what makes the region computation safe against a stray/misplaced END
225
+ * (task 6a037359: `findManagedRange` used to trust `text.indexOf(END,
226
+ * begin)` unconditionally, so an END marker that had drifted past
227
+ * Codex-written tables such as `[hooks.state]` caused every foreign table
228
+ * between BEGIN and that END to be deleted on the next install) and
229
+ * against a config that has no END marker at all (the legacy shape).
230
+ *
231
+ * A run of blank/comment lines immediately followed by foreign content is
232
+ * excluded from the owned region (backed off to the start of that run):
233
+ * Codex, or an operator, may attach a comment to the table that follows,
234
+ * and that comment must never be misread as harness commentary and folded
235
+ * into the replaced block.
236
+ *
237
+ * A line is "foreign" exactly when it is a table header that is not one
238
+ * of ours (`NON_HOOK_TABLE_RE`, mirroring the pre-existing legacy scan);
239
+ * every other non-blank, non-comment line (a hook table's `matcher =` /
240
+ * `hooks =` field, or an older schema's bare `command =` / `match =` /
241
+ * `timeout_ms =` / `blocking =` field) is treated as still-owned content,
242
+ * same as before this fix. A line that BEGAN inside an open TOML
243
+ * multi-line string (`nextTripleDelim`) is always treated as still-owned
244
+ * content too, regardless of what it looks like: a hook table's own
245
+ * multi-line string value (or a foreign table's, once one is already
246
+ * inside the owned region by construction -- this scanner only ever runs
247
+ * from a position that is itself still-owned) may contain a line that
248
+ * reads exactly like a comment, a blank line, or even the END marker
249
+ * without that ending the region early (task 6a037359).
250
+ */
251
+ function scanOwnedContentEnd(text, start) {
252
+ let pos = start;
253
+ let commentRunStart = null;
254
+ let delim = null;
255
+ while (pos < text.length) {
256
+ const lineEnd = lineEndAfter(text, pos);
257
+ const rawLine = text.slice(pos, lineEnd);
258
+ const startedInsideString = delim !== null;
259
+ delim = nextTripleDelim(rawLine, delim);
260
+ if (startedInsideString) {
261
+ commentRunStart = null;
262
+ pos = lineEnd;
263
+ continue;
264
+ }
265
+ const trimmed = rawLine.trim();
266
+ if (trimmed === CODEX_MANAGED_END) {
267
+ return { end: lineEnd, sawEndMarker: true };
268
+ }
269
+ if (trimmed === "" || trimmed.startsWith("#")) {
270
+ if (commentRunStart === null)
271
+ commentRunStart = pos;
272
+ pos = lineEnd;
273
+ continue;
274
+ }
275
+ if (NON_HOOK_TABLE_RE.test(trimmed)) {
276
+ return { end: commentRunStart ?? pos, sawEndMarker: false };
277
+ }
278
+ commentRunStart = null;
279
+ pos = lineEnd;
280
+ }
281
+ return { end: pos, sawEndMarker: false };
282
+ }
283
+ /**
284
+ * Refuses loudly (mirror case of the stray-END bug) when the zone between
285
+ * `from` and `to` still contains a recognizable fragment of a harness
286
+ * hook block: a second BEGIN/source-prefix marker, or a `# harness hook:`
287
+ * comment. Either means a foreign table was wedged between two harness
288
+ * hook tables, or the BEGIN marker moved, and the true block boundaries
289
+ * can no longer be determined by a line scan; guessing risks reordering
290
+ * or dropping harness- or foreign-owned bytes, so this throws instead. The
291
+ * message names the resolved config path (never a hard-coded `~/.codex`),
292
+ * the foreign table header the scan found (usually `from` itself, see
293
+ * `scanOwnedContentEnd`; when only blank or comment lines precede the found
294
+ * marker in the zone -- so the pre-marker lookup finds no content line to
295
+ * report -- the header is looked up again just past that marker's own
296
+ * line, since the wedged table then sits after it, not before; printed
297
+ * through `describeTableHeader`, so only the header's own bracketed key
298
+ * path appears, never a trailing comment), and
299
+ * the line number and marker of the first line beyond it
300
+ * that still looks like harness content. That line's own text is never
301
+ * echoed: the marker is found by a substring search, so the line may be
302
+ * foreign content (a value holding an API token, say) that merely contains
303
+ * the marker text, and the refusal is printed to stderr and into `--json`
304
+ * output. `hasEndMarker` distinguishes the two callers: when `to` was
305
+ * derived from a real stray `CODEX_MANAGED_END` line, the fix guidance can
306
+ * point at that marker; when there is no END marker anywhere in the
307
+ * document (`to` is `text.length`), telling the operator to move something
308
+ * below a marker that does not exist would be nonsensical, so a different
309
+ * fix (add one, or move the table below the last harness hook table) is
310
+ * offered instead (task 6a037359).
311
+ */
312
+ function assertNoSplitBlock(text, from, to, configPath, hasEndMarker) {
313
+ const zoneStart = Math.max(from, 0);
314
+ const zoneEnd = Math.max(to, from);
315
+ const zone = text.slice(zoneStart, zoneEnd);
316
+ let markerOffset = -1;
317
+ let marker = "";
318
+ for (const candidate of [
319
+ CODEX_MANAGED_SOURCE_PREFIX,
320
+ CODEX_MANAGED_BEGIN,
321
+ HARNESS_HOOK_COMMENT_PREFIX,
322
+ ]) {
323
+ const idx = zone.indexOf(candidate);
324
+ if (idx !== -1 && (markerOffset === -1 || idx < markerOffset)) {
325
+ markerOffset = idx;
326
+ marker = candidate.trim();
327
+ }
328
+ }
329
+ if (markerOffset === -1)
330
+ return;
331
+ const offendingLineNumber = lineNumberAt(text, lineStartAt(text, zoneStart + markerOffset));
332
+ // Normally the foreign table header is the zone's own first content line,
333
+ // sitting before the marker (`from` starts at that header per
334
+ // `scanOwnedContentEnd`). But when only blank or comment lines precede the
335
+ // marker in the zone -- so that pre-marker lookup finds no content line --
336
+ // the foreign table that split the block is wedged after the marker's own
337
+ // line instead, so look there.
338
+ const markerLineEnd = lineEndAfter(text, zoneStart + markerOffset);
339
+ const foreignHeaderLine = firstContentLine(text, zoneStart, zoneStart + markerOffset) ??
340
+ firstContentLine(text, markerLineEnd, zoneEnd);
341
+ const firstForeignHeader = foreignHeaderLine === null ? "(unknown)" : describeTableHeader(foreignHeaderLine);
342
+ const guidance = hasEndMarker
343
+ ? `Move ${firstForeignHeader} below the '${CODEX_MANAGED_END}' marker, or delete the lines ` +
344
+ `between '${CODEX_MANAGED_BEGIN}' and '${CODEX_MANAGED_END}' so only harness-owned tables ` +
345
+ "remain between them"
346
+ : `Add a '${CODEX_MANAGED_END}' marker line right after the last harness hook table (before ` +
347
+ `${firstForeignHeader}), or move ${firstForeignHeader} below the last harness hook table`;
348
+ throw new CodexInstallRefusalError(`Codex config ${configPath} has a foreign table (${firstForeignHeader}) sitting before ` +
349
+ `more harness-owned content: line ${offendingLineNumber} contains '${marker}' and still ` +
350
+ "looks like part of the harness-managed hook block; refusing to guess the block " +
351
+ `boundaries. ${guidance}, then re-run \`harness apply --runtime codex --install\`. As a ` +
352
+ `last resort, restore ${configPath} from the most recent .harness-backup-* file (if one exists), but ` +
353
+ "that discards any Codex state (hook trust entries, marketplaces, plugins) written to " +
354
+ "the file since that backup.", configPath);
355
+ }
356
+ // A single-line TOML table header split into the header itself (brackets
357
+ // plus dotted key path, without any trailing comment; group 1) and its
358
+ // dotted key path (group 2). Same shape `NON_HOOK_TABLE_RE` recognizes,
359
+ // minus the `[[hooks.` exclusion, so every header that regex accepts
360
+ // parses here too.
361
+ const TABLE_HEADER_PARTS_RE = new RegExp(String.raw `^(\[\[?\s*(${TOML_KEY}(?:\s*\.\s*${TOML_KEY})*)\s*\]\]?)\s*(?:#.*)?$`);
362
+ const TOML_KEY_GLOBAL_RE = new RegExp(TOML_KEY, "g");
363
+ /** A table header line's own bracketed key path (`header`) and its keys in
364
+ * order (`keys`), each as written. Keys are matched with the quote-aware
365
+ * `TOML_KEY`, so a quoted key containing `.`, `[`, `]` or `#` stays one
366
+ * key, and a trailing comment is never part of either field. `null` for a
367
+ * line that is not a single-line table header. */
368
+ function parseTableHeader(line) {
369
+ const m = TABLE_HEADER_PARTS_RE.exec(line.trim());
370
+ if (m?.[1] === undefined || m[2] === undefined)
371
+ return null;
372
+ return { header: m[1], keys: [...m[2].matchAll(TOML_KEY_GLOBAL_RE)].map((k) => k[0]) };
373
+ }
374
+ /** A table header for operator-facing output: the bracketed key path only,
375
+ * with any trailing comment dropped (a comment is free text and is not
376
+ * echoed). `(unknown)` for a line that is not a table header. */
377
+ function describeTableHeader(line) {
378
+ return parseTableHeader(line)?.header ?? "(unknown)";
379
+ }
380
+ /** A key segment's bare name: a literal (`'...'`) key's inner text, a basic
381
+ * (`"..."`) key's inner text when it holds no escape sequence, otherwise
382
+ * the segment as written. Used only to recognize the `hooks` root however
383
+ * it is spelled. */
384
+ function bareKeyName(segment) {
385
+ const quote = segment[0];
386
+ if (segment.length >= 2 && (quote === "'" || quote === '"') && segment.endsWith(quote)) {
387
+ const inner = segment.slice(1, -1);
388
+ if (quote === "'" || !inner.includes("\\"))
389
+ return inner;
390
+ }
391
+ return segment;
392
+ }
393
+ /** The namespace a foreign table header is grouped under in
394
+ * `foreignSectionsPreserved`: its first key, or its first TWO keys when the
395
+ * first is `hooks` (`hooks.state`), since `hooks` is shared with the
396
+ * harness-managed event arrays and a bare `hooks` group would not tell the
397
+ * operator which namespace was kept. */
398
+ function foreignSectionRootKey(headerLine) {
399
+ const parsed = parseTableHeader(headerLine);
400
+ const first = parsed?.keys[0];
401
+ if (parsed === null || first === undefined)
402
+ return headerLine.trim();
403
+ const second = parsed.keys[1];
404
+ if (bareKeyName(first) === "hooks" && second !== undefined)
405
+ return `${first}.${second}`;
406
+ return first;
407
+ }
408
+ /** One representative header per distinct foreign namespace found between
409
+ * `from` and `to` (e.g. all `[hooks.state."..."]` sub-tables collapse to
410
+ * one entry), in first-seen order. A namespace with more than one table
411
+ * under it prints its root with a wildcard and a count (e.g.
412
+ * `[hooks.state.*] (3 tables)`) instead of the first table's own header,
413
+ * so the operator reads "a whole namespace, N tables" rather than
414
+ * mistaking the printed header for the only table kept; exactly one table
415
+ * under a root prints its own header. Headers print through
416
+ * `describeTableHeader` (no trailing comment). */
417
+ function collectForeignSectionHeaders(text, from, to) {
418
+ const seenRoots = new Set();
419
+ const headers = [];
420
+ const counts = new Map();
421
+ let pos = Math.max(from, 0);
422
+ const limit = Math.max(to, from);
423
+ let delim = null;
424
+ while (pos < limit) {
425
+ const lineEnd = Math.min(lineEndAfter(text, pos), limit);
426
+ const rawLine = text.slice(pos, lineEnd);
427
+ const startedInsideString = delim !== null;
428
+ delim = nextTripleDelim(rawLine, delim);
429
+ if (startedInsideString) {
430
+ pos = lineEnd;
431
+ continue;
432
+ }
433
+ const trimmed = rawLine.trim();
434
+ if (NON_HOOK_TABLE_RE.test(trimmed)) {
435
+ const root = foreignSectionRootKey(trimmed);
436
+ counts.set(root, (counts.get(root) ?? 0) + 1);
437
+ if (!seenRoots.has(root)) {
438
+ seenRoots.add(root);
439
+ headers.push(describeTableHeader(trimmed));
440
+ }
441
+ }
442
+ pos = lineEnd;
443
+ }
444
+ return headers.map((header) => {
445
+ const root = foreignSectionRootKey(header);
446
+ const count = counts.get(root) ?? 1;
447
+ return count > 1 ? `[${root}.*] (${count} tables)` : header;
448
+ });
449
+ }
450
+ function extractHookIds(content) {
451
+ const ids = new Set();
452
+ for (const rawLine of content.split(/\r?\n/)) {
453
+ const m = HOOK_ID_LINE_RE.exec(rawLine.trim());
454
+ if (m?.[1] !== undefined)
455
+ ids.add(m[1]);
38
456
  }
39
- return text.length;
457
+ return ids;
40
458
  }
41
- function findManagedRange(text) {
459
+ function findManagedRange(text, configPath) {
42
460
  const begin = text.indexOf(CODEX_MANAGED_BEGIN);
43
461
  if (begin !== -1) {
462
+ const secondBegin = text.indexOf(CODEX_MANAGED_BEGIN, begin + CODEX_MANAGED_BEGIN.length);
463
+ if (secondBegin !== -1) {
464
+ // Names the second marker's line number only, never that line's own
465
+ // text: the marker is found by a substring search, so the line may be
466
+ // foreign content (a value holding a token, say) that merely contains
467
+ // the marker text, and this refusal is printed to stderr and `--json`.
468
+ const secondBeginLineNumber = lineNumberAt(text, lineStartAt(text, secondBegin));
469
+ throw new CodexInstallRefusalError(`Codex config ${configPath} has more than one '${CODEX_MANAGED_BEGIN}' marker ` +
470
+ `(a second one at line ${secondBeginLineNumber}); refusing to ` +
471
+ "guess which one is current. Delete the entire unwanted BEGIN/END pair -- including " +
472
+ `its own '${CODEX_MANAGED_BEGIN}' and '${CODEX_MANAGED_END}' marker lines (and its ` +
473
+ `'${CODEX_MANAGED_SOURCE_PREFIX}' source-prefix comment line, if it has one) -- ` +
474
+ "keeping exactly one pair, then re-run `harness apply --runtime codex --install`. As " +
475
+ `a last resort, restore ${configPath} from the most recent .harness-backup-* file (if one exists), ` +
476
+ "but that discards any Codex state (hook trust entries, marketplaces, plugins) " +
477
+ "written to the file since that backup.", configPath);
478
+ }
44
479
  let start = lineStartAt(text, begin);
45
480
  const prev = previousLine(text, start);
46
481
  if (prev && prev.text.startsWith(CODEX_MANAGED_SOURCE_PREFIX)) {
47
482
  start = prev.start;
48
483
  }
49
- const endMarker = text.indexOf(CODEX_MANAGED_END, begin);
50
- if (endMarker !== -1) {
51
- return { start, end: lineEndAfter(text, endMarker) };
484
+ const contentStart = lineEndAfter(text, begin);
485
+ const scan = scanOwnedContentEnd(text, contentStart);
486
+ const end = scan.end;
487
+ if (scan.sawEndMarker) {
488
+ return { start, end, foreignSectionsPreserved: [] };
489
+ }
490
+ const strayIdx = findExactLine(text, CODEX_MANAGED_END, end);
491
+ if (strayIdx === -1) {
492
+ assertNoSplitBlock(text, end, text.length, configPath, false);
493
+ const foreignSectionsPreserved = collectForeignSectionHeaders(text, end, text.length);
494
+ return { start, end, foreignSectionsPreserved };
52
495
  }
53
- return { start, end: findLegacyRangeEnd(text, start) };
496
+ const strayLineStart = lineStartAt(text, strayIdx);
497
+ const strayLineEnd = lineEndAfter(text, strayIdx);
498
+ assertNoSplitBlock(text, end, strayLineStart, configPath, true);
499
+ const foreignSectionsPreserved = collectForeignSectionHeaders(text, end, strayLineStart);
500
+ return {
501
+ start,
502
+ end,
503
+ strayEnd: { start: strayLineStart, end: strayLineEnd },
504
+ foreignSectionsPreserved,
505
+ };
54
506
  }
55
507
  const source = text.indexOf(CODEX_MANAGED_SOURCE_PREFIX);
56
508
  if (source !== -1) {
57
509
  const start = lineStartAt(text, source);
58
- return { start, end: findLegacyRangeEnd(text, start) };
510
+ const scan = scanOwnedContentEnd(text, start);
511
+ const end = scan.end;
512
+ const foreignSectionsPreserved = scan.sawEndMarker
513
+ ? []
514
+ : collectForeignSectionHeaders(text, end, text.length);
515
+ return { start, end, foreignSectionsPreserved };
59
516
  }
60
517
  const generated = text.indexOf(GENERATED_HEADER);
61
518
  if (generated !== -1) {
62
519
  const start = lineStartAt(text, generated);
63
- const end = findLegacyRangeEnd(text, start);
520
+ const scan = scanOwnedContentEnd(text, start);
521
+ const end = scan.end;
64
522
  const candidate = text.slice(start, end);
65
523
  if (candidate.includes("[[hooks.") ||
66
524
  candidate.includes("[[hooks.pre_tool_use]]")) {
67
- return { start, end };
525
+ const foreignSectionsPreserved = scan.sawEndMarker
526
+ ? []
527
+ : collectForeignSectionHeaders(text, end, text.length);
528
+ return { start, end, foreignSectionsPreserved };
68
529
  }
69
530
  }
70
531
  return null;
@@ -114,10 +575,198 @@ export function validateCodexManagedConfig(content) {
114
575
  }
115
576
  }
116
577
  }
578
+ /** Parses a Codex config for the safety net below. A leading UTF-8 byte
579
+ * order mark is not TOML content, so it is skipped for parsing only (the
580
+ * bytes written are never changed here). Integers beyond JavaScript's safe
581
+ * range, which TOML allows (64-bit), come back as a `bigint` instead of
582
+ * failing the parse. */
583
+ function parseToml(content) {
584
+ const body = content.startsWith("\uFEFF") ? content.slice(1) : content;
585
+ return parseTomlDocument(body, { integersAsBigInt: "asNeeded" });
586
+ }
587
+ /** Where and why the parser rejected a document, for a refusal message:
588
+ * the line and column it reports plus the first line of its message, and
589
+ * nothing else. The rest of a `TomlError` message is a code frame quoting
590
+ * the surrounding lines of the document verbatim; a Codex config holds
591
+ * secrets (API tokens under `[mcp_servers.*]`), and a refusal is printed to
592
+ * stderr and into `--json` output, so that frame is never included. Any
593
+ * other error is reduced to its name for the same reason. */
594
+ function describeTomlParseError(err) {
595
+ if (err instanceof TomlError) {
596
+ const firstLine = err.message.split("\n", 1)[0] ?? "";
597
+ const reason = firstLine.replace(/^Invalid TOML document: /, "");
598
+ return `line ${err.line}, column ${err.column}: ${reason}`;
599
+ }
600
+ return err instanceof Error ? err.name : "unknown parse error";
601
+ }
602
+ /** True for a parsed TOML table: a non-array, non-date object. */
603
+ function isTomlTable(value) {
604
+ return (typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date));
605
+ }
606
+ /** Every ARRAY-valued key directly under `doc.hooks` (the `[[hooks.<Event>]]`
607
+ * event arrays, the only shape the installer writes) removed, keeping every
608
+ * other `hooks.*` key (single tables such as `hooks.state`, or any future
609
+ * non-array key) untouched. Everything the result still holds, including all
610
+ * of `hooks.state`, is not the installer's to change. The stripped `hooks`
611
+ * map is a null-prototype object, so a key literally named `__proto__`
612
+ * stays an own key instead of re-pointing the map's prototype and dropping
613
+ * out of the comparison. A non-table `doc`, and a missing or non-table
614
+ * `hooks` value, are returned as-is: there is nothing to strip. */
615
+ function stripManagedHookArrays(doc) {
616
+ if (!isTomlTable(doc))
617
+ return doc;
618
+ const obj = doc;
619
+ const hooks = obj.hooks;
620
+ if (!isTomlTable(hooks))
621
+ return obj;
622
+ const strippedHooks = Object.create(null);
623
+ for (const [key, value] of Object.entries(hooks)) {
624
+ if (Array.isArray(value))
625
+ continue;
626
+ strippedHooks[key] = value;
627
+ }
628
+ const result = { ...obj };
629
+ if (Object.keys(strippedHooks).length === 0) {
630
+ // A document all of whose `hooks.*` keys were array-valued (the
631
+ // common case: nothing but harness-managed event arrays) must strip
632
+ // down to "no hooks key at all", the same as a document that never
633
+ // had one, so the comparison never flags the mere PRESENCE of an
634
+ // (empty, post-strip) `hooks` table as a semantic difference.
635
+ delete result.hooks;
636
+ }
637
+ else {
638
+ result.hooks = strippedHooks;
639
+ }
640
+ return result;
641
+ }
642
+ /** A date/time value's identity for comparison: its as-written TOML form
643
+ * (`TomlDate.toISOString` keeps local date, local time, local date-time
644
+ * and offset forms apart), or `invalid` for an unparseable date. */
645
+ function dateIdentity(value) {
646
+ return Number.isNaN(value.getTime()) ? "invalid" : value.toISOString();
647
+ }
648
+ /** The path (as a dotted string once joined by the caller) to the first
649
+ * value at which `a` and `b` differ, walking tables and arrays recursively
650
+ * and comparing primitives (including `bigint`) with `Object.is` and
651
+ * date/time values by `dateIdentity`; `null` means the two are deeply
652
+ * equal. A key present on one side only is a difference at that key (and,
653
+ * when it holds a table, at that table's first key). Key
654
+ * order follows `a`'s own insertion order first, then any key present only
655
+ * in `b`, so the reported path is deterministic. */
656
+ function firstDifferingKeyPath(a, b, path) {
657
+ if (a instanceof Date || b instanceof Date) {
658
+ if (!(a instanceof Date) || !(b instanceof Date))
659
+ return path;
660
+ return dateIdentity(a) === dateIdentity(b) ? null : path;
661
+ }
662
+ if (Array.isArray(a) || Array.isArray(b)) {
663
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
664
+ return path;
665
+ for (let i = 0; i < a.length; i += 1) {
666
+ const diff = firstDifferingKeyPath(a[i], b[i], [...path, String(i)]);
667
+ if (diff)
668
+ return diff;
669
+ }
670
+ return null;
671
+ }
672
+ if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) {
673
+ const aObj = a;
674
+ const bObj = b;
675
+ const keys = new Set([...Object.keys(aObj), ...Object.keys(bObj)]);
676
+ for (const key of keys) {
677
+ const aHas = Object.hasOwn(aObj, key);
678
+ if (aHas !== Object.hasOwn(bObj, key)) {
679
+ // A whole table present on one side only: name its first key too
680
+ // (`hooks.state` rather than just `hooks`), so the refusal points at
681
+ // what was lost.
682
+ const present = aHas ? aObj[key] : bObj[key];
683
+ const child = isTomlTable(present) ? Object.keys(present)[0] : undefined;
684
+ return child === undefined ? [...path, key] : [...path, key, child];
685
+ }
686
+ const diff = firstDifferingKeyPath(aObj[key], bObj[key], [...path, key]);
687
+ if (diff)
688
+ return diff;
689
+ }
690
+ return null;
691
+ }
692
+ return Object.is(a, b) ? null : path;
693
+ }
694
+ /**
695
+ * The TOML-semantic safety net (task 6a037359). The byte-preserving text
696
+ * splice in `planCodexConfigInstall` stays the source of truth for what gets
697
+ * WRITTEN; before it is written, `currentContent` and `nextContent` are
698
+ * both parsed and compared, and the install refuses rather than writes when
699
+ * the comparison finds a change outside what the installer owns. It exists
700
+ * to catch a scanner defect (a shape the line-based scan above mishandles)
701
+ * that would otherwise silently drop or alter operator- or Codex-owned
702
+ * content.
703
+ *
704
+ * The invariant: both documents must parse, and once every ARRAY-valued key
705
+ * directly under `hooks` (the `[[hooks.<Event>]]` event arrays) is removed
706
+ * from both, what remains must be deep-equal. What remains is everything
707
+ * outside the hook event arrays: `hooks.state` and every other single-table
708
+ * `hooks.*` key, plus all non-hooks content (top-level keys, `projects`,
709
+ * `plugins`, `marketplaces`, `mcp_servers`, `tui`, ...). A refusal names the
710
+ * first differing key path.
711
+ *
712
+ * What it does NOT check: the hook event arrays themselves. The installer
713
+ * rewrites its own entries there, and telling an operator-authored entry
714
+ * from a harness-written one would need the same line-level reading of the
715
+ * file this net is meant to be independent of. An operator-authored
716
+ * `[[hooks.<Event>]]` entry that the line scan wrongly counts as part of
717
+ * the harness-managed block is therefore replaced without a refusal
718
+ * (follow-up task 01053b27).
719
+ *
720
+ * Parsing goes through `parseToml` (a leading byte order mark ignored,
721
+ * 64-bit integers kept as `bigint`). A document that does not parse refuses
722
+ * with the parser's line, column and one-line reason only, never the
723
+ * document's own text (`describeTomlParseError`). This function never
724
+ * touches the file, and a `--dry-run` plan reports the same refusal a real
725
+ * install would (both go through `planCodexConfigInstall`).
726
+ */
727
+ export function assertConfigSemanticInvariant(currentContent, nextContent, configPath) {
728
+ let currentDoc;
729
+ try {
730
+ currentDoc = parseToml(currentContent);
731
+ }
732
+ catch (err) {
733
+ throw new CodexInstallRefusalError(`Codex config ${configPath}: the TOML parser used by harness could not read this file ` +
734
+ `(${describeTomlParseError(err)}); refusing to install, because without parsing it ` +
735
+ "harness cannot confirm that every table outside the harness-managed hook block " +
736
+ "would survive the install. The file is untouched. If Codex itself loads this file, " +
737
+ "the construct at that position is one this parser does not support; please report " +
738
+ "it as a harness issue (the construct, not its value).", configPath);
739
+ }
740
+ let nextDoc;
741
+ try {
742
+ nextDoc = parseToml(nextContent);
743
+ }
744
+ catch (err) {
745
+ throw new CodexInstallRefusalError(`The Codex config harness would write to ${configPath} would not parse as TOML ` +
746
+ `(in the would-be output, ${describeTomlParseError(err)}); refusing to write a config ` +
747
+ "that would not load. The file is untouched. A common cause is the current file " +
748
+ "already defining `hooks`, or a hook event under it, in a form the generated " +
749
+ "`[[hooks.<Event>]]` tables cannot extend: an inline table such as `hooks = { ... }`, " +
750
+ "or an inline array such as `PreToolUse = []` under `[hooks]`. Rewrite that " +
751
+ "definition as `[hooks.<name>]` / `[[hooks.<Event>]]` tables (or remove it), then " +
752
+ "re-run `harness apply --runtime codex --install`. If the file has no such " +
753
+ "definition, this points at a harness defect (in the generated hook block, or in " +
754
+ "where the install placed it); please report it with the config's shape, not its values.", configPath);
755
+ }
756
+ const diffPath = firstDifferingKeyPath(stripManagedHookArrays(currentDoc), stripManagedHookArrays(nextDoc), []);
757
+ if (diffPath) {
758
+ throw new CodexInstallRefusalError(`Codex config ${configPath}: installing would change '${diffPath.join(".")}', which is ` +
759
+ "not a harness-managed hook event array; refusing to install rather than risk " +
760
+ "silently dropping or altering operator- or Codex-owned content. The file is " +
761
+ `untouched. If it looks wrong, restore it from the most recent .harness-backup-* file ` +
762
+ "(if one exists), or file an issue with the config shape that triggered this.", configPath);
763
+ }
764
+ }
117
765
  export function planCodexConfigInstall(opts) {
118
766
  validateCodexManagedConfig(opts.generatedContent);
119
767
  const configPath = opts.configPath ?? defaultCodexConfigPath(opts.homeDir);
120
768
  let currentContent = "";
769
+ let configExisted = true;
121
770
  try {
122
771
  currentContent = fs.readFileSync(configPath, "utf8");
123
772
  }
@@ -125,13 +774,24 @@ export function planCodexConfigInstall(opts) {
125
774
  const e = err;
126
775
  if (e.code !== "ENOENT")
127
776
  throw err;
777
+ configExisted = false;
128
778
  }
129
779
  const managedBlock = buildManagedBlock(opts.generatedContent, opts.generatedPath);
130
- const range = findManagedRange(currentContent);
780
+ const range = findManagedRange(currentContent, configPath);
131
781
  let nextContent;
132
782
  let summary;
783
+ let removedHookIds = [];
784
+ let foreignSectionsPreserved = [];
133
785
  if (range) {
134
- nextContent = `${currentContent.slice(0, range.start)}${managedBlock}${currentContent.slice(range.end)}`;
786
+ const oldHookIds = extractHookIds(currentContent.slice(range.start, range.end));
787
+ const newHookIds = extractHookIds(managedBlock);
788
+ removedHookIds = [...oldHookIds].filter((id) => !newHookIds.has(id));
789
+ foreignSectionsPreserved = range.foreignSectionsPreserved;
790
+ const middleForeign = range.strayEnd
791
+ ? currentContent.slice(range.end, range.strayEnd.start)
792
+ : "";
793
+ const tailStart = range.strayEnd ? range.strayEnd.end : range.end;
794
+ nextContent = `${currentContent.slice(0, range.start)}${managedBlock}${middleForeign}${currentContent.slice(tailStart)}`;
135
795
  summary = `updated harness-managed Codex hook block in ${configPath}`;
136
796
  }
137
797
  else {
@@ -145,27 +805,58 @@ export function planCodexConfigInstall(opts) {
145
805
  nextContent = `${currentContent}${prefix}${managedBlock}`;
146
806
  summary = `installed harness-managed Codex hook block into ${configPath}`;
147
807
  }
808
+ if (currentContent !== nextContent) {
809
+ assertConfigSemanticInvariant(currentContent, nextContent, configPath);
810
+ }
148
811
  return {
149
812
  configPath,
150
813
  generatedPath: opts.generatedPath,
151
814
  currentContent,
152
815
  nextContent,
153
816
  changed: currentContent !== nextContent,
817
+ configExisted,
154
818
  summary: currentContent === nextContent
155
819
  ? `Codex config already up to date: ${configPath}`
156
820
  : summary,
821
+ removedHookIds,
822
+ foreignSectionsPreserved,
157
823
  };
158
824
  }
159
825
  function timestampForBackup(now) {
160
826
  return now.toISOString().replace(/[:.]/g, "-");
161
827
  }
828
+ /** The config holds operator secrets (API tokens under `[mcp_servers.*]`,
829
+ * Codex hook trust hashes); the install must never widen its permissions.
830
+ * Preserves the existing file's own mode for both the rewritten config and
831
+ * its backup, and falls back to `0o600` (owner read/write only) when
832
+ * creating a config that did not exist before this install, rather than
833
+ * `atomicWriteFile`'s own `0o644` default. */
834
+ function existingFileMode(filePath) {
835
+ try {
836
+ return fs.statSync(filePath).mode & 0o777;
837
+ }
838
+ catch (err) {
839
+ const e = err;
840
+ if (e.code !== "ENOENT")
841
+ throw err;
842
+ return 0o600;
843
+ }
844
+ }
162
845
  export function writeCodexConfigInstall(plan, opts = {}) {
163
846
  if (!plan.changed)
164
847
  return { ...plan, written: false };
165
- const backupPath = `${plan.configPath}.harness-backup-${timestampForBackup(opts.now ?? new Date())}`;
166
848
  fs.mkdirSync(path.dirname(plan.configPath), { recursive: true });
167
- atomicWriteFile(backupPath, plan.currentContent);
168
- atomicWriteFile(plan.configPath, plan.nextContent);
849
+ const mode = existingFileMode(plan.configPath);
850
+ // Only back up a config that actually existed before this install (task
851
+ // 461ec064): a fresh install has nothing to protect, and writing a
852
+ // 0-byte backup for it misreported a write that never happened.
853
+ if (!plan.configExisted) {
854
+ atomicWriteFile(plan.configPath, plan.nextContent, { mode });
855
+ return { ...plan, written: true };
856
+ }
857
+ const backupPath = `${plan.configPath}.harness-backup-${timestampForBackup(opts.now ?? new Date())}`;
858
+ atomicWriteFile(backupPath, plan.currentContent, { mode });
859
+ atomicWriteFile(plan.configPath, plan.nextContent, { mode });
169
860
  return { ...plan, backupPath, written: true };
170
861
  }
171
862
  //# sourceMappingURL=install-codex-config.js.map