@brunobracaioli/aibrain 0.2.0-beta.1 → 0.3.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.mjs CHANGED
@@ -3,6 +3,9 @@ import { createHash, randomUUID } from 'node:crypto';
3
3
  const MAX_BODY = 256 * 1024;
4
4
  const MAX_SESSION_ID = 256;
5
5
  const TIMEOUT_MS = 10_000;
6
+ const MAX_BUDGET_SNAPSHOT_AGE_MS = 15_000;
7
+ const MAX_RETRY_AFTER_MS = 3_600_000;
8
+ const SAFE_IDENTIFIER = /^[A-Za-z0-9._:-]{1,128}$/u;
6
9
  const MAX_ENDPOINT_LENGTH = 2_048;
7
10
  const SUPPORTED_PROTOCOL_VERSIONS = new Set([
8
11
  '2024-11-05',
@@ -147,11 +150,12 @@ async function responseObject(response) {
147
150
  * @param {JsonObject} body
148
151
  * @param {string | undefined} sessionId
149
152
  * @param {string | undefined} protocolVersion
153
+ * @param {number} [timeoutMs]
150
154
  * @returns {Promise<CallResult>}
151
155
  */
152
- async function call(url, token, body, sessionId, protocolVersion) {
156
+ async function call(url, token, body, sessionId, protocolVersion, timeoutMs = TIMEOUT_MS) {
153
157
  const controller = new AbortController();
154
- const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
158
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
155
159
  const expectedId = Object.hasOwn(body, 'id') ? body.id : undefined;
156
160
  try {
157
161
  const response = await fetch(url, {
@@ -216,6 +220,324 @@ async function call(url, token, body, sessionId, protocolVersion) {
216
220
  }
217
221
  }
218
222
 
223
+ /**
224
+ * Send one already validated, content-free telemetry report over the existing
225
+ * authenticated MCP transport. The tool name and all protocol fields are
226
+ * fixed here; callers cannot select a server or tool.
227
+ *
228
+ * @param {string} endpoint
229
+ * @param {string} token
230
+ * @param {JsonObject} report
231
+ * @param {number} [deadlineMs]
232
+ * @returns {Promise<{accepted:true, duplicate:boolean}>}
233
+ */
234
+ export async function reportContextTelemetry(endpoint, token, report, deadlineMs = 8_000) {
235
+ if (!Number.isFinite(deadlineMs) || deadlineMs <= 0 || deadlineMs > 8_000)
236
+ throw new Error('mcp_deadline_invalid');
237
+ const url = endpointUrl(endpoint).toString();
238
+ if (typeof token !== 'string' || token.trim() === '') throw new Error('mcp_auth_denied');
239
+ const deadline = Date.now() + deadlineMs;
240
+ const remaining = () => {
241
+ const value = deadline - Date.now();
242
+ if (value <= 0) throw new Error('mcp_request_timeout');
243
+ return Math.min(TIMEOUT_MS, value);
244
+ };
245
+ const initialized = await call(
246
+ url,
247
+ token,
248
+ {
249
+ jsonrpc: '2.0',
250
+ id: randomUUID(),
251
+ method: 'initialize',
252
+ params: {
253
+ protocolVersion: '2025-03-26',
254
+ capabilities: {},
255
+ clientInfo: { name: 'aibrain-telemetry-report', version: '0.3.0-beta.9' },
256
+ },
257
+ },
258
+ undefined,
259
+ undefined,
260
+ remaining(),
261
+ );
262
+ const initializeResult = initialized.parsed?.result;
263
+ if (
264
+ !initialized.response.ok ||
265
+ initialized.parsed?.error ||
266
+ !isObject(initializeResult) ||
267
+ typeof initializeResult.protocolVersion !== 'string' ||
268
+ !SUPPORTED_PROTOCOL_VERSIONS.has(initializeResult.protocolVersion)
269
+ )
270
+ throw new Error(responseError(initialized.response, 'mcp_initialize_denied'));
271
+ const protocolVersion = initializeResult.protocolVersion;
272
+ const initializedNotification = await call(
273
+ url,
274
+ token,
275
+ { jsonrpc: '2.0', method: 'notifications/initialized', params: {} },
276
+ initialized.sessionId,
277
+ protocolVersion,
278
+ remaining(),
279
+ );
280
+ if (
281
+ !initializedNotification.response.ok &&
282
+ ![202, 204].includes(initializedNotification.response.status)
283
+ )
284
+ throw new Error(responseError(initializedNotification.response, 'mcp_initialize_denied'));
285
+ const reportResponse = await call(
286
+ url,
287
+ token,
288
+ {
289
+ jsonrpc: '2.0',
290
+ id: randomUUID(),
291
+ method: 'tools/call',
292
+ params: { name: 'brain_context_report', arguments: report },
293
+ },
294
+ initialized.sessionId,
295
+ protocolVersion,
296
+ remaining(),
297
+ );
298
+ const result = reportResponse.parsed?.result;
299
+ if (!reportResponse.response.ok || reportResponse.parsed?.error || !isObject(result))
300
+ throw new Error(responseError(reportResponse.response, 'mcp_report_rejected'));
301
+ if (result.isError === true)
302
+ throw new Error(structuredErrorCode(result) ?? 'mcp_report_rejected');
303
+ const structured = isObject(result.structuredContent) ? result.structuredContent : result;
304
+ if (
305
+ !isObject(structured) ||
306
+ structured.accepted !== true ||
307
+ typeof structured.duplicate !== 'boolean'
308
+ )
309
+ throw new Error('mcp_report_rejected');
310
+ return { accepted: true, duplicate: structured.duplicate };
311
+ }
312
+
313
+ /**
314
+ * Ask the fixed v2 selection tool for a shadow decision. The caller supplies
315
+ * only the already-redacted projection; this function owns the destination,
316
+ * MCP lifecycle and response binding.
317
+ * @param {string} endpoint
318
+ * @param {string} token
319
+ * @param {JsonObject} request
320
+ * @param {number} [deadlineMs]
321
+ * @returns {Promise<JsonObject>}
322
+ */
323
+ export async function selectContextShadow(endpoint, token, request, deadlineMs = 15_000) {
324
+ if (!Number.isFinite(deadlineMs) || deadlineMs <= 0 || deadlineMs > 15_000)
325
+ throw new Error('mcp_deadline_invalid');
326
+ const url = endpointUrl(endpoint).toString();
327
+ if (typeof token !== 'string' || token.trim() === '') throw new Error('mcp_auth_denied');
328
+ const projection = isObject(request.projection) ? request.projection : null;
329
+ const options = isObject(request.options) ? request.options : null;
330
+ if (projection === null || options === null) throw new Error('mcp_shadow_request_invalid');
331
+ const deadline = Date.now() + deadlineMs;
332
+ const remaining = () => {
333
+ const value = deadline - Date.now();
334
+ if (value <= 0) throw new Error('mcp_request_timeout');
335
+ return Math.min(TIMEOUT_MS, value);
336
+ };
337
+ const initialized = await call(
338
+ url,
339
+ token,
340
+ {
341
+ jsonrpc: '2.0',
342
+ id: randomUUID(),
343
+ method: 'initialize',
344
+ params: {
345
+ protocolVersion: '2025-03-26',
346
+ capabilities: {},
347
+ clientInfo: { name: 'aibrain-claude-shadow', version: '0.3.0-beta.9' },
348
+ },
349
+ },
350
+ undefined,
351
+ undefined,
352
+ remaining(),
353
+ );
354
+ const initializeResult = initialized.parsed?.result;
355
+ if (
356
+ !initialized.response.ok ||
357
+ initialized.parsed?.error ||
358
+ !isObject(initializeResult) ||
359
+ typeof initializeResult.protocolVersion !== 'string' ||
360
+ !SUPPORTED_PROTOCOL_VERSIONS.has(initializeResult.protocolVersion)
361
+ )
362
+ throw new Error(responseError(initialized.response, 'mcp_initialize_denied'));
363
+ const protocolVersion = initializeResult.protocolVersion;
364
+ const initializedNotification = await call(
365
+ url,
366
+ token,
367
+ { jsonrpc: '2.0', method: 'notifications/initialized', params: {} },
368
+ initialized.sessionId,
369
+ protocolVersion,
370
+ remaining(),
371
+ );
372
+ if (
373
+ !initializedNotification.response.ok &&
374
+ ![202, 204].includes(initializedNotification.response.status)
375
+ )
376
+ throw new Error(responseError(initializedNotification.response, 'mcp_initialize_denied'));
377
+ const response = await call(
378
+ url,
379
+ token,
380
+ {
381
+ jsonrpc: '2.0',
382
+ id: randomUUID(),
383
+ method: 'tools/call',
384
+ params: { name: 'brain_context_select', arguments: request },
385
+ },
386
+ initialized.sessionId,
387
+ protocolVersion,
388
+ remaining(),
389
+ );
390
+ const result = response.parsed?.result;
391
+ if (!response.response.ok || response.parsed?.error || !isObject(result))
392
+ throw new Error(responseError(response.response, 'mcp_shadow_rejected'));
393
+ if (result.isError === true)
394
+ throw new Error(structuredErrorCode(result) ?? 'mcp_shadow_rejected');
395
+ const structured = isObject(result.structuredContent) ? result.structuredContent : result;
396
+ if (!isObject(structured)) throw new Error('mcp_shadow_rejected');
397
+ return structured;
398
+ }
399
+
400
+ /**
401
+ * Read the provider-free shared capacity snapshot. The snapshot is advisory;
402
+ * atomic admission remains authoritative and this helper never retries or
403
+ * reserves capacity.
404
+ * @param {string} endpoint
405
+ * @param {string} token
406
+ * @param {number} [deadlineMs]
407
+ * @returns {Promise<JsonObject>}
408
+ */
409
+ export async function readContextBudget(endpoint, token, deadlineMs = 8_000) {
410
+ if (!Number.isFinite(deadlineMs) || deadlineMs <= 0 || deadlineMs > 8_000)
411
+ throw new Error('mcp_deadline_invalid');
412
+ const url = endpointUrl(endpoint).toString();
413
+ if (typeof token !== 'string' || token.trim() === '') throw new Error('mcp_auth_denied');
414
+ const fetchedAt = Date.now();
415
+ const deadline = Date.now() + deadlineMs;
416
+ const remaining = () => {
417
+ const value = deadline - Date.now();
418
+ if (value <= 0) throw new Error('mcp_request_timeout');
419
+ return Math.min(TIMEOUT_MS, value);
420
+ };
421
+ const initialized = await call(
422
+ url,
423
+ token,
424
+ {
425
+ jsonrpc: '2.0',
426
+ id: randomUUID(),
427
+ method: 'initialize',
428
+ params: {
429
+ protocolVersion: '2025-03-26',
430
+ capabilities: {},
431
+ clientInfo: { name: 'aibrain-context-budget', version: '0.3.0-beta.9' },
432
+ },
433
+ },
434
+ undefined,
435
+ undefined,
436
+ remaining(),
437
+ );
438
+ const initializeResult = initialized.parsed?.result;
439
+ if (
440
+ !initialized.response.ok ||
441
+ initialized.parsed?.error ||
442
+ !isObject(initializeResult) ||
443
+ typeof initializeResult.protocolVersion !== 'string' ||
444
+ !SUPPORTED_PROTOCOL_VERSIONS.has(initializeResult.protocolVersion)
445
+ )
446
+ throw new Error(responseError(initialized.response, 'mcp_initialize_denied'));
447
+ const protocolVersion = initializeResult.protocolVersion;
448
+ const initializedNotification = await call(
449
+ url,
450
+ token,
451
+ { jsonrpc: '2.0', method: 'notifications/initialized', params: {} },
452
+ initialized.sessionId,
453
+ protocolVersion,
454
+ remaining(),
455
+ );
456
+ if (
457
+ !initializedNotification.response.ok &&
458
+ ![202, 204].includes(initializedNotification.response.status)
459
+ )
460
+ throw new Error(responseError(initializedNotification.response, 'mcp_initialize_denied'));
461
+ const response = await call(
462
+ url,
463
+ token,
464
+ {
465
+ jsonrpc: '2.0',
466
+ id: randomUUID(),
467
+ method: 'tools/call',
468
+ params: { name: 'brain_context_budget', arguments: { schema_version: 1 } },
469
+ },
470
+ initialized.sessionId,
471
+ protocolVersion,
472
+ remaining(),
473
+ );
474
+ const result = response.parsed?.result;
475
+ if (!response.response.ok || response.parsed?.error || !isObject(result))
476
+ throw new Error(responseError(response.response, 'mcp_budget_unavailable'));
477
+ if (result.isError === true) throw new Error(structuredErrorCode(result) ?? 'mcp_budget_denied');
478
+ const structured = isObject(result.structuredContent) ? result.structuredContent : result;
479
+ if (!isObject(structured)) throw new Error('mcp_budget_unavailable');
480
+ const numericFields = [
481
+ structured.available_requests,
482
+ structured.available_tokens,
483
+ structured.output_token_allowance,
484
+ structured.max_batches_per_operation,
485
+ structured.retry_after_ms,
486
+ structured.provider_batches,
487
+ ];
488
+ const numericFieldsValid = numericFields.every(
489
+ (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0,
490
+ );
491
+ const maxBatches = structured.max_batches_per_operation;
492
+ const retryAfter = structured.retry_after_ms;
493
+ const serverTime = Date.parse(String(structured.server_time));
494
+ const expiresAt = Date.parse(String(structured.expires_at));
495
+ const windowEnd = Date.parse(String(structured.window_end));
496
+ const localElapsed = Date.now() - fetchedAt;
497
+ const allowed = new Set([
498
+ 'schema_version',
499
+ 'server_time',
500
+ 'expires_at',
501
+ 'window_end',
502
+ 'available_requests',
503
+ 'available_tokens',
504
+ 'output_token_allowance',
505
+ 'max_batches_per_operation',
506
+ 'account_in_flight',
507
+ 'retry_after_ms',
508
+ 'provider_batches',
509
+ 'provider_model',
510
+ 'correlation_id',
511
+ ]);
512
+ if (
513
+ Object.keys(structured).some((key) => !allowed.has(key)) ||
514
+ structured.schema_version !== 1 ||
515
+ !validTimestamp(structured.server_time) ||
516
+ !validTimestamp(structured.expires_at) ||
517
+ !validTimestamp(structured.window_end) ||
518
+ !Number.isFinite(serverTime) ||
519
+ !Number.isFinite(expiresAt) ||
520
+ !Number.isFinite(windowEnd) ||
521
+ localElapsed < 0 ||
522
+ localElapsed > MAX_BUDGET_SNAPSHOT_AGE_MS ||
523
+ expiresAt <= serverTime ||
524
+ expiresAt - serverTime > MAX_BUDGET_SNAPSHOT_AGE_MS ||
525
+ expiresAt > windowEnd ||
526
+ !numericFieldsValid ||
527
+ typeof maxBatches !== 'number' ||
528
+ maxBatches > 4 ||
529
+ typeof retryAfter !== 'number' ||
530
+ retryAfter > MAX_RETRY_AFTER_MS ||
531
+ typeof structured.account_in_flight !== 'boolean' ||
532
+ (structured.provider_model !== null &&
533
+ (typeof structured.provider_model !== 'string' || structured.provider_model.length > 128)) ||
534
+ typeof structured.correlation_id !== 'string' ||
535
+ !SAFE_IDENTIFIER.test(structured.correlation_id)
536
+ )
537
+ throw new Error('mcp_budget_response_invalid');
538
+ return structured;
539
+ }
540
+
219
541
  /** @param {unknown} value @returns {boolean} */
220
542
  function validTimestamp(value) {
221
543
  return typeof value === 'string' && Number.isFinite(Date.parse(value));
@@ -256,7 +578,7 @@ async function providerFreeProbeV1(endpoint, token) {
256
578
  params: {
257
579
  protocolVersion: '2025-03-26',
258
580
  capabilities: {},
259
- clientInfo: { name: 'aibrain', version: '0.2.0-beta.1' },
581
+ clientInfo: { name: 'aibrain', version: '0.3.0-beta.9' },
260
582
  },
261
583
  },
262
584
  undefined,
@@ -310,7 +632,7 @@ async function providerFreeProbeV1(endpoint, token) {
310
632
  arguments: {
311
633
  schema_version: 1,
312
634
  client: 'codex_app_server',
313
- client_version: '0.2.0-beta.1',
635
+ client_version: '0.3.0-beta.9',
314
636
  session_ref: sessionRef,
315
637
  revision,
316
638
  observed_at: new Date().toISOString(),
@@ -402,7 +724,7 @@ export async function providerFreeProbeV2(endpoint, token, policy, clients) {
402
724
  params: {
403
725
  protocolVersion: '2025-03-26',
404
726
  capabilities: {},
405
- clientInfo: { name: 'aibrain', version: '0.2.0-beta.1' },
727
+ clientInfo: { name: 'aibrain', version: '0.3.0-beta.9' },
406
728
  },
407
729
  },
408
730
  undefined,
@@ -606,7 +928,7 @@ export async function providerFreeProbeV2(endpoint, token, policy, clients) {
606
928
  await callTool(2, 'brain_context_select', {
607
929
  schema_version: 2,
608
930
  client: clients === 'claude' ? 'claude_code' : 'codex_app_server',
609
- client_version: '0.2.0-beta.1',
931
+ client_version: '0.3.0-beta.9',
610
932
  session_ref: sessionRef,
611
933
  revision,
612
934
  observed_at: now,
@@ -619,7 +941,7 @@ export async function providerFreeProbeV2(endpoint, token, policy, clients) {
619
941
  await callTool(3, 'brain_context_compact', {
620
942
  schema_version: 2,
621
943
  client: 'claude_code',
622
- client_version: '0.2.0-beta.1',
944
+ client_version: '0.3.0-beta.9',
623
945
  session_ref: sessionRef,
624
946
  revision,
625
947
  observed_at: now,
package/dist/monitor.mjs CHANGED
@@ -6,6 +6,12 @@ import { constants } from 'node:fs';
6
6
  import { spawn } from 'node:child_process';
7
7
  import { dirname, join } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
+ import {
10
+ CODEX_APP_SERVER_PROFILE,
11
+ CODEX_LAUNCH_ENVELOPE,
12
+ ownedAppServerConfigArgs,
13
+ parseCodexLaunchEnvelope,
14
+ } from './codex-permissions.mjs';
9
15
 
10
16
  const root = dirname(fileURLToPath(import.meta.url));
11
17
  const endpoint = process.env.AIBRAIN_MCP_URL?.trim();
@@ -25,6 +31,7 @@ if (parsed.username || parsed.password || parsed.hash)
25
31
  if (parsed.search) throw new Error('aibrain_monitor_endpoint_query');
26
32
  const canonical = join(root, 'assets', 'codex-context-compaction.mjs');
27
33
  await access(canonical, constants.R_OK);
34
+ const launchEnvelope = parseCodexLaunchEnvelope(process.env[CODEX_LAUNCH_ENVELOPE]);
28
35
  const policyText = process.env.CODEX_CONTEXT_POLICY_JSON;
29
36
  let policy;
30
37
  if (policyText === undefined) {
@@ -80,6 +87,9 @@ const env = {
80
87
  CODEX_RECOVERY_MCP_TOKEN: token,
81
88
  CODEX_CONTEXT_COMPACT_AT: String(policy?.compactAtPercent ?? 85),
82
89
  };
90
+ delete env[CODEX_LAUNCH_ENVELOPE];
91
+ delete env[CODEX_APP_SERVER_PROFILE];
92
+ if (launchEnvelope?.profile !== undefined) env[CODEX_APP_SERVER_PROFILE] = launchEnvelope.profile;
83
93
  if (policy !== null) env.CODEX_CONTEXT_POLICY_JSON = JSON.stringify(policy);
84
94
  else delete env.CODEX_CONTEXT_POLICY_JSON;
85
95
  delete env.JEV_API_KEY;
@@ -98,6 +108,7 @@ const child = spawn(
98
108
  `mcp_servers.aibrain_context.url=${parsed.toString()}`,
99
109
  '-c',
100
110
  'mcp_servers.aibrain_context.bearer_token_env_var="CODEX_RECOVERY_MCP_TOKEN"',
111
+ ...(launchEnvelope === null ? [] : ownedAppServerConfigArgs(launchEnvelope)),
101
112
  ],
102
113
  { cwd: process.cwd(), env, stdio: 'inherit' },
103
114
  );
package/dist/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@brunobracaioli/aibrain",
3
- "version": "0.2.0-beta.1",
3
+ "version": "0.3.0-beta.13",
4
4
  "type": "module"
5
5
  }
package/dist/platform.mjs CHANGED
@@ -70,13 +70,43 @@ export function nativeVersion(executable, environment = process.env) {
70
70
  export function isTestedNativeVersion(client, version) {
71
71
  const value = version.trim();
72
72
  if (client === 'claude')
73
- return /^(?:(?:claude(?:\s+code)?)\s+)?2\.1\.(?:294|295|296)(?:\s+\(Claude\s+Code\))?$/iu.test(
73
+ return /^(?:(?:claude(?:\s+code)?)\s+)?2\.1\.(?:294|295|296|299|300|301|302)(?:\s+\(Claude\s+Code\))?$/iu.test(
74
74
  value,
75
75
  );
76
- return /^(?:codex(?:-cli)?\s+)?0\.16(?:1\.0|2\.0|2\.1)$/iu.test(value);
76
+ return /^(?:codex(?:-cli)?\s+)?0\.16(?:1\.0|2\.0|2\.1|3\.0)$/iu.test(value);
77
+ }
78
+
79
+ const TESTED_NATIVE_VERSIONS = Object.freeze({
80
+ claude: Object.freeze([
81
+ '2.1.294',
82
+ '2.1.295',
83
+ '2.1.296',
84
+ '2.1.299',
85
+ '2.1.300',
86
+ '2.1.301',
87
+ '2.1.302',
88
+ ]),
89
+ codex: Object.freeze(['0.161.0', '0.162.0', '0.162.1', '0.163.0']),
90
+ });
91
+
92
+ /** @param {'claude'|'codex'} client @param {string} version */
93
+ export function nativeVersionDiagnostic(client, version) {
94
+ const detected = version.trim();
95
+ const versionPattern =
96
+ client === 'claude'
97
+ ? /^(?:(?:claude(?:\s+code)?)\s+)?(\d{1,3})\.(\d{1,3})\.(\d{1,3})(-[A-Za-z0-9.-]+)?(?:\s+\(Claude\s+Code\))?$/iu
98
+ : /^(?:codex(?:-cli)?\s+)?(\d{1,3})\.(\d{1,3})\.(\d{1,3})(-[A-Za-z0-9.-]+)?$/iu;
99
+ const versionMatch = detected.match(versionPattern);
100
+ const safeDetected = versionMatch
101
+ ? `${versionMatch[1]}.${versionMatch[2]}.${versionMatch[3]}${versionMatch[4] ? '-prerelease' : ''}`
102
+ : 'unavailable';
103
+ const label = client === 'claude' ? 'Claude Code' : 'Codex CLI';
104
+ const tested = TESTED_NATIVE_VERSIONS[client].join(', ');
105
+ return `${client}_version_unverified: ${label} detected version ${safeDetected}; tested stable versions: ${tested}. Update AI Brain to a compatible version, then run aibrain doctor --json or install a qualified stable native version.`;
77
106
  }
78
107
 
79
108
  /** @param {'claude'|'codex'} client @param {string} version */
80
109
  export function assertTestedNativeVersion(client, version) {
81
- if (!isTestedNativeVersion(client, version)) throw new Error(`${client}_version_unverified`);
110
+ if (!isTestedNativeVersion(client, version))
111
+ throw new Error(nativeVersionDiagnostic(client, version));
82
112
  }
package/dist/state.mjs CHANGED
@@ -30,7 +30,9 @@ import { isValidPolicy } from './policy.mjs';
30
30
  * token: string,
31
31
  * tokenSha256: string,
32
32
  * probe: Probe,
33
- * context?: import('./policy.mjs').ContextPolicy
33
+ * context?: import('./policy.mjs').ContextPolicy,
34
+ * telemetry?: { enabled: boolean, disclosedAt: string | null },
35
+ * claudeObservation?: { shadowEnabled: boolean, diagnosticsEnabled: boolean, disclosedAt: string | null }
34
36
  * }} Config
35
37
  */
36
38
 
@@ -125,6 +127,35 @@ function isDisclosure(value) {
125
127
  return value === DISCLOSURE_VERSION || value === LEGACY_DISCLOSURE_VERSION;
126
128
  }
127
129
 
130
+ /** @param {unknown} value */
131
+ function isTelemetry(value) {
132
+ return (
133
+ isRecord(value) &&
134
+ typeof value.enabled === 'boolean' &&
135
+ (value.disclosedAt === null ||
136
+ (typeof value.disclosedAt === 'string' &&
137
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(value.disclosedAt) &&
138
+ Number.isFinite(Date.parse(value.disclosedAt)))) &&
139
+ Object.keys(value).every((key) => ['enabled', 'disclosedAt'].includes(key))
140
+ );
141
+ }
142
+
143
+ /** @param {unknown} value */
144
+ function isClaudeObservation(value) {
145
+ return (
146
+ isRecord(value) &&
147
+ typeof value.shadowEnabled === 'boolean' &&
148
+ typeof value.diagnosticsEnabled === 'boolean' &&
149
+ (value.disclosedAt === null ||
150
+ (typeof value.disclosedAt === 'string' &&
151
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(value.disclosedAt) &&
152
+ Number.isFinite(Date.parse(value.disclosedAt)))) &&
153
+ Object.keys(value).every((key) =>
154
+ ['shadowEnabled', 'diagnosticsEnabled', 'disclosedAt'].includes(key),
155
+ )
156
+ );
157
+ }
158
+
128
159
  /** @param {unknown} value */
129
160
  function isConfig(value) {
130
161
  if (!isRecord(value)) return false;
@@ -166,6 +197,9 @@ function isConfig(value) {
166
197
  )
167
198
  return false;
168
199
  if (value.schemaVersion === 1 && value.context !== undefined) return false;
200
+ if (value.telemetry !== undefined && !isTelemetry(value.telemetry)) return false;
201
+ if (value.claudeObservation !== undefined && !isClaudeObservation(value.claudeObservation))
202
+ return false;
169
203
  const allowed = new Set([
170
204
  'schemaVersion',
171
205
  'packageVersion',
@@ -180,6 +214,8 @@ function isConfig(value) {
180
214
  'tokenSha256',
181
215
  'probe',
182
216
  'context',
217
+ 'telemetry',
218
+ 'claudeObservation',
183
219
  ]);
184
220
  return Object.keys(value).every((key) => allowed.has(key));
185
221
  }
@@ -189,9 +225,9 @@ export function isValidConfig(value) {
189
225
  return isConfig(value);
190
226
  }
191
227
 
192
- /** @returns {Promise<Config | null>} */
193
- export async function readConfig() {
194
- const root = stateRoot();
228
+ /** @param {string | undefined} [rootOverride] @returns {Promise<Config | null>} */
229
+ export async function readConfig(rootOverride) {
230
+ const root = rootOverride === undefined ? stateRoot() : resolve(rootOverride);
195
231
  await assertSafePath(root);
196
232
  const path = join(root, 'config.json');
197
233
  try {
@@ -254,6 +290,73 @@ export async function tokenDigest(token) {
254
290
  return createHash('sha256').update(token, 'utf8').digest('hex');
255
291
  }
256
292
 
293
+ /** @param {{endpoint: string, token: string, telemetry?: {enabled: boolean, disclosedAt?: string | null}}} config */
294
+ export function telemetryConfigBinding(config) {
295
+ if (
296
+ !isRecord(config) ||
297
+ typeof config.endpoint !== 'string' ||
298
+ typeof config.token !== 'string' ||
299
+ !isRecord(config.telemetry) ||
300
+ config.telemetry.enabled !== true ||
301
+ typeof config.telemetry.disclosedAt !== 'string'
302
+ )
303
+ throw new Error('telemetry_state_mismatch');
304
+ let endpoint;
305
+ try {
306
+ endpoint = new URL(config.endpoint).toString();
307
+ } catch {
308
+ throw new Error('telemetry_state_mismatch');
309
+ }
310
+ return createHash('sha256')
311
+ .update(`${endpoint}\u0000${config.token}\u0000${config.telemetry.disclosedAt}`, 'utf8')
312
+ .digest('hex');
313
+ }
314
+
315
+ /** @param {{endpoint: string, token: string, claudeObservation?: {shadowEnabled?: boolean, diagnosticsEnabled?: boolean, disclosedAt?: string | null}}} config */
316
+ export function claudeObservationConfigBinding(config) {
317
+ if (
318
+ !isRecord(config) ||
319
+ typeof config.endpoint !== 'string' ||
320
+ typeof config.token !== 'string' ||
321
+ !isRecord(config.claudeObservation) ||
322
+ typeof config.claudeObservation.disclosedAt !== 'string'
323
+ )
324
+ throw new Error('claude_observation_state_mismatch');
325
+ let endpoint;
326
+ try {
327
+ endpoint = new URL(config.endpoint).toString();
328
+ } catch {
329
+ throw new Error('claude_observation_state_mismatch');
330
+ }
331
+ return createHash('sha256')
332
+ .update(`${endpoint}\u0000${config.token}\u0000${config.claudeObservation.disclosedAt}`, 'utf8')
333
+ .digest('hex');
334
+ }
335
+
336
+ /**
337
+ * Bind launcher-managed Claude checkpoints to the authenticated account and
338
+ * disclosure, independently of shadow/diagnostics opt-ins.
339
+ * @param {{endpoint: string, token: string, disclosedAt?: string}} config
340
+ */
341
+ export function claudeCheckpointConfigBinding(config) {
342
+ if (
343
+ !isRecord(config) ||
344
+ typeof config.endpoint !== 'string' ||
345
+ typeof config.token !== 'string' ||
346
+ typeof config.disclosedAt !== 'string'
347
+ )
348
+ throw new Error('claude_checkpoint_state_mismatch');
349
+ let endpoint;
350
+ try {
351
+ endpoint = new URL(config.endpoint).toString();
352
+ } catch {
353
+ throw new Error('claude_checkpoint_state_mismatch');
354
+ }
355
+ return createHash('sha256')
356
+ .update(`${endpoint}\u0000${config.token}\u0000${config.disclosedAt}`, 'utf8')
357
+ .digest('hex');
358
+ }
359
+
257
360
  /** @param {string} path */
258
361
  export async function readPrivateToken(path) {
259
362
  const resolved = resolve(path);