@mnemom/mnemom 0.17.37 → 0.17.38-next.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.
Files changed (114) hide show
  1. package/dist/commands/agent-run.d.ts +11 -0
  2. package/dist/commands/agent-run.js +37 -0
  3. package/dist/commands/agent-share-render.d.ts +5 -5
  4. package/dist/commands/agent-share-render.js +9 -7
  5. package/dist/commands/agent-share-test-issuer.d.ts +1 -1
  6. package/dist/commands/agent-share-test-issuer.js +1 -1
  7. package/dist/commands/agent.d.ts +8 -1
  8. package/dist/commands/agent.js +14 -3
  9. package/dist/commands/scan.d.ts +15 -2
  10. package/dist/commands/scan.js +48 -3
  11. package/dist/index.js +1 -1
  12. package/dist/lib/commit-report.d.ts +7 -13
  13. package/dist/lib/commit-report.js +12 -5
  14. package/dist/lib/conduct-receipt.d.ts +8 -3
  15. package/dist/lib/conduct-receipt.js +29 -7
  16. package/dist/lib/experimental.d.ts +1 -1
  17. package/dist/lib/experimental.js +1 -12
  18. package/dist/lib/runner/executor-client.d.ts +5 -0
  19. package/dist/lib/runner/executor-client.js +8 -0
  20. package/dist/lib/runner/job.d.ts +37 -0
  21. package/dist/lib/runner/job.js +74 -1
  22. package/dist/lib/runner/phases.d.ts +7 -2
  23. package/dist/lib/runner/phases.js +7 -2
  24. package/dist/lib/runner/proxy.d.ts +26 -2
  25. package/dist/lib/runner/proxy.js +33 -1
  26. package/dist/lib/runner/record-commit.d.ts +16 -0
  27. package/dist/lib/runner/record-commit.js +7 -0
  28. package/dist/lib/scan/spend/anthropic-usage.d.ts +20 -1
  29. package/dist/lib/scan/spend/anthropic-usage.js +32 -7
  30. package/dist/lib/scan/spend/anthropic-wif.d.ts +42 -0
  31. package/dist/lib/scan/spend/anthropic-wif.js +153 -0
  32. package/package.json +2 -5
  33. package/node_modules/@mnemom/aip-verifier/LICENSE +0 -190
  34. package/node_modules/@mnemom/aip-verifier/dist/claim-lint-CVITIRcU.d.cts +0 -478
  35. package/node_modules/@mnemom/aip-verifier/dist/claim-lint-CVITIRcU.d.ts +0 -478
  36. package/node_modules/@mnemom/aip-verifier/dist/index.cjs +0 -971
  37. package/node_modules/@mnemom/aip-verifier/dist/index.cjs.map +0 -1
  38. package/node_modules/@mnemom/aip-verifier/dist/index.d.cts +0 -330
  39. package/node_modules/@mnemom/aip-verifier/dist/index.d.ts +0 -330
  40. package/node_modules/@mnemom/aip-verifier/dist/index.js +0 -933
  41. package/node_modules/@mnemom/aip-verifier/dist/index.js.map +0 -1
  42. package/node_modules/@mnemom/aip-verifier/dist/render-lines.cjs +0 -879
  43. package/node_modules/@mnemom/aip-verifier/dist/render-lines.cjs.map +0 -1
  44. package/node_modules/@mnemom/aip-verifier/dist/render-lines.d.cts +0 -96
  45. package/node_modules/@mnemom/aip-verifier/dist/render-lines.d.ts +0 -96
  46. package/node_modules/@mnemom/aip-verifier/dist/render-lines.js +0 -875
  47. package/node_modules/@mnemom/aip-verifier/dist/render-lines.js.map +0 -1
  48. package/node_modules/@mnemom/aip-verifier/package.json +0 -86
  49. package/node_modules/@noble/ed25519/LICENSE +0 -21
  50. package/node_modules/@noble/ed25519/README.md +0 -371
  51. package/node_modules/@noble/ed25519/index.d.ts +0 -391
  52. package/node_modules/@noble/ed25519/index.js +0 -933
  53. package/node_modules/@noble/ed25519/index.ts +0 -1134
  54. package/node_modules/@noble/ed25519/package.json +0 -54
  55. package/node_modules/@noble/hashes/LICENSE +0 -21
  56. package/node_modules/@noble/hashes/README.md +0 -478
  57. package/node_modules/@noble/hashes/_blake.d.ts +0 -20
  58. package/node_modules/@noble/hashes/_blake.js +0 -59
  59. package/node_modules/@noble/hashes/_md.d.ts +0 -95
  60. package/node_modules/@noble/hashes/_md.js +0 -209
  61. package/node_modules/@noble/hashes/_u64.d.ts +0 -36
  62. package/node_modules/@noble/hashes/_u64.js +0 -77
  63. package/node_modules/@noble/hashes/argon2.d.ts +0 -148
  64. package/node_modules/@noble/hashes/argon2.js +0 -577
  65. package/node_modules/@noble/hashes/blake1.d.ts +0 -162
  66. package/node_modules/@noble/hashes/blake1.js +0 -630
  67. package/node_modules/@noble/hashes/blake2.d.ts +0 -203
  68. package/node_modules/@noble/hashes/blake2.js +0 -573
  69. package/node_modules/@noble/hashes/blake3.d.ts +0 -68
  70. package/node_modules/@noble/hashes/blake3.js +0 -310
  71. package/node_modules/@noble/hashes/eskdf.d.ts +0 -91
  72. package/node_modules/@noble/hashes/eskdf.js +0 -225
  73. package/node_modules/@noble/hashes/hkdf.d.ts +0 -70
  74. package/node_modules/@noble/hashes/hkdf.js +0 -152
  75. package/node_modules/@noble/hashes/hmac.d.ts +0 -50
  76. package/node_modules/@noble/hashes/hmac.js +0 -93
  77. package/node_modules/@noble/hashes/index.d.ts +0 -1
  78. package/node_modules/@noble/hashes/index.js +0 -34
  79. package/node_modules/@noble/hashes/legacy.d.ts +0 -107
  80. package/node_modules/@noble/hashes/legacy.js +0 -348
  81. package/node_modules/@noble/hashes/package.json +0 -91
  82. package/node_modules/@noble/hashes/pbkdf2.d.ts +0 -66
  83. package/node_modules/@noble/hashes/pbkdf2.js +0 -176
  84. package/node_modules/@noble/hashes/scrypt.d.ts +0 -98
  85. package/node_modules/@noble/hashes/scrypt.js +0 -304
  86. package/node_modules/@noble/hashes/sha2.d.ts +0 -176
  87. package/node_modules/@noble/hashes/sha2.js +0 -446
  88. package/node_modules/@noble/hashes/sha3-addons.d.ts +0 -533
  89. package/node_modules/@noble/hashes/sha3-addons.js +0 -973
  90. package/node_modules/@noble/hashes/sha3.d.ts +0 -208
  91. package/node_modules/@noble/hashes/sha3.js +0 -475
  92. package/node_modules/@noble/hashes/src/_blake.ts +0 -67
  93. package/node_modules/@noble/hashes/src/_md.ts +0 -227
  94. package/node_modules/@noble/hashes/src/_u64.ts +0 -111
  95. package/node_modules/@noble/hashes/src/argon2.ts +0 -693
  96. package/node_modules/@noble/hashes/src/blake1.ts +0 -677
  97. package/node_modules/@noble/hashes/src/blake2.ts +0 -648
  98. package/node_modules/@noble/hashes/src/blake3.ts +0 -344
  99. package/node_modules/@noble/hashes/src/eskdf.ts +0 -265
  100. package/node_modules/@noble/hashes/src/hkdf.ts +0 -161
  101. package/node_modules/@noble/hashes/src/hmac.ts +0 -130
  102. package/node_modules/@noble/hashes/src/index.ts +0 -33
  103. package/node_modules/@noble/hashes/src/legacy.ts +0 -361
  104. package/node_modules/@noble/hashes/src/pbkdf2.ts +0 -219
  105. package/node_modules/@noble/hashes/src/scrypt.ts +0 -375
  106. package/node_modules/@noble/hashes/src/sha2.ts +0 -486
  107. package/node_modules/@noble/hashes/src/sha3-addons.ts +0 -1157
  108. package/node_modules/@noble/hashes/src/sha3.ts +0 -525
  109. package/node_modules/@noble/hashes/src/utils.ts +0 -978
  110. package/node_modules/@noble/hashes/src/webcrypto.ts +0 -283
  111. package/node_modules/@noble/hashes/utils.d.ts +0 -560
  112. package/node_modules/@noble/hashes/utils.js +0 -687
  113. package/node_modules/@noble/hashes/webcrypto.d.ts +0 -121
  114. package/node_modules/@noble/hashes/webcrypto.js +0 -228
@@ -15,6 +15,7 @@ import { type HttpResult, type RunnerApi } from "./executor-client.js";
15
15
  import { type OutcomeFields, type RefusedReason } from "./outcome.js";
16
16
  import { type PhaseInvocation } from "./phases.js";
17
17
  import { type ModelProxy, type ProxyState } from "./proxy.js";
18
+ import type { RecordCommitBody } from "./record-commit.js";
18
19
  import { type RunnerFailMode } from "./settings.js";
19
20
  /** A claimed job's working copy. */
20
21
  export interface Workspace {
@@ -45,6 +46,13 @@ export interface JobDeps {
45
46
  openPr(repo: string, ws: Workspace, title: string, body: string): Promise<string>;
46
47
  /** Commits on the job branch that the base does not have; 0 means there is nothing to open. */
47
48
  commitsAhead(ws: Workspace): Promise<number>;
49
+ /** The working copy's HEAD sha before a phase runs; null only for a clone with no commit. Throws otherwise. */
50
+ headSha(ws: Workspace): Promise<string | null>;
51
+ /**
52
+ * RecordCommit/v1 bodies for the commits made since `since` (null: since the base), oldest
53
+ * first, under `lineageKey` and the job's repo. A commit that cannot be read is left out.
54
+ */
55
+ commitBodies(ws: Workspace, since: string | null, lineageKey: string, repo: string): Promise<RecordCommitBody[]>;
48
56
  enableAutoMerge(prUrl: string): Promise<void>;
49
57
  cleanup(ws: Workspace): Promise<void>;
50
58
  }
@@ -69,6 +77,35 @@ export declare function authRefusal(r: HttpResult): RefusedReason | null;
69
77
  * outcome. Throws only on a runner bug.
70
78
  */
71
79
  export declare function runJob(sessionId: string, opts: JobOptions, deps: JobDeps): Promise<JobResult>;
80
+ /** What the job reported to Record's commit index; logged once, never part of the outcome. */
81
+ export interface CommitReport {
82
+ reported: number;
83
+ /** Refused by the api (4xx other than 404) or unreachable: left out of the index. */
84
+ dropped: number;
85
+ /** Made in a phase whose model calls left no lineage key, so there was nothing to file under. */
86
+ unlinked: number;
87
+ /** Phases whose starting HEAD could not be read: their commits are never guessed at. */
88
+ skipped: number;
89
+ /** The index answered 404 (Record dark for the org): nothing more is sent for this job. */
90
+ dark: boolean;
91
+ }
92
+ /** Waits before each retry of a 429, 5xx or unreachable report; then the commit is dropped. */
93
+ export declare const COMMIT_RETRY_MS: readonly [1000, 3000];
94
+ /**
95
+ * Report the commits one phase made (ruling 34.05 (a)): each under the lineage key the gateway
96
+ * answered for that phase's conversation, with the lease key, from this process. Never throws and
97
+ * never changes the outcome; the lease key is sent only as the request credential, never logged.
98
+ */
99
+ export declare function reportPhaseCommits(deps: JobDeps, report: CommitReport, p: {
100
+ ws: Workspace;
101
+ /** The HEAD before the phase; null: a clone with no commit; undefined: it could not be read. */
102
+ before: string | null | undefined;
103
+ conversationId: string;
104
+ proxy: Pick<ModelProxy, "lineageFor">;
105
+ orgId: string;
106
+ repo: string;
107
+ leaseKey: string;
108
+ }): Promise<void>;
72
109
  /**
73
110
  * Report a failed job after claim (34.12 (6), (10)); a refusal releases the lease instead. The
74
111
  * fail reason vocabulary is the Durable lead's to rule (A3 accepts only transcript_lost today); a
@@ -20,7 +20,7 @@ import { errorCode, errorDetails } from "./executor-client.js";
20
20
  import { doneOutcome, failedOutcome, phaseReason, refusedOutcome, } from "./outcome.js";
21
21
  import { planPhase } from "./phases.js";
22
22
  import { canonNodeKey, prBody } from "./pr-body.js";
23
- import { requestSummary } from "./proxy.js";
23
+ import { phaseConversationId, requestSummary } from "./proxy.js";
24
24
  import { resolveMergeStrategy } from "./settings.js";
25
25
  const TOLERATED = RUNNER_OPEN_MODE_TOLERATED;
26
26
  const OPEN_MODE_RETRIES = 3;
@@ -189,6 +189,13 @@ export async function runJob(sessionId, opts, deps) {
189
189
  let leaseLost = false;
190
190
  let ws = null;
191
191
  let proxy = null;
192
+ const commits = {
193
+ reported: 0,
194
+ dropped: 0,
195
+ unlinked: 0,
196
+ skipped: 0,
197
+ dark: false,
198
+ };
192
199
  let heartbeat = null;
193
200
  const phaseAbort = new AbortController();
194
201
  const onStop = () => phaseAbort.abort();
@@ -303,10 +310,17 @@ export async function runJob(sessionId, opts, deps) {
303
310
  contractValue: header.value,
304
311
  leaseKey: claim.leaseKey,
305
312
  agentId: job.agent_id,
313
+ conversationId: phaseConversationId(sessionId, claim.epoch, phases[0].order),
306
314
  };
307
315
  proxy = await deps.startProxy(state);
308
316
  let opensPr = false;
309
317
  for (const phase of phases) {
318
+ state.conversationId = phaseConversationId(sessionId, claim.epoch, phase.order);
319
+ // undefined: the HEAD could not be read, so this phase's commits cannot be told apart.
320
+ const before = await deps.headSha(ws).catch((e) => {
321
+ deps.log(`reading HEAD before phase ${phase.order} failed: ${e.message}`);
322
+ return undefined;
323
+ });
310
324
  const planned = planPhase(phase);
311
325
  if (!planned.ok) {
312
326
  deps.log(`phase ${phase.order} ${phase.name}: ${planned.detail}`);
@@ -354,6 +368,15 @@ export async function runJob(sessionId, opts, deps) {
354
368
  }
355
369
  break;
356
370
  }
371
+ await reportPhaseCommits(deps, commits, {
372
+ ws,
373
+ before,
374
+ conversationId: state.conversationId,
375
+ proxy,
376
+ orgId: bundle.org_id,
377
+ repo: job.repo,
378
+ leaseKey: claim.leaseKey,
379
+ });
357
380
  }
358
381
  if (opensPr) {
359
382
  try {
@@ -390,12 +413,62 @@ export async function runJob(sessionId, opts, deps) {
390
413
  await proxy.close();
391
414
  // One line per job that reached its phases: what every model request carried (W3.11).
392
415
  deps.log(`model requests: ${JSON.stringify(requestSummary(proxy.forwarded))}`);
416
+ deps.log(`commits reported: ${JSON.stringify(commits)}`);
393
417
  }
394
418
  // A workspace left behind never changes the outcome; it is logged for the operator.
395
419
  if (ws)
396
420
  await deps.cleanup(ws).catch((e) => deps.log(`cleanup failed: ${e.message}`));
397
421
  }
398
422
  }
423
+ /** Waits before each retry of a 429, 5xx or unreachable report; then the commit is dropped. */
424
+ export const COMMIT_RETRY_MS = [1_000, 3_000];
425
+ /**
426
+ * Report the commits one phase made (ruling 34.05 (a)): each under the lineage key the gateway
427
+ * answered for that phase's conversation, with the lease key, from this process. Never throws and
428
+ * never changes the outcome; the lease key is sent only as the request credential, never logged.
429
+ */
430
+ export async function reportPhaseCommits(deps, report, p) {
431
+ if (report.dark)
432
+ return;
433
+ if (p.before === undefined) {
434
+ report.skipped += 1;
435
+ deps.log(`${p.conversationId}: commits not reported, the phase's starting HEAD is unknown`);
436
+ return;
437
+ }
438
+ try {
439
+ const lineageKey = p.proxy.lineageFor(p.conversationId);
440
+ if (lineageKey === null) {
441
+ const n = (await deps.commitBodies(p.ws, p.before, "-", p.repo)).length;
442
+ report.unlinked += n;
443
+ if (n > 0)
444
+ deps.log(`${n} commit(s) in ${p.conversationId} have no lineage key: not reported`);
445
+ return;
446
+ }
447
+ for (const body of await deps.commitBodies(p.ws, p.before, lineageKey, p.repo)) {
448
+ let res = await deps.api.recordCommit(p.orgId, body, p.leaseKey);
449
+ for (const wait of COMMIT_RETRY_MS) {
450
+ if (!(res.status === 0 || res.status === 429 || res.status >= 500))
451
+ break;
452
+ await deps.sleep(wait);
453
+ res = await deps.api.recordCommit(p.orgId, body, p.leaseKey);
454
+ }
455
+ if (res.status >= 200 && res.status < 300)
456
+ report.reported += 1;
457
+ else if (res.status === 404) {
458
+ report.dark = true;
459
+ deps.log("the commit index answered 404 (Record is off for this org): commits not reported");
460
+ return;
461
+ }
462
+ else {
463
+ report.dropped += 1;
464
+ deps.log(`commit ${body.sha.slice(0, 12)} not reported: ${res.code ?? res.networkError ?? res.status}`);
465
+ }
466
+ }
467
+ }
468
+ catch (e) {
469
+ deps.log(`commit report failed: ${e.message}`);
470
+ }
471
+ }
399
472
  async function fetchBundle(deps, sessionId, claim) {
400
473
  const [jwksRes, res] = await Promise.all([
401
474
  deps.api.jwks(),
@@ -15,8 +15,13 @@ export declare const CAPABILITY_TOOLS: Readonly<Record<string, {
15
15
  tools: string[];
16
16
  allowed: string[];
17
17
  }>>;
18
- /** Env names a phase may inherit: a working PATH and locale. Nothing else. */
19
- export declare const PHASE_ENV_PASSTHROUGH: readonly ["PATH", "LANG", "TZ"];
18
+ /**
19
+ * Env names a phase may inherit: a working PATH, the shell and locale. Nothing else. Claude Code's
20
+ * Bash tool needs SHELL to name a POSIX shell; without it, on an image with no bash on PATH (Alpine),
21
+ * every Bash call fails and a phase can write files but never commit them (door2-path image run
22
+ * 41940973594 ended pr_no_changes).
23
+ */
24
+ export declare const PHASE_ENV_PASSTHROUGH: readonly ["PATH", "SHELL", "LANG", "TZ"];
20
25
  /** Not a secret: lets claude start; the proxy drops it before the gateway (ruling: option (i)). */
21
26
  export declare const PHASE_MODEL_KEY_PLACEHOLDER = "mnemom-runner-placeholder";
22
27
  export type ToolGrant = {
@@ -21,8 +21,13 @@ export const CAPABILITY_TOOLS = Object.freeze({
21
21
  // The runner opens the PR itself once every phase has passed; the model gets no tool for it.
22
22
  "pr.open": { tools: [], allowed: [] },
23
23
  });
24
- /** Env names a phase may inherit: a working PATH and locale. Nothing else. */
25
- export const PHASE_ENV_PASSTHROUGH = ["PATH", "LANG", "TZ"];
24
+ /**
25
+ * Env names a phase may inherit: a working PATH, the shell and locale. Nothing else. Claude Code's
26
+ * Bash tool needs SHELL to name a POSIX shell; without it, on an image with no bash on PATH (Alpine),
27
+ * every Bash call fails and a phase can write files but never commit them (door2-path image run
28
+ * 41940973594 ended pr_no_changes).
29
+ */
30
+ export const PHASE_ENV_PASSTHROUGH = ["PATH", "SHELL", "LANG", "TZ"];
26
31
  /** Not a secret: lets claude start; the proxy drops it before the gateway (ruling: option (i)). */
27
32
  export const PHASE_MODEL_KEY_PLACEHOLDER = "mnemom-runner-placeholder";
28
33
  export function toolsFor(capabilityIds) {
@@ -7,24 +7,39 @@
7
7
  * Anthropic key in x-api-key (ruling 34.30.1; the phase only ever holds a placeholder), the job read's agent id in
8
8
  * x-mnemom-agent-id (the gateway checks it against the lease's session), and the job's
9
9
  * sealed contract goes in `x-mnemom-contract` (exactly one, the sealed version). The phase never
10
- * sees the lease key. A 409 `contract_mismatch` from the gateway (34.11) is reported to the job,
10
+ * sees the lease key. Each phase gets its own `x-mnemom-conversation-id` (from the session, lease
11
+ * epoch and phase order), so the gateway keys one lineage per phase instead of a body-hash one per
12
+ * request, and the `x-mnemom-lineage-key` it answers is kept for the phase's commit report. A 409 `contract_mismatch` from the gateway (34.11) is reported to the job,
11
13
  * which refetches and continues only on a higher sealed version, else fails closed.
12
14
  */
13
15
  import { request as httpRequest, type IncomingMessage, type ServerResponse } from "node:http";
14
16
  export declare const LEASE_KEY_HEADER = "x-mnemom-api-key";
15
17
  export declare const AGENT_ID_HEADER = "x-mnemom-agent-id";
18
+ export declare const CONVERSATION_ID_HEADER = "x-mnemom-conversation-id";
19
+ export declare const LINEAGE_KEY_HEADER = "x-mnemom-lineage-key";
20
+ /**
21
+ * One phase's conversation id: the session, the lease epoch and the phase order, so a retried
22
+ * claim or another phase never shares a lineage. Within the gateway's 1-256 printable, no-`#` id.
23
+ */
24
+ export declare function phaseConversationId(sessionId: string, epoch: number, order: number): string;
16
25
  export interface ProxyState {
17
26
  /** The sealed header value to send; swapped in place after an accepted amend. */
18
27
  contractValue: string;
19
28
  leaseKey: string;
20
29
  /** The session's agent, from the job read; always sent, a phase's own value never is. */
21
30
  agentId: string;
31
+ /** The running phase's conversation id; the job sets it before each phase. */
32
+ conversationId: string;
22
33
  }
23
34
  export interface ForwardedRequest {
24
35
  method: string;
25
36
  path: string;
26
37
  contract: string;
27
38
  status: number;
39
+ /** The conversation id the request carried. */
40
+ conversation: string;
41
+ /** The gateway's `x-mnemom-lineage-key` answer, when it sent a well-formed one. */
42
+ lineage: string | null;
28
43
  }
29
44
  export interface ModelProxy {
30
45
  url: string;
@@ -34,17 +49,26 @@ export interface ModelProxy {
34
49
  contractMismatch: {
35
50
  expected_version: number | null;
36
51
  } | null;
52
+ /** The lineage key the gateway last answered for a conversation id, or null when none came. */
53
+ lineageFor(conversationId: string): string | null;
37
54
  close(): Promise<void>;
38
55
  }
39
56
  /** Outbound headers for one request: the phase's own, minus anything the proxy decides. */
40
57
  export declare function outboundHeaders(incoming: IncomingMessage["headers"], state: ProxyState, modelKey: string): Record<string, string | string[] | undefined>;
41
- /** What the job's model requests carried: each contract by its sha256, in first-seen order. */
58
+ /**
59
+ * What the job's model requests carried: each contract by its sha256, in first-seen order, and the
60
+ * lineage key the gateway answered for each conversation (one per phase), in first-seen order.
61
+ */
42
62
  export interface RequestSummary {
43
63
  count: number;
44
64
  contracts: {
45
65
  contract_sha256: string;
46
66
  requests: number;
47
67
  }[];
68
+ lineages: {
69
+ conversation_id: string;
70
+ lineage_key: string | null;
71
+ }[];
48
72
  }
49
73
  /**
50
74
  * One job's forwarded requests, summarised for the operator's log and for the door2-path header
@@ -7,7 +7,9 @@
7
7
  * Anthropic key in x-api-key (ruling 34.30.1; the phase only ever holds a placeholder), the job read's agent id in
8
8
  * x-mnemom-agent-id (the gateway checks it against the lease's session), and the job's
9
9
  * sealed contract goes in `x-mnemom-contract` (exactly one, the sealed version). The phase never
10
- * sees the lease key. A 409 `contract_mismatch` from the gateway (34.11) is reported to the job,
10
+ * sees the lease key. Each phase gets its own `x-mnemom-conversation-id` (from the session, lease
11
+ * epoch and phase order), so the gateway keys one lineage per phase instead of a body-hash one per
12
+ * request, and the `x-mnemom-lineage-key` it answers is kept for the phase's commit report. A 409 `contract_mismatch` from the gateway (34.11) is reported to the job,
11
13
  * which refetches and continues only on a higher sealed version, else fails closed.
12
14
  */
13
15
  import { createServer, request as httpRequest, } from "node:http";
@@ -16,6 +18,17 @@ import { createHash } from "node:crypto";
16
18
  import { applySealedContract, CONTRACT_HEADER } from "./contract-header.js";
17
19
  export const LEASE_KEY_HEADER = "x-mnemom-api-key";
18
20
  export const AGENT_ID_HEADER = "x-mnemom-agent-id";
21
+ export const CONVERSATION_ID_HEADER = "x-mnemom-conversation-id";
22
+ export const LINEAGE_KEY_HEADER = "x-mnemom-lineage-key";
23
+ /** The gateway's lineage key as echoed: short, printable, no spaces. Anything else is not kept. */
24
+ const LINEAGE_KEY_RE = /^[\x21-\x7e]{1,256}$/;
25
+ /**
26
+ * One phase's conversation id: the session, the lease epoch and the phase order, so a retried
27
+ * claim or another phase never shares a lineage. Within the gateway's 1-256 printable, no-`#` id.
28
+ */
29
+ export function phaseConversationId(sessionId, epoch, order) {
30
+ return `durable-${sessionId}-e${epoch}-p${order}`;
31
+ }
19
32
  /** Hop-by-hop headers (RFC 9110 7.6.1) and the ones the proxy owns. */
20
33
  const DROP = new Set([
21
34
  "connection",
@@ -43,8 +56,16 @@ export function outboundHeaders(incoming, state, modelKey) {
43
56
  kept[LEASE_KEY_HEADER] = state.leaseKey;
44
57
  kept["x-api-key"] = modelKey;
45
58
  kept[AGENT_ID_HEADER] = state.agentId;
59
+ kept[CONVERSATION_ID_HEADER] = state.conversationId;
46
60
  return applySealedContract(kept, state.contractValue);
47
61
  }
62
+ /** The last well-formed lineage key answered for each conversation, in first-seen order. */
63
+ function lineagesOf(forwarded) {
64
+ const m = new Map();
65
+ for (const f of forwarded)
66
+ m.set(f.conversation, f.lineage ?? m.get(f.conversation) ?? null);
67
+ return m;
68
+ }
48
69
  /**
49
70
  * One job's forwarded requests, summarised for the operator's log and for the door2-path header
50
71
  * check (W3.11): the sha256 of each header value's decoded bytes is the contract it sealed to.
@@ -58,6 +79,10 @@ export function requestSummary(forwarded) {
58
79
  return {
59
80
  count: forwarded.length,
60
81
  contracts: [...by].map(([contract_sha256, requests]) => ({ contract_sha256, requests })),
82
+ lineages: [...lineagesOf(forwarded)].map(([conversation_id, lineage_key]) => ({
83
+ conversation_id,
84
+ lineage_key,
85
+ })),
61
86
  };
62
87
  }
63
88
  /** The largest 409 body the proxy reads for a contract mismatch. */
@@ -102,11 +127,13 @@ export async function startModelProxy(gatewayUrl, state, modelKey) {
102
127
  url: "",
103
128
  forwarded,
104
129
  contractMismatch: null,
130
+ lineageFor: (id) => lineagesOf(forwarded).get(id) ?? null,
105
131
  close: async () => { },
106
132
  };
107
133
  const server = createServer((req, res) => {
108
134
  const headers = outboundHeaders(req.headers, state, modelKey);
109
135
  const sent = headers[CONTRACT_HEADER];
136
+ const conversation = state.conversationId;
110
137
  const out = send({
111
138
  protocol: upstream.protocol,
112
139
  hostname: upstream.hostname,
@@ -116,11 +143,14 @@ export async function startModelProxy(gatewayUrl, state, modelKey) {
116
143
  headers,
117
144
  }, (up) => {
118
145
  const status = up.statusCode ?? 502;
146
+ const echoed = up.headers[LINEAGE_KEY_HEADER];
119
147
  forwarded.push({
120
148
  method: req.method ?? "GET",
121
149
  path: req.url ?? "/",
122
150
  contract: sent,
123
151
  status,
152
+ conversation,
153
+ lineage: typeof echoed === "string" && LINEAGE_KEY_RE.test(echoed) ? echoed : null,
124
154
  });
125
155
  if (status === 409) {
126
156
  // Buffered to read the mismatch, up to a cap; a larger body is relayed unread.
@@ -162,6 +192,8 @@ export async function startModelProxy(gatewayUrl, state, modelKey) {
162
192
  path: req.url ?? "/",
163
193
  contract: sent,
164
194
  status: 502,
195
+ conversation,
196
+ lineage: null,
165
197
  });
166
198
  upstreamFailed(res, e);
167
199
  });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The RecordCommit/v1 body Record's commit index takes (`POST /v1/orgs/{org}/record/commits`).
3
+ * It lives here so the runner can type its commit report without loading the CLI's commit reporter
4
+ * (which spawns git and reads auth); lib/commit-report.ts re-exports both names.
5
+ */
6
+ /** The commit index body's schema tag (shared with the fleet worker's reporter). */
7
+ export declare const COMMIT_SCHEMA = "RecordCommit/v1";
8
+ /** RecordCommit/v1: strings only, every field required; the route rejects an unknown field. */
9
+ export interface RecordCommitBody {
10
+ schema: typeof COMMIT_SCHEMA;
11
+ lineage_key: string;
12
+ repo: string;
13
+ branch: string;
14
+ sha: string;
15
+ patch_id: string;
16
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The RecordCommit/v1 body Record's commit index takes (`POST /v1/orgs/{org}/record/commits`).
3
+ * It lives here so the runner can type its commit report without loading the CLI's commit reporter
4
+ * (which spawns git and reads auth); lib/commit-report.ts re-exports both names.
5
+ */
6
+ /** The commit index body's schema tag (shared with the fleet worker's reporter). */
7
+ export const COMMIT_SCHEMA = "RecordCommit/v1";
@@ -1,12 +1,24 @@
1
1
  export declare const ANTHROPIC_ADMIN_ORIGIN = "https://api.anthropic.com";
2
2
  export declare const ADMIN_KEY_ENV = "ANTHROPIC_ADMIN_KEY";
3
+ /** The only paths a usage credential is ever sent to (design T18: fenced to report endpoints). */
4
+ export declare const REPORT_PATHS: readonly string[];
5
+ /** The fence, as one exported check so it is directly testable. Throws AnthropicUsageError for any other path. */
6
+ export declare function assertReportPath(path: string): void;
7
+ /** The exact auth header to send; same shape as mnemom-api src/org-scan/usage/credential-source.ts. */
8
+ export type AnthropicAuthHeader = {
9
+ header: "x-api-key";
10
+ value: string;
11
+ } | {
12
+ header: "authorization";
13
+ value: string;
14
+ };
3
15
  export declare const DEFAULT_MAX_PAGES = 20;
4
16
  /** One shared deadline for a request including its retries (not N x timeout). */
5
17
  export declare const DEFAULT_TIMEOUT_MS = 15000;
6
18
  export declare const DEFAULT_MAX_RETRIES = 2;
7
19
  /** Shape check only; the key is never echoed, even on a mismatch. */
8
20
  export declare const isAdminKeyShape: (k: unknown) => k is string;
9
- export type UsageFailure = "key_invalid" | "key_refused" | "rate_limited" | "timeout" | "network" | "upstream" | "malformed" | "too_large";
21
+ export type UsageFailure = "key_invalid" | "key_refused" | "token_refused" | "rate_limited" | "timeout" | "network" | "upstream" | "malformed" | "too_large";
10
22
  /** Fixed sentence for a failure reason. */
11
23
  export declare const usageFailureText: (r: UsageFailure) => string;
12
24
  /** Carries only a reason tag. Its message is the fixed sentence: nothing upstream can reach it. */
@@ -55,3 +67,10 @@ export declare function fetchMeasuredUsage(key: string | undefined, period: {
55
67
  start: string;
56
68
  end: string;
57
69
  }, deps?: AnthropicUsageDeps): Promise<MeasuredUsage>;
70
+ /** A WIF access token as the bearer for the two report reads. Printable ASCII only, else key_invalid. */
71
+ export declare function bearerAuth(token: string): AnthropicAuthHeader;
72
+ /** The two reads with an already-resolved credential (pasted key or WIF bearer). Throws AnthropicUsageError only. */
73
+ export declare function fetchMeasuredUsageWithAuth(auth: AnthropicAuthHeader, period: {
74
+ start: string;
75
+ end: string;
76
+ }, deps?: AnthropicUsageDeps): Promise<MeasuredUsage>;
@@ -1,6 +1,11 @@
1
1
  // MNE-9298 Tier 1: the CTO's own Anthropic Admin key, used LOCALLY, to read measured usage. Port of
2
2
  // mnemom-api src/org-scan/usage/usage-client.ts (two read-only GETs: usage_report/messages grouped by
3
- // context_window, and cost_report) with the server-only parts (egress allowlist, WIF) dropped.
3
+ // context_window, and cost_report) with the server-only parts (egress allowlist) dropped.
4
+ //
5
+ // MNE-9706: the reads take either credential, as the server's credential-source.ts does: the pasted Admin
6
+ // key (x-api-key) or a short-lived WIF access token (Authorization: Bearer, see anthropic-wif.ts). Either
7
+ // one is sent ONLY to the two report paths below; walk() refuses any other path (the fence, design T18:
8
+ // Anthropic's WIF scope for these reports is org:admin, so the fence has to live here).
4
9
  //
5
10
  // The key is held in memory for the length of the two reads. It is never stored, logged, written to disk,
6
11
  // echoed, put in argv, a URL or an error message, and is sent only to api.anthropic.com (a constant, not
@@ -11,6 +16,13 @@ export const ADMIN_KEY_ENV = "ANTHROPIC_ADMIN_KEY";
11
16
  const ANTHROPIC_VERSION = "2023-06-01";
12
17
  const USAGE_PATH = "/v1/organizations/usage_report/messages";
13
18
  const COST_PATH = "/v1/organizations/cost_report";
19
+ /** The only paths a usage credential is ever sent to (design T18: fenced to report endpoints). */
20
+ export const REPORT_PATHS = [USAGE_PATH, COST_PATH];
21
+ /** The fence, as one exported check so it is directly testable. Throws AnthropicUsageError for any other path. */
22
+ export function assertReportPath(path) {
23
+ if (!REPORT_PATHS.includes(path))
24
+ throw new AnthropicUsageError("malformed");
25
+ }
14
26
  export const DEFAULT_MAX_PAGES = 20;
15
27
  /** One shared deadline for a request including its retries (not N x timeout). */
16
28
  export const DEFAULT_TIMEOUT_MS = 15_000;
@@ -22,6 +34,7 @@ export const isAdminKeyShape = (k) => typeof k === "string" && ADMIN_KEY_RE.test
22
34
  const FAILURE_TEXT = {
23
35
  key_invalid: "The Anthropic admin key is not an Admin API key (it should start with sk-ant-admin).",
24
36
  key_refused: "Anthropic refused the admin key (401/403).",
37
+ token_refused: "Anthropic refused the workload identity token for the usage reports (401/403). The federation rule needs scope org:admin.",
25
38
  rate_limited: "Anthropic rate limit reached.",
26
39
  timeout: "Anthropic did not answer in time.",
27
40
  network: "Anthropic was unreachable.",
@@ -96,14 +109,14 @@ function retryWait(res, attempt) {
96
109
  return Number.isFinite(ra) && ra > 0 ? ra * 1000 : backoff(attempt);
97
110
  }
98
111
  class Reader {
99
- key;
112
+ auth;
100
113
  fetchImpl;
101
114
  sleep;
102
115
  maxPages;
103
116
  timeoutMs;
104
117
  maxRetries;
105
- constructor(key, deps) {
106
- this.key = key;
118
+ constructor(auth, deps) {
119
+ this.auth = auth;
107
120
  this.fetchImpl = deps.fetchImpl ?? ((...a) => fetch(...a));
108
121
  this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
109
122
  this.maxPages = deps.maxPages ?? DEFAULT_MAX_PAGES;
@@ -111,6 +124,8 @@ class Reader {
111
124
  this.maxRetries = deps.maxRetries ?? DEFAULT_MAX_RETRIES;
112
125
  }
113
126
  async walk(path, base, parse, callMaxPages) {
127
+ // The fence: the credential goes to the two report paths and nowhere else.
128
+ assertReportPath(path);
114
129
  const ceiling = typeof callMaxPages === "number" && Number.isFinite(callMaxPages) && callMaxPages >= 1
115
130
  ? Math.min(this.maxPages, Math.floor(callMaxPages))
116
131
  : this.maxPages;
@@ -154,7 +169,7 @@ class Reader {
154
169
  res = await this.fetchImpl(url, {
155
170
  method: "GET",
156
171
  headers: {
157
- "x-api-key": this.key,
172
+ [this.auth.header]: this.auth.value,
158
173
  "anthropic-version": ANTHROPIC_VERSION,
159
174
  accept: "application/json",
160
175
  },
@@ -185,7 +200,7 @@ class Reader {
185
200
  }
186
201
  await res.body?.cancel().catch(() => { });
187
202
  if (res.status === 401 || res.status === 403)
188
- throw new AnthropicUsageError("key_refused");
203
+ throw new AnthropicUsageError(this.auth.header === "x-api-key" ? "key_refused" : "token_refused");
189
204
  const retryable = res.status === 429 || res.status >= 500;
190
205
  const wait = retryable ? retryWait(res, attempt + 1) : 0;
191
206
  if (retryable &&
@@ -209,11 +224,21 @@ export async function fetchMeasuredUsage(key, period, deps = {}) {
209
224
  const k = key?.trim();
210
225
  if (!k || !isAdminKeyShape(k))
211
226
  throw new AnthropicUsageError("key_invalid");
227
+ return fetchMeasuredUsageWithAuth({ header: "x-api-key", value: k }, period, deps);
228
+ }
229
+ /** A WIF access token as the bearer for the two report reads. Printable ASCII only, else key_invalid. */
230
+ export function bearerAuth(token) {
231
+ if (!/^[\x21-\x7e]{8,4096}$/.test(token))
232
+ throw new AnthropicUsageError("key_invalid");
233
+ return { header: "authorization", value: `Bearer ${token}` };
234
+ }
235
+ /** The two reads with an already-resolved credential (pasted key or WIF bearer). Throws AnthropicUsageError only. */
236
+ export async function fetchMeasuredUsageWithAuth(auth, period, deps = {}) {
212
237
  if (!validDay(period.start) || !validDay(period.end))
213
238
  throw new AnthropicUsageError("malformed");
214
239
  const startingAt = alignStartingAt(`${period.start}T00:00:00Z`);
215
240
  const endingAt = new Date(Date.parse(`${period.end}T00:00:00Z`) + DAY_MS).toISOString();
216
- const reader = new Reader(k, deps);
241
+ const reader = new Reader(auth, deps);
217
242
  const up = new URLSearchParams({
218
243
  starting_at: startingAt,
219
244
  ending_at: endingAt,
@@ -0,0 +1,42 @@
1
+ export declare const WIF_EXCHANGE_URL = "https://api.anthropic.com/v1/oauth/token";
2
+ export declare const WIF_ENV: {
3
+ readonly rule: "ANTHROPIC_FEDERATION_RULE_ID";
4
+ readonly org: "ANTHROPIC_ORGANIZATION_ID";
5
+ readonly serviceAccount: "ANTHROPIC_SERVICE_ACCOUNT_ID";
6
+ readonly workspace: "ANTHROPIC_WORKSPACE_ID";
7
+ };
8
+ /** Same ceiling the server held a WIF token to (credential-source.ts MAX_WIF_TOKEN_LIFETIME_MS). */
9
+ export declare const MAX_WIF_TOKEN_LIFETIME_MS = 300000;
10
+ export interface WifConfig {
11
+ federationRuleId: string;
12
+ organizationId: string;
13
+ serviceAccountId: string;
14
+ workspaceId?: string;
15
+ }
16
+ export type WifFailure = "config_invalid" | "assertion_unavailable" | "assertion_refused" | "exchange_refused" | "exchange_failed";
17
+ export declare const wifFailureText: (r: WifFailure) => string;
18
+ export declare const WIF_FALLBACK_NOTE = "Falling back to the pasted Anthropic Admin key.";
19
+ /** Carries only a reason tag; its message is the fixed sentence. */
20
+ export declare class WifError extends Error {
21
+ readonly reason: WifFailure;
22
+ constructor(reason: WifFailure);
23
+ }
24
+ /**
25
+ * Reads the WIF ids from env. None of the three required ids set: null (WIF is not set up, no message).
26
+ * Some set, or any malformed: WifError("config_invalid"), so a half-done setup is said out loud.
27
+ */
28
+ export declare function readWifConfig(env: NodeJS.ProcessEnv): WifConfig | null;
29
+ export interface WifDeps {
30
+ /** Step 1 seam: returns the assertion for the org. Defaults to the mnemom-api route with the CLI's auth. */
31
+ mintAssertion?: (orgId: string) => Promise<unknown>;
32
+ /** Step 2 seam: the fetch used ONLY for the Anthropic exchange. */
33
+ fetchImpl?: typeof fetch;
34
+ now?: () => number;
35
+ }
36
+ export interface WifAccessToken {
37
+ accessToken: string;
38
+ /** Epoch ms, clamped to MAX_WIF_TOKEN_LIFETIME_MS from receipt. */
39
+ expiresAt: number;
40
+ }
41
+ /** Steps 1 and 2. Throws WifError only. */
42
+ export declare function obtainWifAccessToken(orgId: string, cfg: WifConfig, deps?: WifDeps): Promise<WifAccessToken>;