@mnemom/mnemom 0.17.32 → 0.17.34-next.1

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 (88) hide show
  1. package/dist/commands/agent-fuel.d.ts +9 -0
  2. package/dist/commands/agent-fuel.js +36 -2
  3. package/dist/commands/agent-run.d.ts +50 -0
  4. package/dist/commands/agent-run.js +221 -0
  5. package/dist/commands/agent-setup.d.ts +8 -3
  6. package/dist/commands/agent-setup.js +32 -9
  7. package/dist/commands/agent-share-fixture.d.ts +3 -1
  8. package/dist/commands/agent-share-fixture.js +11 -7
  9. package/dist/commands/agent-share-render.d.ts +23 -0
  10. package/dist/commands/agent-share-render.js +72 -0
  11. package/dist/commands/agent-share.d.ts +15 -2
  12. package/dist/commands/agent-share.js +30 -9
  13. package/dist/commands/agent.d.ts +34 -4
  14. package/dist/commands/agent.js +116 -20
  15. package/dist/commands/protection.js +1 -1
  16. package/dist/commands/scan.d.ts +97 -0
  17. package/dist/commands/scan.js +238 -0
  18. package/dist/index.js +55 -0
  19. package/dist/lib/api.d.ts +13 -0
  20. package/dist/lib/api.js +25 -1
  21. package/dist/lib/auth.d.ts +6 -0
  22. package/dist/lib/auth.js +161 -14
  23. package/dist/lib/experimental.d.ts +1 -1
  24. package/dist/lib/experimental.js +13 -0
  25. package/dist/lib/oauth.d.ts +7 -0
  26. package/dist/lib/oauth.js +13 -0
  27. package/dist/lib/runner/bundle.d.ts +129 -0
  28. package/dist/lib/runner/bundle.js +319 -0
  29. package/dist/lib/runner/contract-header.d.ts +46 -0
  30. package/dist/lib/runner/contract-header.js +85 -0
  31. package/dist/lib/runner/executor-client.d.ts +71 -0
  32. package/dist/lib/runner/executor-client.js +97 -0
  33. package/dist/lib/runner/job.d.ts +70 -0
  34. package/dist/lib/runner/job.js +463 -0
  35. package/dist/lib/runner/loop.d.ts +45 -0
  36. package/dist/lib/runner/loop.js +104 -0
  37. package/dist/lib/runner/oidc.d.ts +38 -0
  38. package/dist/lib/runner/oidc.js +89 -0
  39. package/dist/lib/runner/options.d.ts +41 -0
  40. package/dist/lib/runner/options.js +47 -0
  41. package/dist/lib/runner/outcome.d.ts +47 -0
  42. package/dist/lib/runner/outcome.js +89 -0
  43. package/dist/lib/runner/phases.d.ts +49 -0
  44. package/dist/lib/runner/phases.js +99 -0
  45. package/dist/lib/runner/proxy.d.ts +45 -0
  46. package/dist/lib/runner/proxy.js +153 -0
  47. package/dist/lib/runner/settings.d.ts +31 -0
  48. package/dist/lib/runner/settings.js +44 -0
  49. package/dist/lib/scan/caps.d.ts +35 -0
  50. package/dist/lib/scan/caps.js +67 -0
  51. package/dist/lib/scan/credential.d.ts +37 -0
  52. package/dist/lib/scan/credential.js +104 -0
  53. package/dist/lib/scan/failure.d.ts +10 -0
  54. package/dist/lib/scan/failure.js +43 -0
  55. package/dist/lib/scan/pr-source.d.ts +57 -0
  56. package/dist/lib/scan/pr-source.js +130 -0
  57. package/dist/lib/scan/record.d.ts +49 -0
  58. package/dist/lib/scan/record.js +85 -0
  59. package/dist/lib/scan/report-email.d.ts +92 -0
  60. package/dist/lib/scan/report-email.js +136 -0
  61. package/dist/lib/scan/report.d.ts +13 -0
  62. package/dist/lib/scan/report.js +30 -0
  63. package/dist/lib/scan/scan.d.ts +64 -0
  64. package/dist/lib/scan/scan.js +182 -0
  65. package/dist/lib/scan/share.d.ts +38 -0
  66. package/dist/lib/scan/share.js +47 -0
  67. package/dist/lib/scan/spend/ai-tool-detect.d.ts +6 -0
  68. package/dist/lib/scan/spend/ai-tool-detect.js +18 -0
  69. package/dist/lib/scan/spend/anthropic-usage.d.ts +57 -0
  70. package/dist/lib/scan/spend/anthropic-usage.js +244 -0
  71. package/dist/lib/scan/spend/cost-corpus.v1.d.ts +56 -0
  72. package/dist/lib/scan/spend/cost-corpus.v1.js +64 -0
  73. package/dist/lib/scan/spend/prices.d.ts +33 -0
  74. package/dist/lib/scan/spend/prices.js +57 -0
  75. package/dist/lib/scan/spend/spend-estimate.d.ts +30 -0
  76. package/dist/lib/scan/spend/spend-estimate.js +70 -0
  77. package/dist/lib/scan/spend/spend-tiers.d.ts +25 -0
  78. package/dist/lib/scan/spend/spend-tiers.js +42 -0
  79. package/dist/lib/scan/spend/token-rate.d.ts +30 -0
  80. package/dist/lib/scan/spend/token-rate.js +54 -0
  81. package/dist/lib/session-key.d.ts +41 -0
  82. package/dist/lib/session-key.js +124 -0
  83. package/dist/lib/statusline-feed.js +10 -11
  84. package/dist/lib/statusline.d.ts +6 -0
  85. package/dist/lib/statusline.js +19 -9
  86. package/dist/rc-key-401.mjs +75 -0
  87. package/dist/rc-proxy.mjs +290 -30
  88. package/package.json +2 -1
@@ -0,0 +1,70 @@
1
+ // MNE-9268: Tier-3 spend RANGE estimator. Pure and in-memory: takes per-PR counts and mark text, returns
2
+ // one labelled estimate line or an explicit "no estimate". Nothing is stored, fetched or logged.
3
+ //
4
+ // No line is produced when there are too few AI-assisted PRs to price (MIN_AI_PRS). That is a
5
+ // data-sufficiency threshold on the evidence behind the range, nothing to do with how large a
6
+ // reporting cohort is. Default-OFF behind ORG_SCAN_MODE via estimateSpendIfEnabled.
7
+ import { AI_TOOLS, detectAiTools } from "./ai-tool-detect.js";
8
+ import { PRICES_AS_OF, bandFor, blendedUsdPerToken } from "./prices.js";
9
+ import { DERIVED_TOKEN_RATE } from "./token-rate.js";
10
+ /** AI-assisted PRs needed before a range is worth printing. */
11
+ export const MIN_AI_PRS = 10;
12
+ /** Fixed strings only: a failure never carries input or an error message into the output. */
13
+ const NONE_LINES = {
14
+ too_few_ai_prs: "No spend estimate: too few AI-assisted PRs in this scan to price a range.",
15
+ no_rate: "No spend estimate: the cost reference data is not sufficient to price a range.",
16
+ invalid_input: "No spend estimate: the PR counts could not be read.",
17
+ };
18
+ const isCount = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
19
+ function none(reason, aiPrs, scannedPrs) {
20
+ return { kind: "none", reason, aiPrs, scannedPrs, line: NONE_LINES[reason] };
21
+ }
22
+ function validPr(p) {
23
+ if (typeof p !== "object" || p === null)
24
+ return false;
25
+ const o = p;
26
+ return (isCount(o.additions) &&
27
+ isCount(o.deletions) &&
28
+ typeof o.markText === "string" &&
29
+ (o.tools === undefined ||
30
+ (Array.isArray(o.tools) && o.tools.every((t) => AI_TOOLS.includes(t)))));
31
+ }
32
+ /** Two significant figures so the range does not claim precision it lacks ($2.4, not $2; 0 stays $0). */
33
+ function usd(n) {
34
+ return `$${n.toLocaleString("en-US", { maximumSignificantDigits: 2 })}`;
35
+ }
36
+ export function estimateSpend(prs, rate = DERIVED_TOKEN_RATE) {
37
+ if (!Array.isArray(prs))
38
+ return none("invalid_input", 0, 0);
39
+ const scanned = prs.length;
40
+ if (!prs.every(validPr))
41
+ return none("invalid_input", 0, scanned);
42
+ let aiPrs = 0;
43
+ let low = 0;
44
+ let high = 0;
45
+ const byTool = {};
46
+ for (const p of prs) {
47
+ const tools = p.tools ?? detectAiTools(p.markText);
48
+ if (tools.length === 0)
49
+ continue;
50
+ aiPrs += 1;
51
+ for (const t of tools)
52
+ byTool[t] = (byTool[t] ?? 0) + 1;
53
+ if (!rate)
54
+ continue;
55
+ const lines = p.additions + p.deletions;
56
+ // A PR carrying several marks is priced once, across the widest band of the tools named.
57
+ const bands = tools.map(bandFor);
58
+ const lo = Math.min(...bands.map((b) => blendedUsdPerToken(b.low)));
59
+ const hi = Math.max(...bands.map((b) => blendedUsdPerToken(b.high)));
60
+ low += lines * rate.low * lo;
61
+ high += lines * rate.high * hi;
62
+ }
63
+ if (aiPrs < MIN_AI_PRS)
64
+ return none("too_few_ai_prs", aiPrs, scanned);
65
+ if (!rate)
66
+ return none("no_rate", aiPrs, scanned);
67
+ const line = `Estimate, AI tool spend: ${usd(low)} to ${usd(high)} across ${aiPrs} AI-assisted PRs ` +
68
+ `(public list prices as of ${PRICES_AS_OF} times a tokens-per-line rate from our own agents' PRs; not billing data).`;
69
+ return { kind: "range", lowUsd: low, highUsd: high, aiPrs, scannedPrs: scanned, byTool, line };
70
+ }
@@ -0,0 +1,25 @@
1
+ import type { SpendEstimate } from "./spend-estimate.js";
2
+ export declare const ORG_SCAN_ENV = "MNEMOM_ORG_SCAN_MODE";
3
+ /** Default OFF: only the exact string "true" turns the tiers on. */
4
+ export declare const isSpendTiersEnabled: (env?: NodeJS.ProcessEnv) => boolean;
5
+ export declare const NOT_ENABLED_MSG = "The spend fallback is not enabled.";
6
+ export declare const SPEND_INVALID_MSG = "Monthly spend must be a dollar amount above zero, like 5000.";
7
+ export declare const SPEND_NO_TERMINAL_MSG = "Pass --monthly-spend <usd> (there is no terminal to ask on).";
8
+ export declare const MAX_MONTHLY_SPEND_USD = 10000000;
9
+ /** Dollars, or null if the text is not a positive, plausible amount. */
10
+ export declare function parseMonthlySpendUsd(raw: string): number | null;
11
+ export type SpendTier = 1 | 2 | 3;
12
+ export interface SpendSelection {
13
+ /** null when the evidence is too thin: print nothing. */
14
+ tier: SpendTier | null;
15
+ line: string | null;
16
+ /** May this line leave the machine in a share payload? Never for a self-reported figure. */
17
+ shareable: boolean;
18
+ }
19
+ export declare function selectSpend(i: {
20
+ measuredUsd?: number;
21
+ /** Fraction of measured tokens from requests over 200k context; shown only with a measured figure. */
22
+ over200kShare?: number | null;
23
+ selfReportedUsd?: number | null;
24
+ estimate: SpendEstimate;
25
+ }): SpendSelection;
@@ -0,0 +1,42 @@
1
+ export const ORG_SCAN_ENV = "MNEMOM_ORG_SCAN_MODE";
2
+ /** Default OFF: only the exact string "true" turns the tiers on. */
3
+ export const isSpendTiersEnabled = (env = process.env) => env[ORG_SCAN_ENV] === "true";
4
+ export const NOT_ENABLED_MSG = "The spend fallback is not enabled.";
5
+ export const SPEND_INVALID_MSG = "Monthly spend must be a dollar amount above zero, like 5000.";
6
+ export const SPEND_NO_TERMINAL_MSG = "Pass --monthly-spend <usd> (there is no terminal to ask on).";
7
+ export const MAX_MONTHLY_SPEND_USD = 10_000_000;
8
+ const SPEND_RE = /^\$?\s*(\d{1,3}(,\d{3})+|\d+)(\.\d{1,2})?$/;
9
+ /** Dollars, or null if the text is not a positive, plausible amount. */
10
+ export function parseMonthlySpendUsd(raw) {
11
+ const t = raw.trim();
12
+ if (!SPEND_RE.test(t))
13
+ return null;
14
+ const n = Number(t.replace(/[$,\s]/g, ""));
15
+ return Number.isFinite(n) && n > 0 && n <= MAX_MONTHLY_SPEND_USD ? n : null;
16
+ }
17
+ const usd = (n) => `$${n.toLocaleString("en-US", { maximumFractionDigits: 2 })}`;
18
+ const isUsd = (n) => typeof n === "number" && Number.isFinite(n) && n > 0;
19
+ const pct = (f) => `${Math.round(f * 100)}%`;
20
+ const isShare = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0 && n <= 1;
21
+ export function selectSpend(i) {
22
+ if (isUsd(i.measuredUsd)) {
23
+ return {
24
+ tier: 1,
25
+ line: `Tier 1, measured: ${usd(i.measuredUsd)} of Anthropic usage over the period${isShare(i.over200kShare)
26
+ ? ` (${pct(i.over200kShare)} of tokens in requests over 200k context)`
27
+ : ""}.`,
28
+ shareable: true,
29
+ };
30
+ }
31
+ if (isUsd(i.selfReportedUsd)) {
32
+ return {
33
+ tier: 2,
34
+ line: `Tier 2, self-reported: about ${usd(i.selfReportedUsd)} a month (given by you; not verified, not sent, not saved).`,
35
+ shareable: false,
36
+ };
37
+ }
38
+ if (i.estimate.kind === "range") {
39
+ return { tier: 3, line: `Tier 3, ${i.estimate.line}`, shareable: true };
40
+ }
41
+ return { tier: null, line: null, shareable: false };
42
+ }
@@ -0,0 +1,30 @@
1
+ /** Rows needed before the rate is trusted at all (data sufficiency, not a group size). */
2
+ export declare const MIN_CORPUS_ROWS = 5;
3
+ export interface CostRowShape {
4
+ repo: string;
5
+ pr: number;
6
+ additions: number;
7
+ deletions: number;
8
+ cost_usd: number;
9
+ runs: number;
10
+ models: string[];
11
+ }
12
+ export interface TokenRate {
13
+ /** Price-equivalent tokens per changed (added + deleted) line: 25th percentile. */
14
+ low: number;
15
+ median: number;
16
+ /** 75th percentile. */
17
+ high: number;
18
+ rows: number;
19
+ }
20
+ /** Linear-interpolated quantile of a sorted array. */
21
+ export declare function quantile(sorted: number[], q: number): number;
22
+ /**
23
+ * Derive the rate from corpus rows: per-row tokens/line, then the 25th, 50th and 75th percentile.
24
+ * Rows that fail validation are dropped (not repaired); fewer than MIN_CORPUS_ROWS valid rows
25
+ * returns null, never a rate built on a handful.
26
+ */
27
+ export declare function deriveTokenRate(rows: unknown): TokenRate | null;
28
+ export declare const CORPUS_AS_OF: string;
29
+ /** The shipped rate: derived once from the committed fixture. */
30
+ export declare const DERIVED_TOKEN_RATE: TokenRate | null;
@@ -0,0 +1,54 @@
1
+ // MNE-9268: tokens-per-changed-line and its spread, DERIVED from our own agents' PRs whose run cost is
2
+ // known (cost-corpus.v1.json, built by build-cost-corpus.mjs from the ADW "Run cost" PR comments).
3
+ // Nothing here is a typed-in rate: change the fixture and the rate moves, and token-rate.test.ts pins
4
+ // the derivation. Tokens are price-equivalent: cost / (our model's blended list price per token).
5
+ import rawCorpus from "./cost-corpus.v1.js";
6
+ import { TOOL_PRICES, blendedUsdPerToken } from "./prices.js";
7
+ /** Rows needed before the rate is trusted at all (data sufficiency, not a group size). */
8
+ export const MIN_CORPUS_ROWS = 5;
9
+ /** Our agents ran on Sonnet-family models in every corpus row; price their tokens as that. */
10
+ const BASIS_USD_PER_TOKEN = blendedUsdPerToken(TOOL_PRICES.claude_code.low);
11
+ const isCount = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
12
+ function validRow(r) {
13
+ if (typeof r !== "object" || r === null)
14
+ return false;
15
+ const o = r;
16
+ return (isCount(o.additions) &&
17
+ isCount(o.deletions) &&
18
+ o.additions + o.deletions > 0 &&
19
+ isCount(o.cost_usd) &&
20
+ o.cost_usd > 0);
21
+ }
22
+ /** Linear-interpolated quantile of a sorted array. */
23
+ export function quantile(sorted, q) {
24
+ if (sorted.length === 0)
25
+ return NaN;
26
+ const pos = (sorted.length - 1) * q;
27
+ const lo = Math.floor(pos);
28
+ const hi = Math.ceil(pos);
29
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
30
+ }
31
+ /**
32
+ * Derive the rate from corpus rows: per-row tokens/line, then the 25th, 50th and 75th percentile.
33
+ * Rows that fail validation are dropped (not repaired); fewer than MIN_CORPUS_ROWS valid rows
34
+ * returns null, never a rate built on a handful.
35
+ */
36
+ export function deriveTokenRate(rows) {
37
+ if (!Array.isArray(rows))
38
+ return null;
39
+ const per = rows
40
+ .filter(validRow)
41
+ .map((r) => r.cost_usd / (r.additions + r.deletions) / BASIS_USD_PER_TOKEN)
42
+ .sort((a, b) => a - b);
43
+ if (per.length < MIN_CORPUS_ROWS)
44
+ return null;
45
+ return {
46
+ low: quantile(per, 0.25),
47
+ median: quantile(per, 0.5),
48
+ high: quantile(per, 0.75),
49
+ rows: per.length,
50
+ };
51
+ }
52
+ export const CORPUS_AS_OF = rawCorpus.as_of;
53
+ /** The shipped rate: derived once from the committed fixture. */
54
+ export const DERIVED_TOKEN_RATE = deriveTokenRate(rawCorpus.rows);
@@ -66,3 +66,44 @@ export declare function mintSessionGatewayKey(deps: SessionKeyDeps, args: {
66
66
  * one. Never called with the owner's own key id.
67
67
  */
68
68
  export declare function revokeSessionGatewayKey(deps: SessionKeyDeps, keyId: string): Promise<void>;
69
+ /** When to renew a key expiring at `expiresAt`, measured from `now` (ms). */
70
+ export declare function renewDelayMs(expiresAt: string, now: number): number;
71
+ /** What {@link mintSessionGatewayKey} mints for: the same arguments at launch and at each renewal. */
72
+ export interface SessionKeyMintArgs {
73
+ agentId: string;
74
+ orgId?: string;
75
+ sessionId?: string;
76
+ }
77
+ export interface SessionKeyRenewalOptions {
78
+ /** `say` is where renewal events go: the proxy log, never the terminal the session draws on. */
79
+ deps: SessionKeyDeps;
80
+ mintArgs: SessionKeyMintArgs;
81
+ initial: {
82
+ keyId: string;
83
+ expiresAt: string;
84
+ };
85
+ /** Hand the new key to the running proxy. Throws when it cannot (the pipe is gone). */
86
+ deliver: (minted: MintedSessionGatewayKey) => void;
87
+ /** Tell the proxy its re-mint request failed, so it stops waiting and answers the 401. */
88
+ refuse: (reason: string) => void;
89
+ now?: () => number;
90
+ }
91
+ export interface SessionKeyRenewal {
92
+ /**
93
+ * Re-mint now. `rejected` is the proxy's request after a gateway 401, naming the key id it sent;
94
+ * a request about a key that is no longer current is a no-op (the proxy already has the newer
95
+ * one). Resolves true when a new key was delivered. Never rejects.
96
+ */
97
+ renew(reason: "scheduled" | "rejected", rejectedKeyId?: string): Promise<boolean>;
98
+ /** The key the proxy holds now: the one to revoke on exit. */
99
+ currentKeyId(): string;
100
+ stop(): void;
101
+ }
102
+ /**
103
+ * Keep the proxy's session key fresh: renew at ~80% of its remaining lifetime (floor 60s), retry a
104
+ * failed renewal every 5 minutes, and after each swap revoke the old key (best effort, like the
105
+ * exit revoke). Every new key goes through {@link mintSessionGatewayKey}, so a renewal is held to
106
+ * the same fail-closed checks as the launch: a key that is not exactly a `['gateway']` session key
107
+ * is never delivered. Logs key ids and expiry times only, never a key.
108
+ */
109
+ export declare function startSessionKeyRenewal(opts: SessionKeyRenewalOptions): SessionKeyRenewal;
@@ -99,3 +99,127 @@ export async function revokeSessionGatewayKey(deps, keyId) {
99
99
  deps.say(`mnemom agent: could not revoke the session key (non-fatal — it expires on its own within 24h): ${err instanceof Error ? err.message : String(err)}`);
100
100
  }
101
101
  }
102
+ // ───────────────────────────────────────────────────────────────────────────
103
+ // Renewal for long-lived Remote Control sessions
104
+ // ───────────────────────────────────────────────────────────────────────────
105
+ //
106
+ // The minted key is 24h-TTL'd, and a `--remote-control` seat outlives that: before this, every
107
+ // model turn failed with the gateway's `401 Invalid Mnemom API key` exactly 24h after launch. The
108
+ // launcher (the CLI PARENT, which holds ambient auth) therefore re-mints before expiry and hands
109
+ // the new key to the running proxy over its control pipe — never argv, env or disk — then revokes
110
+ // the old one. The proxy can also ask for one immediate re-mint when the gateway rejects its key.
111
+ /** Renew once this share of the key's remaining lifetime has passed. */
112
+ const RENEW_AT_FRACTION = 0.8;
113
+ /** Never schedule a renewal sooner than this (a key that is nearly or already expired). */
114
+ const RENEW_FLOOR_MS = 60_000;
115
+ /** The lifetime assumed when `expiresAt` does not parse: the route's documented 24h. */
116
+ const ASSUMED_TTL_MS = 24 * 60 * 60 * 1000;
117
+ /** After a failed renewal, try again this much later. */
118
+ const RENEW_RETRY_MS = 5 * 60 * 1000;
119
+ /**
120
+ * A re-mint the proxy asks for (its key was rejected) is refused if one was attempted this
121
+ * recently, so a gateway that keeps rejecting fresh keys cannot turn every request into a mint.
122
+ */
123
+ const REJECTED_COOLDOWN_MS = 60_000;
124
+ /** setTimeout's ceiling (a larger delay fires at once). */
125
+ const MAX_TIMER_MS = 2 ** 31 - 1;
126
+ /** When to renew a key expiring at `expiresAt`, measured from `now` (ms). */
127
+ export function renewDelayMs(expiresAt, now) {
128
+ const exp = Date.parse(expiresAt);
129
+ const left = Number.isFinite(exp) ? exp - now : ASSUMED_TTL_MS;
130
+ return Math.min(MAX_TIMER_MS, Math.max(RENEW_FLOOR_MS, Math.floor(left * RENEW_AT_FRACTION)));
131
+ }
132
+ /**
133
+ * Keep the proxy's session key fresh: renew at ~80% of its remaining lifetime (floor 60s), retry a
134
+ * failed renewal every 5 minutes, and after each swap revoke the old key (best effort, like the
135
+ * exit revoke). Every new key goes through {@link mintSessionGatewayKey}, so a renewal is held to
136
+ * the same fail-closed checks as the launch: a key that is not exactly a `['gateway']` session key
137
+ * is never delivered. Logs key ids and expiry times only, never a key.
138
+ */
139
+ export function startSessionKeyRenewal(opts) {
140
+ const now = opts.now ?? Date.now;
141
+ const say = opts.deps.say;
142
+ let current = { ...opts.initial };
143
+ let timer;
144
+ let inflight;
145
+ let lastAttempt = Number.NEGATIVE_INFINITY;
146
+ let stopped = false;
147
+ const schedule = (ms) => {
148
+ if (stopped)
149
+ return;
150
+ clearTimeout(timer);
151
+ timer = setTimeout(() => void renew("scheduled"), ms);
152
+ timer.unref?.();
153
+ };
154
+ const attempt = async (reason) => {
155
+ lastAttempt = now();
156
+ let minted;
157
+ try {
158
+ minted = await mintSessionGatewayKey(opts.deps, opts.mintArgs);
159
+ }
160
+ catch (err) {
161
+ // mintSessionGatewayKey always throws an Error wrapping the mint's own failure in `cause`,
162
+ // with launch-refusal wording that is wrong mid-session: log the cause when it is an Error.
163
+ const e = err;
164
+ const msg = e.cause instanceof Error ? e.cause.message : e.message;
165
+ say(`session key renewal (${reason}) FAILED: ${msg}; keeping id=${current.keyId} expires=${current.expiresAt}, retrying in ${RENEW_RETRY_MS / 1000}s`);
166
+ schedule(RENEW_RETRY_MS);
167
+ return false;
168
+ }
169
+ if (stopped) {
170
+ await revokeSessionGatewayKey(opts.deps, minted.keyId); // nobody will ever use it
171
+ return false;
172
+ }
173
+ try {
174
+ opts.deliver(minted);
175
+ }
176
+ catch (err) {
177
+ say(`session key renewal (${reason}): could not hand id=${minted.keyId} to the proxy (${err instanceof Error ? err.message : String(err)}); revoking it and keeping id=${current.keyId}`);
178
+ await revokeSessionGatewayKey(opts.deps, minted.keyId);
179
+ schedule(RENEW_RETRY_MS);
180
+ return false;
181
+ }
182
+ const old = current;
183
+ current = { keyId: minted.keyId, expiresAt: minted.expiresAt };
184
+ say(`session key renewed (${reason}): id=${current.keyId} expires=${current.expiresAt}; revoking id=${old.keyId}`);
185
+ schedule(renewDelayMs(current.expiresAt, now()));
186
+ await revokeSessionGatewayKey(opts.deps, old.keyId);
187
+ return true;
188
+ };
189
+ const renew = (reason, rejectedKeyId) => {
190
+ if (stopped)
191
+ return Promise.resolve(false);
192
+ if (reason === "rejected") {
193
+ // The proxy sent a key that is no longer current: the newer one is already on its way.
194
+ if (rejectedKeyId && rejectedKeyId !== current.keyId)
195
+ return Promise.resolve(true);
196
+ if (!inflight && now() - lastAttempt < REJECTED_COOLDOWN_MS) {
197
+ say(`session key id=${current.keyId} rejected by the gateway; not re-minting (last attempt under ${REJECTED_COOLDOWN_MS / 1000}s ago)`);
198
+ opts.refuse("cooldown");
199
+ return Promise.resolve(false);
200
+ }
201
+ }
202
+ // One mint at a time: a rejection that arrives mid-renewal waits on that renewal.
203
+ inflight ??= attempt(reason).finally(() => {
204
+ inflight = undefined;
205
+ });
206
+ const p = inflight;
207
+ if (reason === "rejected") {
208
+ void p.then((ok) => {
209
+ if (!ok)
210
+ opts.refuse("mint failed");
211
+ });
212
+ }
213
+ return p;
214
+ };
215
+ // The launch key's id and expiry are logged by the proxy at startup.
216
+ schedule(renewDelayMs(current.expiresAt, now()));
217
+ return {
218
+ renew,
219
+ currentKeyId: () => current.keyId,
220
+ stop: () => {
221
+ stopped = true;
222
+ clearTimeout(timer);
223
+ },
224
+ };
225
+ }
@@ -12,7 +12,7 @@
12
12
  import { renameSync, writeFileSync } from "node:fs";
13
13
  import { getRecordTotals } from "./api.js";
14
14
  import { readRecordState } from "./conduct-receipt.js";
15
- import { LINEAGE_KEY_RE, MMU_RE } from "./statusline.js";
15
+ import { LINEAGE_KEY_RE, toFedTotals } from "./statusline.js";
16
16
  /** How often the launcher re-reads the totals. */
17
17
  export const FEED_INTERVAL_MS = 10_000;
18
18
  /**
@@ -32,18 +32,17 @@ export async function feedOnce(o) {
32
32
  const totals = await (o.fetchTotals ?? getRecordTotals)(o.orgId, key);
33
33
  if (!totals || totals.lineage_key !== key)
34
34
  return false;
35
- // Persist only amounts the bin would render: a malformed reply never replaces a good feed.
36
- const mu = totals.mu_charged_mmu;
37
- if (typeof totals.model_cost_mmu !== "string" || !MMU_RE.test(totals.model_cost_mmu)) {
38
- return false;
39
- }
40
- if (mu !== null && (typeof mu !== "string" || !MMU_RE.test(mu)))
41
- return false;
42
- const tmp = `${o.feedFile}.tmp`;
43
- writeFileSync(tmp, JSON.stringify({
35
+ // Persist only the amounts the bin would render, rebuilt from the checked values (never
36
+ // the reply itself): a malformed reply never replaces a good feed. The path is the
37
+ // launcher's own feedFile; nothing in the reply names where it is written.
38
+ const fed = toFedTotals({
44
39
  model_cost_mmu: totals.model_cost_mmu,
45
40
  mu_charged_mmu: totals.mu_charged_mmu,
46
- }));
41
+ });
42
+ if (!fed)
43
+ return false;
44
+ const tmp = `${o.feedFile}.tmp`;
45
+ writeFileSync(tmp, JSON.stringify(fed));
47
46
  renameSync(tmp, o.feedFile);
48
47
  return true;
49
48
  }
@@ -38,6 +38,12 @@ export interface FedTotals {
38
38
  /** null: no billing row yet, shown "μ not charged" (OD-9). */
39
39
  mu_charged_mmu: string | null;
40
40
  }
41
+ /**
42
+ * The totals the segment renders, rebuilt from only the two checked amounts, or null when
43
+ * either is off. The one shape check for both sides of the feed: the launcher writes only
44
+ * what this returns (lib/statusline-feed.ts) and the bin reads only what this accepts.
45
+ */
46
+ export declare function toFedTotals(d: Record<string, unknown>): FedTotals | null;
41
47
  /** The feed file's totals, or null when it is absent or anything in it is off. */
42
48
  export declare function readFeed(path: string): FedTotals | null;
43
49
  /** Model cost in dollars: 1000 mmu = 1 μ = 1¢. Under half a cent reads "<$0.01", never "$0.00". */
@@ -37,19 +37,29 @@ export const FEED_ENV = "MNEMOM_STATUSLINE_FEED";
37
37
  export const LINEAGE_KEY_RE = /^(cid|cr):[0-9a-f]{16}$/;
38
38
  /** A millimu amount as the record route returns it: a decimal string, no sign, no leading 0. */
39
39
  export const MMU_RE = /^(0|[1-9][0-9]{0,18})$/;
40
+ /** `v` when it is a millimu decimal string (MMU_RE), else undefined. */
41
+ function mmu(v) {
42
+ return typeof v === "string" && MMU_RE.test(v) ? v : undefined;
43
+ }
44
+ /**
45
+ * The totals the segment renders, rebuilt from only the two checked amounts, or null when
46
+ * either is off. The one shape check for both sides of the feed: the launcher writes only
47
+ * what this returns (lib/statusline-feed.ts) and the bin reads only what this accepts.
48
+ */
49
+ export function toFedTotals(d) {
50
+ const cost = mmu(d.model_cost_mmu);
51
+ if (cost === undefined)
52
+ return null;
53
+ if (d.mu_charged_mmu === null)
54
+ return { model_cost_mmu: cost, mu_charged_mmu: null };
55
+ const mu = mmu(d.mu_charged_mmu);
56
+ return mu === undefined ? null : { model_cost_mmu: cost, mu_charged_mmu: mu };
57
+ }
40
58
  /** The feed file's totals, or null when it is absent or anything in it is off. */
41
59
  export function readFeed(path) {
42
60
  try {
43
61
  const d = parseJson(readFileSync(path, "utf8"));
44
- if (!d)
45
- return null;
46
- const cost = d.model_cost_mmu;
47
- const mu = d.mu_charged_mmu;
48
- if (typeof cost !== "string" || !MMU_RE.test(cost))
49
- return null;
50
- if (mu !== null && (typeof mu !== "string" || !MMU_RE.test(mu)))
51
- return null;
52
- return { model_cost_mmu: cost, mu_charged_mmu: mu };
62
+ return d ? toFedTotals(d) : null;
53
63
  }
54
64
  catch {
55
65
  return null;
@@ -0,0 +1,75 @@
1
+ // rc-key-401 — the Remote Control proxy's handling of a gateway 401 it may be able to fix.
2
+ //
3
+ // A 401 on a buffered /v1/messages request may be the Mnemom session key expiring under a long
4
+ // session (rc-proxy.mjs, SESSION KEY RENEWAL). The proxy holds that 401 back from the client until
5
+ // the whole body is in, then asks isMnemomKeyRejection() whether a fresh key would fix it. Split out
6
+ // of rc-proxy.mjs (a script that reads stdin and listens at import) so each branch is unit-tested
7
+ // (rc-key-401.test.ts). Node ≥ 18, no deps.
8
+ import { Buffer } from "node:buffer";
9
+ import zlib from "node:zlib";
10
+
11
+ // A held-back 401 body (and its decompressed form) is read up to this size. The gateway's key
12
+ // rejection is a few hundred bytes; anything larger is not one and is streamed to the client as is.
13
+ export const KEY_401_BODY_MAX = 64 * 1024;
14
+
15
+ // How long a key-rejected request waits for the launcher's re-mint (its mint call is bounded at
16
+ // 10s): MNEMOM_RC_REMINT_WAIT_MS when set and non-empty ("0" included), else 20s.
17
+ export const REMINT_WAIT_DEFAULT_MS = 20_000;
18
+ export function remintWaitMs(env) {
19
+ const v = env.MNEMOM_RC_REMINT_WAIT_MS;
20
+ return v ? Number(v) : REMINT_WAIT_DEFAULT_MS;
21
+ }
22
+
23
+ // Whether a held-back gateway 401 body says the Mnemom key itself was refused (unknown, revoked or
24
+ // expired): the only 401 a fresh key can fix. Anything it cannot decode is not one.
25
+ export function isMnemomKeyRejection(raw, enc, max = KEY_401_BODY_MAX) {
26
+ let text;
27
+ try {
28
+ const b =
29
+ enc === "gzip" || enc === "x-gzip"
30
+ ? zlib.gunzipSync(raw, { maxOutputLength: max })
31
+ : enc === "deflate"
32
+ ? zlib.inflateSync(raw, { maxOutputLength: max })
33
+ : enc === "br"
34
+ ? zlib.brotliDecompressSync(raw, { maxOutputLength: max })
35
+ : raw;
36
+ text = b.toString("utf8");
37
+ } catch {
38
+ return false;
39
+ }
40
+ return /invalid[ _]mnemom[ _](api[ _])?key/i.test(text);
41
+ }
42
+
43
+ // Hold the upstream response `ur` back from the client response `res`. Up to `max` bytes,
44
+ // `onBody(raw)` gets the whole body at its end and nothing has reached the client. Past `max` the
45
+ // body is not a key rejection: the status, headers and every byte so far go to the client, the rest
46
+ // streams through, and `onOverflow(bytes)` runs at the end. Every write first checks that the client
47
+ // is still there: a hang-up destroys `res` (and, via rc-proxy.mjs, the upstream request), and a
48
+ // chunk already in flight must not write to it.
49
+ export function holdBack401(ur, res, { max = KEY_401_BODY_MAX, onBody, onOverflow }) {
50
+ const chunks = [];
51
+ let held = 0;
52
+ let overflow = false;
53
+ ur.on("data", (c) => {
54
+ if (overflow) {
55
+ if (!res.destroyed) res.write(c);
56
+ return;
57
+ }
58
+ chunks.push(c);
59
+ held += c.length;
60
+ if (held > max) {
61
+ overflow = true;
62
+ if (res.destroyed) return;
63
+ res.writeHead(ur.statusCode, ur.headers);
64
+ for (const x of chunks.splice(0)) res.write(x);
65
+ }
66
+ });
67
+ ur.on("end", () => {
68
+ if (overflow) {
69
+ if (!res.destroyed) res.end();
70
+ onOverflow(held);
71
+ return;
72
+ }
73
+ onBody(Buffer.concat(chunks));
74
+ });
75
+ }