@codetelemetry/connect 0.1.0

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.
@@ -0,0 +1,631 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ export const REPOSITORY_HOOK_MARKER = "codetelemetry-repository-observation";
4
+ export const REPOSITORY_HOOK_FILE = "codetelemetry-repository-hook.cjs";
5
+ export const REPOSITORY_HOOK_STATE_FILE = "codetelemetry-repository-hook-state.json";
6
+ export const REPOSITORY_HOOK_STATE_MAX_ENTRIES = 64;
7
+ export const CODEX_HOOKS_FILE = "hooks.json";
8
+ export function normalizeRepositoryRemote(value) {
9
+ const raw = String(value || "").trim();
10
+ if (!raw)
11
+ return "";
12
+ const scp = raw.includes("://") ? null : /^(?:[^@/:]+@)?([^/:]+):(.+)$/.exec(raw);
13
+ const candidate = scp ? `ssh://${scp[1]}/${scp[2]}` : raw;
14
+ try {
15
+ const parsed = new URL(candidate);
16
+ if (parsed.protocol === "file:") {
17
+ return parsed.pathname.replace(/\.git\/?$/i, "").replace(/\/$/, "");
18
+ }
19
+ const host = parsed.hostname.toLowerCase() + (parsed.port ? `:${parsed.port}` : "");
20
+ const pathname = parsed.pathname
21
+ .replace(/\/{2,}/g, "/")
22
+ .replace(/\.git\/?$/i, "")
23
+ .replace(/^\/+|\/+$/g, "");
24
+ return host && pathname ? `${host}/${pathname}` : "";
25
+ }
26
+ catch {
27
+ return raw.replace(/\.git\/?$/i, "").replace(/\/$/, "");
28
+ }
29
+ }
30
+ export function selectCodexOtelExporterLine(raw) {
31
+ if (raw.includes('"""') || raw.includes("'''"))
32
+ return "";
33
+ const stripComment = (line) => {
34
+ let quote = null;
35
+ for (let i = 0; i < line.length; i++) {
36
+ const ch = line[i];
37
+ if (quote) {
38
+ if (ch === "\\" && quote === '"')
39
+ i++;
40
+ else if (ch === quote)
41
+ quote = null;
42
+ }
43
+ else if (ch === '"' || ch === "'")
44
+ quote = ch;
45
+ else if (ch === "#")
46
+ return line.slice(0, i);
47
+ }
48
+ return line;
49
+ };
50
+ const structuralOnly = (line) => {
51
+ let out = "";
52
+ let quote = null;
53
+ for (let i = 0; i < line.length; i++) {
54
+ const ch = line[i];
55
+ if (quote) {
56
+ if (ch === "\\" && quote === '"')
57
+ i++;
58
+ else if (ch === quote)
59
+ quote = null;
60
+ }
61
+ else if (ch === '"' || ch === "'")
62
+ quote = ch;
63
+ else if (ch === "#")
64
+ break;
65
+ else
66
+ out += ch;
67
+ }
68
+ return out;
69
+ };
70
+ const headerName = (line) => {
71
+ const text = stripComment(line).trim();
72
+ if (!text.startsWith("[") || !text.endsWith("]"))
73
+ return null;
74
+ const arrayTable = text.startsWith("[[");
75
+ const closer = arrayTable ? "]]" : "]";
76
+ if (!text.endsWith(closer))
77
+ return null;
78
+ const inner = text.slice(arrayTable ? 2 : 1, text.length - closer.length).trim();
79
+ if (!inner)
80
+ return null;
81
+ const unquoted = inner.replace(/^(["'])(.*)\1$/, "$2");
82
+ return arrayTable ? `[]${unquoted}` : unquoted;
83
+ };
84
+ const depthDelta = (line) => {
85
+ let depth = 0;
86
+ for (const ch of structuralOnly(line)) {
87
+ if (ch === "[" || ch === "{")
88
+ depth++;
89
+ else if (ch === "]" || ch === "}")
90
+ depth--;
91
+ }
92
+ return depth;
93
+ };
94
+ let depth = 0;
95
+ let inOtel = false;
96
+ for (const line of raw.split(/\r?\n/)) {
97
+ if (depth === 0) {
98
+ const header = headerName(line);
99
+ if (header !== null) {
100
+ inOtel = header === "otel";
101
+ }
102
+ else if (inOtel && /^\s*exporter\s*=/.test(structuralOnly(line))) {
103
+ return line;
104
+ }
105
+ }
106
+ depth = Math.max(0, depth + depthDelta(line));
107
+ }
108
+ return "";
109
+ }
110
+ export function repositoryHookSource(options = {}) {
111
+ const codexConfigPath = options.codexConfigPath
112
+ ? path.resolve(options.codexConfigPath)
113
+ : "";
114
+ return String.raw `"use strict";
115
+ const crypto = require("node:crypto");
116
+ const fs = require("node:fs");
117
+ const http = require("node:http");
118
+ const https = require("node:https");
119
+ const path = require("node:path");
120
+ const { execFileSync } = require("node:child_process");
121
+ const codexConfigPath = ${JSON.stringify(codexConfigPath)};
122
+ const observationStatePath = path.join(__dirname, ${JSON.stringify(REPOSITORY_HOOK_STATE_FILE)});
123
+ const observationLockPath = observationStatePath + ".lock";
124
+ const maxObservationStateEntries = ${REPOSITORY_HOOK_STATE_MAX_ENTRIES};
125
+ const lockWaitCell = new Int32Array(new SharedArrayBuffer(4));
126
+ // Codex kills this command after three seconds. Keep one absolute budget for
127
+ // all synchronous discovery, state locks, transport, and rollback work, with a
128
+ // generous host-scheduler margin rather than stacking independent timeouts.
129
+ const hookDeadlineAt = Date.now() + 2200;
130
+ const postClaimRollbackReserveMs = 250;
131
+ const minimumPostClaimBudgetMs = 850;
132
+ const maximumGitBudgetMs = 275;
133
+ const maximumTransportBudgetMs = 650;
134
+ function remainingHookBudget() { return Math.max(0, hookDeadlineAt - Date.now()); }
135
+
136
+ function git(cwd, args) {
137
+ // Never let repository discovery consume the time reserved for a delivery
138
+ // failure to restore its claim before the host's hard timeout.
139
+ const timeout = Math.min(maximumGitBudgetMs, remainingHookBudget() - minimumPostClaimBudgetMs);
140
+ if (timeout < 25) return "";
141
+ try { return execFileSync("git", ["-C", cwd, ...args], {
142
+ encoding: "utf8",
143
+ stdio: ["ignore", "pipe", "ignore"],
144
+ timeout: Math.floor(timeout),
145
+ }).trim(); }
146
+ catch { return ""; }
147
+ }
148
+ const repositoryRemoteIdentity = ${normalizeRepositoryRemote.toString()};
149
+ const selectCodexOtelExporterLine = ${selectCodexOtelExporterLine.toString()};
150
+ function remoteName(value) {
151
+ if (!value) return "";
152
+ try {
153
+ const parsed = new URL(value);
154
+ return parsed.pathname.split("/").filter(Boolean).pop() || "";
155
+ } catch {
156
+ return value.split(/[\\/]/).filter(Boolean).pop() || "";
157
+ }
158
+ }
159
+ function attr(key, value) { return { key, value: { stringValue: String(value) } }; }
160
+ function digest(value) { return crypto.createHash("sha256").update(String(value)).digest("hex"); }
161
+ function acquireObservationLock(deadlineAt) {
162
+ for (let attempt = 0; attempt < 30; attempt++) {
163
+ if (Date.now() + 5 > deadlineAt) return null;
164
+ try { return fs.openSync(observationLockPath, "wx", 0o600); }
165
+ catch (error) {
166
+ if (!error || error.code !== "EEXIST") return null;
167
+ try {
168
+ if (Date.now() - fs.statSync(observationLockPath).mtimeMs > 5000) {
169
+ fs.unlinkSync(observationLockPath);
170
+ continue;
171
+ }
172
+ } catch {}
173
+ try { Atomics.wait(lockWaitCell, 0, 0, 5); } catch {}
174
+ }
175
+ }
176
+ return null;
177
+ }
178
+ function readObservationState() {
179
+ try {
180
+ const stat = fs.statSync(observationStatePath);
181
+ if (!stat.isFile() || stat.size > 32768) return [];
182
+ const parsed = JSON.parse(fs.readFileSync(observationStatePath, "utf8"));
183
+ if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.entries)) return [];
184
+ return parsed.entries.filter((entry) => entry &&
185
+ /^[a-f0-9]{64}$/.test(entry.sessionHash) &&
186
+ /^[a-f0-9]{64}$/.test(entry.observationHash)
187
+ ).slice(-maxObservationStateEntries);
188
+ } catch { return []; }
189
+ }
190
+ function writeObservationState(entries) {
191
+ // An exclusive, unguessable sibling keeps atomic publication from following
192
+ // a pre-planted temp-file symlink in this user-writable config directory.
193
+ const tempPath = observationStatePath + "." + crypto.randomUUID() + ".tmp";
194
+ try {
195
+ fs.writeFileSync(tempPath, JSON.stringify({ version: 1, entries: entries.slice(-maxObservationStateEntries) }), {
196
+ encoding: "utf8",
197
+ mode: 0o600,
198
+ flag: "wx",
199
+ });
200
+ fs.renameSync(tempPath, observationStatePath);
201
+ try { fs.chmodSync(observationStatePath, 0o600); } catch {}
202
+ } finally {
203
+ try { fs.unlinkSync(tempPath); } catch {}
204
+ }
205
+ }
206
+ function claimObservation(sessionId, observationHash, preserveStart, deadlineAt) {
207
+ const sessionHash = digest(sessionId || "anonymous-session");
208
+ // A persisted pre-delivery claim must never look like a delivered
209
+ // observation. If the host kills this process, the next invocation compares
210
+ // its real observation hash to this opaque marker and retries immediately.
211
+ const claimHash = digest("pending\0" + sessionHash + "\0" + observationHash + "\0" + crypto.randomUUID());
212
+ const lock = acquireObservationLock(deadlineAt);
213
+ // A start observation remains useful even when local state is unavailable.
214
+ // Other events fail closed to a no-op so a broken cache cannot amplify every
215
+ // tool call into a network request.
216
+ if (lock === null) return preserveStart
217
+ ? { persisted: false, sessionHash, observationHash, claimHash, previousObservationHash: null }
218
+ : null;
219
+ try {
220
+ const entries = readObservationState();
221
+ const current = entries.find((entry) => entry.sessionHash === sessionHash);
222
+ if (!preserveStart && current && current.observationHash === observationHash) return null;
223
+ writeObservationState([
224
+ ...entries.filter((entry) => entry.sessionHash !== sessionHash),
225
+ { sessionHash, observationHash: claimHash },
226
+ ]);
227
+ return {
228
+ persisted: true,
229
+ sessionHash,
230
+ observationHash,
231
+ claimHash,
232
+ previousObservationHash: current ? current.observationHash : null,
233
+ };
234
+ } catch {
235
+ return preserveStart
236
+ ? { persisted: false, sessionHash, observationHash, claimHash, previousObservationHash: null }
237
+ : null;
238
+ } finally {
239
+ try { fs.closeSync(lock); } catch {}
240
+ try { fs.unlinkSync(observationLockPath); } catch {}
241
+ }
242
+ }
243
+ function rollbackObservation(claim, deadlineAt) {
244
+ if (!claim || !claim.persisted) return;
245
+ const lock = acquireObservationLock(deadlineAt);
246
+ if (lock === null) return;
247
+ try {
248
+ const entries = readObservationState();
249
+ const current = entries.find((entry) => entry.sessionHash === claim.sessionHash);
250
+ // Delivery can overlap a later PostToolUse. Restore only our own claim;
251
+ // never overwrite newer repository/workspace evidence for this session.
252
+ if (!current || current.observationHash !== claim.claimHash) return;
253
+ const restored = entries.filter((entry) => entry.sessionHash !== claim.sessionHash);
254
+ if (claim.previousObservationHash) {
255
+ restored.push({
256
+ sessionHash: claim.sessionHash,
257
+ observationHash: claim.previousObservationHash,
258
+ });
259
+ }
260
+ writeObservationState(restored);
261
+ } catch {
262
+ // Cache repair is best-effort and must never block the host client.
263
+ } finally {
264
+ try { fs.closeSync(lock); } catch {}
265
+ try { fs.unlinkSync(observationLockPath); } catch {}
266
+ }
267
+ }
268
+ function commitObservation(claim, deadlineAt) {
269
+ if (!claim || !claim.persisted) return;
270
+ const lock = acquireObservationLock(deadlineAt);
271
+ if (lock === null) return;
272
+ try {
273
+ const entries = readObservationState();
274
+ const current = entries.find((entry) => entry.sessionHash === claim.sessionHash);
275
+ // A later transition may have claimed or committed while this request was
276
+ // in flight. Only this exact pending marker may publish its delivered hash.
277
+ if (!current || current.observationHash !== claim.claimHash) return;
278
+ writeObservationState([
279
+ ...entries.filter((entry) => entry.sessionHash !== claim.sessionHash),
280
+ { sessionHash: claim.sessionHash, observationHash: claim.observationHash },
281
+ ]);
282
+ } catch {
283
+ // Leaving an opaque pending marker is safe: it cannot suppress a retry.
284
+ } finally {
285
+ try { fs.closeSync(lock); } catch {}
286
+ try { fs.unlinkSync(observationLockPath); } catch {}
287
+ }
288
+ }
289
+ function codexTelemetryConfig() {
290
+ if (!codexConfigPath) return {};
291
+ try {
292
+ const raw = fs.readFileSync(codexConfigPath, "utf8");
293
+ // The connect writer owns this single-line exporter shape. Read the live
294
+ // config at hook time so the bearer key is never copied into this script
295
+ // and key rotation does not change the trusted hook definition.
296
+ const line = selectCodexOtelExporterLine(raw);
297
+ const endpointMatch = /\bendpoint\s*=\s*"([^"]+)"/.exec(line);
298
+ const authorizationMatch = /\bauthorization\s*=\s*"([^"]+)"/i.exec(line);
299
+ return {
300
+ endpoint: endpointMatch ? endpointMatch[1] : "",
301
+ authorization: authorizationMatch ? authorizationMatch[1] : "",
302
+ };
303
+ } catch { return {}; }
304
+ }
305
+ function environmentAuthHeaders() {
306
+ const raw = String(process.env.OTEL_EXPORTER_OTLP_HEADERS || "");
307
+ for (const part of raw.split(",")) {
308
+ const separator = part.indexOf("=");
309
+ if (separator < 0 || part.slice(0, separator).trim().toLowerCase() !== "authorization") continue;
310
+ const value = part.slice(separator + 1).trim();
311
+ if (!value) return {};
312
+ // OTEL exporter headers permit percent-encoded values. Decode only after
313
+ // splitting fields so an encoded comma cannot become a second credential.
314
+ try { return { Authorization: decodeURIComponent(value) }; }
315
+ catch { return { Authorization: value }; }
316
+ }
317
+ return {};
318
+ }
319
+ function deliveryDestination() {
320
+ const base = String(process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "").replace(/\/$/, "");
321
+ // Endpoint and credentials are one provenance-bearing choice. In particular,
322
+ // never attach the key from config.toml to an environment-provided endpoint.
323
+ if (base) return {
324
+ endpoint: base.endsWith("/v1/logs") ? base : base + "/v1/logs",
325
+ headers: environmentAuthHeaders(),
326
+ source: "environment",
327
+ };
328
+ const config = codexTelemetryConfig();
329
+ const authorization = String(config.authorization || "");
330
+ return {
331
+ endpoint: String(config.endpoint || ""),
332
+ headers: authorization ? { Authorization: authorization } : {},
333
+ source: "config",
334
+ };
335
+ }
336
+ function isLoopbackHostname(value) {
337
+ const hostname = String(value || "").toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
338
+ return hostname === "::1" || hostname === "localhost" || hostname.endsWith(".localhost") ||
339
+ /^127(?:\.\d{1,3}){3}$/.test(hostname);
340
+ }
341
+ function permitsAuthorization(url, headers) {
342
+ const authorization = String(headers.Authorization || "");
343
+ if (!/^\s*Bearer\s+/i.test(authorization)) return true;
344
+ return url.protocol === "https:" || (url.protocol === "http:" && isLoopbackHostname(url.hostname));
345
+ }
346
+ async function main() {
347
+ let input = {};
348
+ try { input = JSON.parse(fs.readFileSync(0, "utf8") || "{}"); } catch {}
349
+ const destination = deliveryDestination();
350
+ let url;
351
+ try { url = new URL(destination.endpoint); } catch { return; }
352
+ if (url.protocol !== "https:" && url.protocol !== "http:") return;
353
+ if (!permitsAuthorization(url, destination.headers)) return;
354
+ const cwd = path.resolve(String(input.new_cwd || input.cwd || process.cwd()));
355
+ const root = git(cwd, ["rev-parse", "--show-toplevel"]) || cwd;
356
+ const remote = repositoryRemoteIdentity(git(root, ["config", "--get", "remote.origin.url"]));
357
+ const initialCommit = git(root, ["rev-list", "--max-parents=0", "HEAD"]).split(/\s+/).filter(Boolean).sort()[0] || "";
358
+ const commonDir = git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
359
+ const commonRoot = commonDir.endsWith(path.sep + ".git") ? path.dirname(commonDir) : root;
360
+ const repositoryId = crypto.createHash("sha256").update(remote || initialCommit || root).digest("hex");
361
+ const repositoryName = remoteName(remote) || path.basename(commonRoot);
362
+ const sessionId = String(input.session_id || "").slice(0, 256);
363
+ const hookEvent = String(input.hook_event_name || "repository_observed").slice(0, 64);
364
+ // State contains only fixed-size hashes: session ids and raw paths never
365
+ // persist locally. SessionStart is intentionally never suppressed, while
366
+ // prompt/tool hooks emit only when repository or workspace evidence changes.
367
+ const observationHash = digest(repositoryId + "\0" + digest(root) + "\0" + digest(cwd));
368
+ const now = String(BigInt(Date.now()) * 1000000n);
369
+ const attributes = [
370
+ attr("event.name", "codetelemetry.repository_observed"),
371
+ attr("project.path", root),
372
+ attr("code.repository.id", repositoryId),
373
+ attr("code.repository.name", repositoryName),
374
+ attr("code.repository.root", root),
375
+ attr("code.workspace.path", cwd),
376
+ attr("hook.event", hookEvent),
377
+ ];
378
+ if (sessionId) attributes.push(attr("session.id", sessionId));
379
+ const body = JSON.stringify({ resourceLogs: [{
380
+ resource: { attributes: [attr("service.name", "codetelemetry-connect")] },
381
+ scopeLogs: [{ scope: { name: "codetelemetry.repository-hook", version: "1" }, logRecords: [{
382
+ timeUnixNano: now,
383
+ observedTimeUnixNano: now,
384
+ body: { stringValue: "repository_observed" },
385
+ attributes,
386
+ }] }],
387
+ }] });
388
+ // This hook performs one non-retrying delivery, so a fresh UUID is both the
389
+ // logical-delivery identity and the durable retry receipt key. It must never
390
+ // be installed as a static OTLP exporter header.
391
+ const idempotencyKey = crypto.randomUUID();
392
+ // Claim only after every local delivery input is valid. If transport then
393
+ // fails, rollback below compare-and-restores this exact hash. Do not begin a
394
+ // claim unless the absolute hook budget still covers transport and rollback.
395
+ if (remainingHookBudget() < minimumPostClaimBudgetMs) return;
396
+ const claimDeadlineAt = Math.min(hookDeadlineAt - minimumPostClaimBudgetMs + 100, Date.now() + 100);
397
+ const claim = claimObservation(sessionId, observationHash, hookEvent === "SessionStart", claimDeadlineAt);
398
+ if (!claim) return;
399
+ let delivered = false;
400
+ try {
401
+ const transportBudget = Math.min(
402
+ maximumTransportBudgetMs,
403
+ remainingHookBudget() - postClaimRollbackReserveMs,
404
+ );
405
+ if (transportBudget < 25) throw new Error("repository hook delivery budget exhausted");
406
+ delivered = await new Promise((resolve) => {
407
+ let finished = false;
408
+ let absoluteTimer;
409
+ const finish = (ok) => {
410
+ if (finished) return;
411
+ finished = true;
412
+ if (absoluteTimer) clearTimeout(absoluteTimer);
413
+ resolve(ok);
414
+ };
415
+ const transport = url.protocol === "https:" ? https : http;
416
+ const req = transport.request(url, {
417
+ method: "POST",
418
+ headers: {
419
+ "content-type": "application/json",
420
+ "content-length": Buffer.byteLength(body),
421
+ "x-code-telemetry-idempotency-key": idempotencyKey,
422
+ ...destination.headers,
423
+ },
424
+ timeout: Math.floor(transportBudget),
425
+ }, (res) => {
426
+ const accepted = Number(res.statusCode) >= 200 && Number(res.statusCode) < 300;
427
+ res.resume();
428
+ res.on("end", () => finish(accepted));
429
+ res.on("error", () => finish(false));
430
+ });
431
+ // ClientRequest's timeout is an inactivity timeout, not an end-to-end
432
+ // bound. This timer also covers a peer that trickles or never responds.
433
+ if (!finished) {
434
+ absoluteTimer = setTimeout(() => { req.destroy(); finish(false); }, Math.floor(transportBudget));
435
+ }
436
+ req.on("timeout", () => { req.destroy(); finish(false); });
437
+ req.on("error", () => finish(false));
438
+ req.end(body);
439
+ });
440
+ } catch {}
441
+ if (delivered) commitObservation(claim, hookDeadlineAt);
442
+ else rollbackObservation(claim, hookDeadlineAt);
443
+ }
444
+ main().catch(() => {}).finally(() => { process.exitCode = 0; });
445
+ `;
446
+ }
447
+ export function quotedCommand(filePath, platform = process.platform) {
448
+ const quote = platform === "win32"
449
+ ? (value) => `"${value}"`
450
+ : (value) => `'${value.replace(/'/g, `'\\''`)}'`;
451
+ return `${quote(process.execPath)} ${quote(filePath)} ${quote(REPOSITORY_HOOK_MARKER)}`;
452
+ }
453
+ function withoutManaged(groups) {
454
+ if (!Array.isArray(groups))
455
+ return [];
456
+ return groups.flatMap((group) => {
457
+ if (!group || typeof group !== "object")
458
+ return [];
459
+ const record = group;
460
+ const hooks = Array.isArray(record.hooks)
461
+ ? record.hooks.filter((hook) => !hook || typeof hook !== "object" ||
462
+ !String(hook.command ?? "").includes(REPOSITORY_HOOK_MARKER))
463
+ : [];
464
+ return hooks.length ? [{ ...record, hooks }] : [];
465
+ });
466
+ }
467
+ function withoutManagedHookEvents(previous) {
468
+ const next = {};
469
+ for (const [event, groups] of Object.entries(previous)) {
470
+ if (!Array.isArray(groups)) {
471
+ next[event] = groups;
472
+ continue;
473
+ }
474
+ const kept = withoutManaged(groups);
475
+ if (kept.length)
476
+ next[event] = kept;
477
+ }
478
+ return next;
479
+ }
480
+ function hasManagedHookEvents(previous) {
481
+ return Object.values(previous).some((groups) => Array.isArray(groups) && groups.some((group) => {
482
+ if (!group || typeof group !== "object")
483
+ return false;
484
+ const hooks = group.hooks;
485
+ return Array.isArray(hooks) && hooks.some((hook) => hook && typeof hook === "object" &&
486
+ String(hook.command ?? "").includes(REPOSITORY_HOOK_MARKER));
487
+ }));
488
+ }
489
+ function removeOwnedHookFiles(directory) {
490
+ for (const name of [
491
+ REPOSITORY_HOOK_FILE,
492
+ REPOSITORY_HOOK_STATE_FILE,
493
+ `${REPOSITORY_HOOK_STATE_FILE}.lock`,
494
+ ]) {
495
+ try {
496
+ fs.unlinkSync(path.join(directory, name));
497
+ }
498
+ catch { }
499
+ }
500
+ }
501
+ function readCodexHooksFile(hooksPath) {
502
+ if (!fs.existsSync(hooksPath))
503
+ return {};
504
+ const raw = fs.readFileSync(hooksPath, "utf8").trim();
505
+ if (!raw)
506
+ return {};
507
+ try {
508
+ const parsed = JSON.parse(raw);
509
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
510
+ if ("hooks" in parsed && (typeof parsed.hooks !== "object" ||
511
+ parsed.hooks === null || Array.isArray(parsed.hooks))) {
512
+ throw new Error("hooks must be an object");
513
+ }
514
+ return parsed;
515
+ }
516
+ throw new Error("hooks.json is not a JSON object");
517
+ }
518
+ catch (error) {
519
+ throw new Error(`refusing to overwrite ${hooksPath}: it isn't valid Codex hooks JSON (${error.message}). ` +
520
+ "Fix or move it, then retry.");
521
+ }
522
+ }
523
+ export function validateCodexRepositoryObservationHook(configPath) {
524
+ readCodexHooksFile(path.join(path.dirname(path.resolve(configPath)), CODEX_HOOKS_FILE));
525
+ }
526
+ export function installCodexRepositoryObservationHook(configPath) {
527
+ const resolvedConfigPath = path.resolve(configPath);
528
+ const directory = path.dirname(resolvedConfigPath);
529
+ const hooksPath = path.join(directory, CODEX_HOOKS_FILE);
530
+ const hookPath = path.join(directory, REPOSITORY_HOOK_FILE);
531
+ const existing = readCodexHooksFile(hooksPath);
532
+ const previous = existing.hooks ?? {};
533
+ const command = {
534
+ type: "command",
535
+ command: quotedCommand(hookPath),
536
+ timeout: 3,
537
+ };
538
+ const next = {
539
+ ...existing,
540
+ hooks: {
541
+ ...previous,
542
+ SessionStart: [
543
+ ...withoutManaged(previous.SessionStart),
544
+ { matcher: "startup|resume|clear|compact", hooks: [command] },
545
+ ],
546
+ UserPromptSubmit: [
547
+ ...withoutManaged(previous.UserPromptSubmit),
548
+ { hooks: [command] },
549
+ ],
550
+ PostToolUse: [
551
+ ...withoutManaged(previous.PostToolUse),
552
+ { hooks: [command] },
553
+ ],
554
+ },
555
+ };
556
+ fs.mkdirSync(directory, { recursive: true });
557
+ fs.writeFileSync(hookPath, repositoryHookSource({ codexConfigPath: resolvedConfigPath }), { mode: 0o700 });
558
+ try {
559
+ fs.chmodSync(hookPath, 0o700);
560
+ }
561
+ catch { }
562
+ fs.writeFileSync(hooksPath, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
563
+ try {
564
+ fs.chmodSync(hooksPath, 0o600);
565
+ }
566
+ catch { }
567
+ }
568
+ export function removeCodexRepositoryObservationHook(configPath) {
569
+ const directory = path.dirname(path.resolve(configPath));
570
+ const hooksPath = path.join(directory, CODEX_HOOKS_FILE);
571
+ if (fs.existsSync(hooksPath)) {
572
+ try {
573
+ const existing = readCodexHooksFile(hooksPath);
574
+ const previous = existing.hooks ?? {};
575
+ if (hasManagedHookEvents(previous)) {
576
+ const hooks = withoutManagedHookEvents(previous);
577
+ fs.writeFileSync(hooksPath, JSON.stringify({ ...existing, hooks }, null, 2) + "\n", { mode: 0o600 });
578
+ try {
579
+ fs.chmodSync(hooksPath, 0o600);
580
+ }
581
+ catch { }
582
+ }
583
+ }
584
+ catch {
585
+ }
586
+ }
587
+ removeOwnedHookFiles(directory);
588
+ }
589
+ export function installRepositoryObservationHook(settings, settingsPath) {
590
+ const directory = path.dirname(path.resolve(settingsPath));
591
+ const hookPath = path.join(directory, REPOSITORY_HOOK_FILE);
592
+ fs.mkdirSync(directory, { recursive: true });
593
+ fs.writeFileSync(hookPath, repositoryHookSource(), { mode: 0o700 });
594
+ try {
595
+ fs.chmodSync(hookPath, 0o700);
596
+ }
597
+ catch { }
598
+ const command = { type: "command", command: quotedCommand(hookPath), timeout: 3 };
599
+ const previous = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks)
600
+ ? settings.hooks
601
+ : {};
602
+ return {
603
+ ...settings,
604
+ hooks: {
605
+ ...previous,
606
+ SessionStart: [
607
+ ...withoutManaged(previous.SessionStart),
608
+ { matcher: "startup|resume|clear|compact", hooks: [command] },
609
+ ],
610
+ CwdChanged: [
611
+ ...withoutManaged(previous.CwdChanged),
612
+ { hooks: [command] },
613
+ ],
614
+ },
615
+ };
616
+ }
617
+ export function removeRepositoryObservationHook(settings) {
618
+ const previous = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks)
619
+ ? settings.hooks
620
+ : null;
621
+ if (!previous)
622
+ return settings;
623
+ const hooks = withoutManagedHookEvents(previous);
624
+ if (Object.keys(hooks).length)
625
+ return { ...settings, hooks };
626
+ const { hooks: _hooks, ...rest } = settings;
627
+ return rest;
628
+ }
629
+ export function removeRepositoryObservationFiles(settingsPath) {
630
+ removeOwnedHookFiles(path.dirname(path.resolve(settingsPath)));
631
+ }
@@ -0,0 +1,28 @@
1
+ export type EnvBlock = Record<string, string>;
2
+ export declare function baseEnv(key: string, ingestEndpoint: string): EnvBlock;
3
+ export declare function enrichEnv(): EnvBlock;
4
+ export declare const LEGACY_BROKEN_RESOURCE_ATTRS = "project.path=$PWD";
5
+ export declare function buildEnvBlock(key: string, ingestEndpoint: string, enrich: boolean): EnvBlock;
6
+ type SettingsObject = Record<string, unknown>;
7
+ export declare function mergeSettings(existing: SettingsObject, env: EnvBlock): SettingsObject;
8
+ export type WriteResult = {
9
+ path: string;
10
+ wrote: string[];
11
+ backupPath: string | null;
12
+ created: boolean;
13
+ };
14
+ export type WriteOptions = {
15
+ key: string;
16
+ ingestEndpoint: string;
17
+ settingsPath: string;
18
+ enrich?: boolean;
19
+ repositoryObservation?: boolean;
20
+ backup?: boolean;
21
+ };
22
+ export declare function writeSettings(opts: WriteOptions): WriteResult;
23
+ export declare function readSettingsSummary(filePath: string): {
24
+ exists: boolean;
25
+ otelKeys: string[];
26
+ hasKey: boolean;
27
+ };
28
+ export {};