@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.
- package/LICENSE +21 -0
- package/README.md +26 -0
- package/dist/bin/connect.d.ts +2 -0
- package/dist/bin/connect.js +6 -0
- package/dist/src/cli.d.ts +10 -0
- package/dist/src/cli.js +172 -0
- package/dist/src/codexConfig.d.ts +23 -0
- package/dist/src/codexConfig.js +265 -0
- package/dist/src/config.d.ts +26 -0
- package/dist/src/config.js +32 -0
- package/dist/src/connectivity.d.ts +27 -0
- package/dist/src/connectivity.js +54 -0
- package/dist/src/deviceClient.d.ts +32 -0
- package/dist/src/deviceClient.js +112 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +8 -0
- package/dist/src/keys.d.ts +5 -0
- package/dist/src/keys.js +24 -0
- package/dist/src/onboard.d.ts +47 -0
- package/dist/src/onboard.js +84 -0
- package/dist/src/otlp.d.ts +18 -0
- package/dist/src/otlp.js +60 -0
- package/dist/src/repositoryHook.d.ts +17 -0
- package/dist/src/repositoryHook.js +631 -0
- package/dist/src/settings.d.ts +28 -0
- package/dist/src/settings.js +97 -0
- package/package.json +25 -0
|
@@ -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 {};
|