@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.
- package/CHANGELOG.md +30 -0
- package/README.md +1 -1
- package/dist/cli/apply/apply.d.ts +6 -0
- package/dist/cli/apply/apply.js +2 -0
- package/dist/cli/apply/apply.js.map +1 -1
- package/dist/cli/apply/index.d.ts +1 -0
- package/dist/cli/apply/index.js +1 -0
- package/dist/cli/apply/index.js.map +1 -1
- package/dist/cli/apply/install-codex-config.d.ts +75 -0
- package/dist/cli/apply/install-codex-config.js +712 -21
- package/dist/cli/apply/install-codex-config.js.map +1 -1
- package/dist/cli/apply/next-steps.d.ts +17 -0
- package/dist/cli/apply/next-steps.js +17 -0
- package/dist/cli/apply/next-steps.js.map +1 -1
- package/dist/cli/doctor/types.d.ts +1 -1
- package/dist/cli/index.js +62 -17
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/pack/hook-solution-acceptance.js +24 -2
- package/dist/cli/pack/hook-solution-acceptance.js.map +1 -1
- package/dist/cli/pack/hook-stay-in-scope.js +7 -5
- package/dist/cli/pack/hook-stay-in-scope.js.map +1 -1
- package/dist/cli/policy/intercept.js +2 -2
- package/dist/cli/validate/checks.js +3 -3
- package/dist/cli/validate/checks.js.map +1 -1
- package/dist/runtime/kubectl-target-parse.js +3 -3
- package/dist/runtime/kubectl-target-parse.js.map +1 -1
- package/dist/runtime/task-providers/agent-tasks.d.ts +3 -1
- package/dist/runtime/task-providers/agent-tasks.js +3 -1
- package/dist/runtime/task-providers/agent-tasks.js.map +1 -1
- package/package.json +3 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
|
34
|
-
const
|
|
35
|
-
|
|
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 =
|
|
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
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|