@mnemom/mnemom 0.17.32 → 0.17.33
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/commands/agent-fuel.d.ts +9 -0
- package/dist/commands/agent-fuel.js +36 -2
- package/dist/commands/agent-run.d.ts +50 -0
- package/dist/commands/agent-run.js +221 -0
- package/dist/commands/agent-setup.d.ts +8 -3
- package/dist/commands/agent-setup.js +32 -9
- package/dist/commands/agent-share-fixture.d.ts +3 -1
- package/dist/commands/agent-share-fixture.js +11 -7
- package/dist/commands/agent-share-render.d.ts +23 -0
- package/dist/commands/agent-share-render.js +72 -0
- package/dist/commands/agent-share.d.ts +15 -2
- package/dist/commands/agent-share.js +30 -9
- package/dist/commands/agent.d.ts +19 -4
- package/dist/commands/agent.js +33 -13
- package/dist/index.js +20 -0
- package/dist/lib/api.js +17 -1
- package/dist/lib/auth.d.ts +6 -0
- package/dist/lib/auth.js +161 -14
- package/dist/lib/experimental.d.ts +1 -1
- package/dist/lib/experimental.js +13 -0
- package/dist/lib/oauth.d.ts +7 -0
- package/dist/lib/oauth.js +13 -0
- package/dist/lib/runner/bundle.d.ts +129 -0
- package/dist/lib/runner/bundle.js +319 -0
- package/dist/lib/runner/contract-header.d.ts +46 -0
- package/dist/lib/runner/contract-header.js +85 -0
- package/dist/lib/runner/executor-client.d.ts +71 -0
- package/dist/lib/runner/executor-client.js +97 -0
- package/dist/lib/runner/job.d.ts +70 -0
- package/dist/lib/runner/job.js +463 -0
- package/dist/lib/runner/loop.d.ts +45 -0
- package/dist/lib/runner/loop.js +104 -0
- package/dist/lib/runner/oidc.d.ts +38 -0
- package/dist/lib/runner/oidc.js +89 -0
- package/dist/lib/runner/options.d.ts +41 -0
- package/dist/lib/runner/options.js +47 -0
- package/dist/lib/runner/outcome.d.ts +47 -0
- package/dist/lib/runner/outcome.js +89 -0
- package/dist/lib/runner/phases.d.ts +49 -0
- package/dist/lib/runner/phases.js +99 -0
- package/dist/lib/runner/proxy.d.ts +45 -0
- package/dist/lib/runner/proxy.js +153 -0
- package/dist/lib/runner/settings.d.ts +31 -0
- package/dist/lib/runner/settings.js +44 -0
- package/dist/lib/statusline-feed.js +10 -11
- package/dist/lib/statusline.d.ts +6 -0
- package/dist/lib/statusline.js +19 -9
- package/dist/rc-proxy.mjs +69 -5
- package/package.json +2 -1
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runner's own model proxy (ruling 34.11, 34.12 (7); Doors W3.11).
|
|
3
|
+
*
|
|
4
|
+
* A served phase's Claude Code (and a code phase's child) points ANTHROPIC_BASE_URL here, on
|
|
5
|
+
* loopback. The proxy forwards each request to the gateway and decides every Mnemom header itself:
|
|
6
|
+
* any `x-mnemom-*` the phase sent is dropped, the lease key goes in X-Mnemom-Api-Key, and the job's
|
|
7
|
+
* sealed contract goes in `x-mnemom-contract` (exactly one, the sealed version). The phase never
|
|
8
|
+
* sees the lease key. A 409 `contract_mismatch` from the gateway (34.11) is reported to the job,
|
|
9
|
+
* which refetches and continues only on a higher sealed version, else fails closed.
|
|
10
|
+
*/
|
|
11
|
+
import { createServer, request as httpRequest, } from "node:http";
|
|
12
|
+
import { request as httpsRequest } from "node:https";
|
|
13
|
+
import { applySealedContract, CONTRACT_HEADER } from "./contract-header.js";
|
|
14
|
+
export const LEASE_KEY_HEADER = "x-mnemom-api-key";
|
|
15
|
+
/** Hop-by-hop headers (RFC 9110 7.6.1) and the ones the proxy owns. */
|
|
16
|
+
const DROP = new Set([
|
|
17
|
+
"connection",
|
|
18
|
+
"keep-alive",
|
|
19
|
+
"proxy-connection",
|
|
20
|
+
"transfer-encoding",
|
|
21
|
+
"upgrade",
|
|
22
|
+
"te",
|
|
23
|
+
"trailer",
|
|
24
|
+
"host",
|
|
25
|
+
"content-length",
|
|
26
|
+
]);
|
|
27
|
+
/** Outbound headers for one request: the phase's own, minus anything the proxy decides. */
|
|
28
|
+
export function outboundHeaders(incoming, state) {
|
|
29
|
+
const kept = {};
|
|
30
|
+
for (const [name, value] of Object.entries(incoming)) {
|
|
31
|
+
const lower = name.toLowerCase();
|
|
32
|
+
if (DROP.has(lower) || lower.startsWith("x-mnemom-"))
|
|
33
|
+
continue;
|
|
34
|
+
kept[lower] = value;
|
|
35
|
+
}
|
|
36
|
+
kept[LEASE_KEY_HEADER] = state.leaseKey;
|
|
37
|
+
return applySealedContract(kept, state.contractValue);
|
|
38
|
+
}
|
|
39
|
+
/** The largest 409 body the proxy reads for a contract mismatch. */
|
|
40
|
+
export const MISMATCH_BODY_MAX = 64 * 1024;
|
|
41
|
+
function mismatchVersion(body) {
|
|
42
|
+
try {
|
|
43
|
+
const b = JSON.parse(body);
|
|
44
|
+
const code = typeof b.error === "object" && b.error ? b.error.code : (b.error ?? b.code);
|
|
45
|
+
if (code !== "contract_mismatch")
|
|
46
|
+
return undefined;
|
|
47
|
+
const v = (typeof b.error === "object" && b.error ? b.error.details?.expected_version : undefined) ??
|
|
48
|
+
b.expected_version;
|
|
49
|
+
return Number.isSafeInteger(v) ? v : null;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** The client for the gateway's scheme: TLS for https, plain only for a local http gateway. */
|
|
56
|
+
export function requestFor(protocol) {
|
|
57
|
+
return protocol === "https:" ? httpsRequest : httpRequest;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The phase's answer when the gateway fails: a 502 if nothing was sent yet, else the connection is
|
|
61
|
+
* cut, so a truncated body is never mistaken for a whole one.
|
|
62
|
+
*/
|
|
63
|
+
export function upstreamFailed(res, e) {
|
|
64
|
+
if (res.headersSent) {
|
|
65
|
+
res.destroy(e);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
res.writeHead(502, { "content-type": "application/json" });
|
|
69
|
+
res.end(JSON.stringify({ error: { code: "runner_proxy_upstream", message: e.message } }));
|
|
70
|
+
}
|
|
71
|
+
export async function startModelProxy(gatewayUrl, state) {
|
|
72
|
+
const upstream = new URL(gatewayUrl);
|
|
73
|
+
const send = requestFor(upstream.protocol);
|
|
74
|
+
const basePath = upstream.pathname.replace(/\/+$/, "");
|
|
75
|
+
const forwarded = [];
|
|
76
|
+
const proxy = {
|
|
77
|
+
url: "",
|
|
78
|
+
forwarded,
|
|
79
|
+
contractMismatch: null,
|
|
80
|
+
close: async () => { },
|
|
81
|
+
};
|
|
82
|
+
const server = createServer((req, res) => {
|
|
83
|
+
const headers = outboundHeaders(req.headers, state);
|
|
84
|
+
const sent = headers[CONTRACT_HEADER];
|
|
85
|
+
const out = send({
|
|
86
|
+
protocol: upstream.protocol,
|
|
87
|
+
hostname: upstream.hostname,
|
|
88
|
+
port: upstream.port || undefined,
|
|
89
|
+
method: req.method,
|
|
90
|
+
path: `${basePath}${req.url ?? "/"}`,
|
|
91
|
+
headers,
|
|
92
|
+
}, (up) => {
|
|
93
|
+
const status = up.statusCode ?? 502;
|
|
94
|
+
forwarded.push({
|
|
95
|
+
method: req.method ?? "GET",
|
|
96
|
+
path: req.url ?? "/",
|
|
97
|
+
contract: sent,
|
|
98
|
+
status,
|
|
99
|
+
});
|
|
100
|
+
if (status === 409) {
|
|
101
|
+
// Buffered to read the mismatch, up to a cap; a larger body is relayed unread.
|
|
102
|
+
const chunks = [];
|
|
103
|
+
let size = 0;
|
|
104
|
+
up.on("error", (e) => upstreamFailed(res, e));
|
|
105
|
+
const onData = (c) => {
|
|
106
|
+
chunks.push(c);
|
|
107
|
+
size += c.length;
|
|
108
|
+
if (size <= MISMATCH_BODY_MAX)
|
|
109
|
+
return;
|
|
110
|
+
up.off("data", onData);
|
|
111
|
+
up.off("end", onEnd);
|
|
112
|
+
res.writeHead(status, up.headers);
|
|
113
|
+
for (const b of chunks)
|
|
114
|
+
res.write(b);
|
|
115
|
+
up.pipe(res);
|
|
116
|
+
};
|
|
117
|
+
const onEnd = () => {
|
|
118
|
+
const body = Buffer.concat(chunks);
|
|
119
|
+
const v = mismatchVersion(body.toString("utf8"));
|
|
120
|
+
if (v !== undefined)
|
|
121
|
+
proxy.contractMismatch = { expected_version: v };
|
|
122
|
+
res.writeHead(status, up.headers);
|
|
123
|
+
res.end(body);
|
|
124
|
+
};
|
|
125
|
+
up.on("data", onData);
|
|
126
|
+
up.on("end", onEnd);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
res.writeHead(status, up.headers);
|
|
130
|
+
// A gateway that drops mid-body errors the response, not the request.
|
|
131
|
+
up.on("error", (e) => upstreamFailed(res, e));
|
|
132
|
+
up.pipe(res);
|
|
133
|
+
});
|
|
134
|
+
out.on("error", (e) => {
|
|
135
|
+
forwarded.push({
|
|
136
|
+
method: req.method ?? "GET",
|
|
137
|
+
path: req.url ?? "/",
|
|
138
|
+
contract: sent,
|
|
139
|
+
status: 502,
|
|
140
|
+
});
|
|
141
|
+
upstreamFailed(res, e);
|
|
142
|
+
});
|
|
143
|
+
req.pipe(out);
|
|
144
|
+
});
|
|
145
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
146
|
+
const { port } = server.address();
|
|
147
|
+
proxy.url = `http://127.0.0.1:${port}`;
|
|
148
|
+
proxy.close = () => new Promise((resolve) => {
|
|
149
|
+
server.closeAllConnections?.();
|
|
150
|
+
server.close(() => resolve());
|
|
151
|
+
});
|
|
152
|
+
return proxy;
|
|
153
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin runner settings (Doors W-L13, W4.4): what the runner does on a Mnemom error, and how a job
|
|
3
|
+
* lands once its PR is open.
|
|
4
|
+
*
|
|
5
|
+
* Fail mode (decision 20, OD-2): served-phase jobs fail CLOSED by default. A Mnemom error (claim,
|
|
6
|
+
* bundle fetch, bundle verification, the job read, the gateway) stops the job and leaves it for
|
|
7
|
+
* the lease to lapse back to ready; nothing runs unverified or ungoverned. `open` lets a job that
|
|
8
|
+
* already holds a verified bundle carry on through a transient error on a later call. It never
|
|
9
|
+
* lets an unverified bundle run: verification failures stop the job in every mode. An unknown
|
|
10
|
+
* value reads as the default, closed, with a warning, so a typo can never weaken the runner.
|
|
11
|
+
*
|
|
12
|
+
* Merge strategy (W4.4, T15): the bundle may name `merge_on_green` (our fleet) or `external`
|
|
13
|
+
* (a person merges). A bundle that names none resolves to `external`: the job opens the PR and
|
|
14
|
+
* stops.
|
|
15
|
+
*/
|
|
16
|
+
import type { MergeStrategy, ServedPhaseBundle } from "./bundle.js";
|
|
17
|
+
export type RunnerFailMode = "closed" | "open";
|
|
18
|
+
export declare const RUNNER_FAIL_MODE_ENV = "MNEMOM_RUNNER_FAIL_MODE";
|
|
19
|
+
export declare const DEFAULT_RUNNER_FAIL_MODE: RunnerFailMode;
|
|
20
|
+
export interface ResolvedFailMode {
|
|
21
|
+
mode: RunnerFailMode;
|
|
22
|
+
/** Set when a value was given but not recognised (the default applied). */
|
|
23
|
+
warning?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The fail mode for one runner. Precedence: the `--fail-mode` flag, then MNEMOM_RUNNER_FAIL_MODE,
|
|
27
|
+
* then closed. Matching is case-insensitive and trims whitespace; an empty value counts as unset.
|
|
28
|
+
*/
|
|
29
|
+
export declare function resolveFailMode(flag: string | undefined, env: string | undefined): ResolvedFailMode;
|
|
30
|
+
/** The bundle's merge strategy, `external` when it names none. */
|
|
31
|
+
export declare function resolveMergeStrategy(bundle: Pick<ServedPhaseBundle, "merge_strategy">): MergeStrategy;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin runner settings (Doors W-L13, W4.4): what the runner does on a Mnemom error, and how a job
|
|
3
|
+
* lands once its PR is open.
|
|
4
|
+
*
|
|
5
|
+
* Fail mode (decision 20, OD-2): served-phase jobs fail CLOSED by default. A Mnemom error (claim,
|
|
6
|
+
* bundle fetch, bundle verification, the job read, the gateway) stops the job and leaves it for
|
|
7
|
+
* the lease to lapse back to ready; nothing runs unverified or ungoverned. `open` lets a job that
|
|
8
|
+
* already holds a verified bundle carry on through a transient error on a later call. It never
|
|
9
|
+
* lets an unverified bundle run: verification failures stop the job in every mode. An unknown
|
|
10
|
+
* value reads as the default, closed, with a warning, so a typo can never weaken the runner.
|
|
11
|
+
*
|
|
12
|
+
* Merge strategy (W4.4, T15): the bundle may name `merge_on_green` (our fleet) or `external`
|
|
13
|
+
* (a person merges). A bundle that names none resolves to `external`: the job opens the PR and
|
|
14
|
+
* stops.
|
|
15
|
+
*/
|
|
16
|
+
export const RUNNER_FAIL_MODE_ENV = "MNEMOM_RUNNER_FAIL_MODE";
|
|
17
|
+
export const DEFAULT_RUNNER_FAIL_MODE = "closed";
|
|
18
|
+
/**
|
|
19
|
+
* The fail mode for one runner. Precedence: the `--fail-mode` flag, then MNEMOM_RUNNER_FAIL_MODE,
|
|
20
|
+
* then closed. Matching is case-insensitive and trims whitespace; an empty value counts as unset.
|
|
21
|
+
*/
|
|
22
|
+
export function resolveFailMode(flag, env) {
|
|
23
|
+
for (const [source, raw] of [
|
|
24
|
+
["--fail-mode", flag],
|
|
25
|
+
[RUNNER_FAIL_MODE_ENV, env],
|
|
26
|
+
]) {
|
|
27
|
+
if (raw === undefined)
|
|
28
|
+
continue;
|
|
29
|
+
const v = raw.trim().toLowerCase();
|
|
30
|
+
if (v === "")
|
|
31
|
+
continue;
|
|
32
|
+
if (v === "closed" || v === "open")
|
|
33
|
+
return { mode: v };
|
|
34
|
+
return {
|
|
35
|
+
mode: DEFAULT_RUNNER_FAIL_MODE,
|
|
36
|
+
warning: `${source}=${JSON.stringify(raw)} is not closed or open; failing closed`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return { mode: DEFAULT_RUNNER_FAIL_MODE };
|
|
40
|
+
}
|
|
41
|
+
/** The bundle's merge strategy, `external` when it names none. */
|
|
42
|
+
export function resolveMergeStrategy(bundle) {
|
|
43
|
+
return bundle.merge_strategy ?? "external";
|
|
44
|
+
}
|
|
@@ -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,
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
}
|
package/dist/lib/statusline.d.ts
CHANGED
|
@@ -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". */
|
package/dist/lib/statusline.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/rc-proxy.mjs
CHANGED
|
@@ -13,6 +13,15 @@
|
|
|
13
13
|
//
|
|
14
14
|
// WHAT IT DOES
|
|
15
15
|
// * Listens on 127.0.0.1 (ephemeral port) as an HTTP CONNECT proxy.
|
|
16
|
+
// * Serves only the caller that holds this session's proxy credential (MNE-9233). Loopback is not
|
|
17
|
+
// a boundary: every local process, any other user and every other seat's session can reach the
|
|
18
|
+
// port, and this proxy injects the owner's keys. The launcher mints the credential per session,
|
|
19
|
+
// hands it here on stdin and to Claude Code as the userinfo of HTTPS_PROXY
|
|
20
|
+
// (`http://mnemom:<credential>@127.0.0.1:<port>`), which Claude Code sends as
|
|
21
|
+
// `Proxy-Authorization: Basic` on CONNECT (measured on Claude Code 2.1.312) and on absolute-form
|
|
22
|
+
// requests (its bundled axios builds the header from the proxy URL's userinfo). Any other caller
|
|
23
|
+
// gets 407 before anything is tunnelled, terminated or injected. `Proxy-Authorization` is
|
|
24
|
+
// hop-by-hop: it is never forwarded upstream or to the gateway.
|
|
16
25
|
// * Terminates TLS for api.anthropic.com ONLY, with an ephemeral CA (397 days, key held in
|
|
17
26
|
// memory only) minted at startup into a private 0700 dir under ~/.mnemom/rc (removed on
|
|
18
27
|
// exit; see rc-state.mjs for why not the OS temp dir), and a 7-day leaf re-minted every 24 h
|
|
@@ -52,8 +61,9 @@
|
|
|
52
61
|
// (diagnostic only: it forwards continue-deltas the API rejects).
|
|
53
62
|
//
|
|
54
63
|
// SECRETS
|
|
55
|
-
// Arrive on stdin as
|
|
56
|
-
// empty), OpenAI egress key (may be empty)
|
|
64
|
+
// Arrive on stdin as four newline-terminated lines: Anthropic key, Mnemom key (may be
|
|
65
|
+
// empty), OpenAI egress key (may be empty), the per-session proxy credential (required).
|
|
66
|
+
// Never argv, never env, never disk, never logged.
|
|
57
67
|
// The log (MNEMOM_AGENT_RC_PROXY_LOG, else stderr) carries method/path/status/latency and the
|
|
58
68
|
// gateway's `x-mnemom-agent` / `x-mnemom-goal-verdict` response headers only. Gateway error
|
|
59
69
|
// bodies (status ≥ 400) are logged truncated so a 4xx is diagnosable (compressed ones as
|
|
@@ -84,7 +94,8 @@
|
|
|
84
94
|
// MNEMOM_AGENT_TURN_HEADER 0/off: no x-mnemom-turn header (default on in implicit mode;
|
|
85
95
|
// see turn-origin.mjs)
|
|
86
96
|
//
|
|
87
|
-
// stdout: exactly one line, `READY <port> <ca.pem path>`, once listening. Node ≥ 18,
|
|
97
|
+
// stdout: exactly one line, `READY <port> <ca.pem path>`, once listening (no secret on it). Node ≥ 18,
|
|
98
|
+
// no deps.
|
|
88
99
|
import http from "node:http";
|
|
89
100
|
import https from "node:https";
|
|
90
101
|
import net from "node:net";
|
|
@@ -165,7 +176,7 @@ const log = (line, retry = true) => {
|
|
|
165
176
|
process.stderr.write(s);
|
|
166
177
|
};
|
|
167
178
|
|
|
168
|
-
// --- secrets:
|
|
179
|
+
// --- secrets: four lines on stdin -------------------------------------------------------
|
|
169
180
|
let secrets;
|
|
170
181
|
try {
|
|
171
182
|
const lines = fs.readFileSync(0, "utf8").split("\n");
|
|
@@ -173,15 +184,34 @@ try {
|
|
|
173
184
|
anthropic: (lines[0] || "").trim(),
|
|
174
185
|
mnemom: (lines[1] || "").trim(),
|
|
175
186
|
openai: (lines[2] || "").trim(),
|
|
187
|
+
proxy: (lines[3] || "").trim(),
|
|
176
188
|
};
|
|
177
189
|
} catch {
|
|
178
|
-
die("expected
|
|
190
|
+
die("expected four lines on stdin (anthropic, mnemom, openai, proxy credential)");
|
|
179
191
|
}
|
|
180
192
|
if (AGENT_ID && !secrets.mnemom)
|
|
181
193
|
die("a pinned agent (MNEMOM_AGENT_RC_AGENT_ID) needs the Mnemom key (stdin line 2)");
|
|
182
194
|
if (SUBSCRIPTION) {
|
|
183
195
|
if (!secrets.mnemom) die("subscription login needs the Mnemom key (stdin line 2)");
|
|
184
196
|
} else if (!secrets.anthropic) die("the Anthropic key (stdin line 1) is empty");
|
|
197
|
+
// Fail closed: with no credential there is nothing to check a caller against.
|
|
198
|
+
if (!secrets.proxy) die("the proxy credential (stdin line 4) is empty");
|
|
199
|
+
|
|
200
|
+
// --- caller authentication (MNE-9233; see WHAT IT DOES) -------------------------------------
|
|
201
|
+
// `Proxy-Authorization: Basic base64("mnemom:<credential>")`, compared in constant time. The
|
|
202
|
+
// header value is never logged.
|
|
203
|
+
const PROXY_USER = "mnemom";
|
|
204
|
+
const PROXY_AUTH_EXPECTED = Buffer.from(`${PROXY_USER}:${secrets.proxy}`, "utf8");
|
|
205
|
+
function callerAuthorized(header) {
|
|
206
|
+
const m = /^basic\s+([A-Za-z0-9+/]+={0,2})\s*$/i.exec(typeof header === "string" ? header : "");
|
|
207
|
+
if (!m) return false;
|
|
208
|
+
const got = Buffer.from(m[1], "base64");
|
|
209
|
+
return (
|
|
210
|
+
got.length === PROXY_AUTH_EXPECTED.length && crypto.timingSafeEqual(got, PROXY_AUTH_EXPECTED)
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
const PROXY_REALM = 'Basic realm="mnemom-code-rc-proxy"';
|
|
214
|
+
const PROXY_407_NOTE = "407 proxy authentication required (no or wrong Proxy-Authorization)";
|
|
185
215
|
|
|
186
216
|
// --- ephemeral CA + rotating leaf for api.anthropic.com ---------------------------------
|
|
187
217
|
// Private 0700 dir under ~/.mnemom/rc (rc-state.mjs), removed on exit. Claude Code reads NODE_EXTRA_CA_CERTS
|
|
@@ -389,6 +419,7 @@ const upstreamAgent = new https.Agent({ keepAlive: true, maxSockets: 32 });
|
|
|
389
419
|
const HOP = new Set([
|
|
390
420
|
"connection",
|
|
391
421
|
"proxy-connection",
|
|
422
|
+
"proxy-authorization", // this session's proxy credential: never leaves the proxy
|
|
392
423
|
"keep-alive",
|
|
393
424
|
"upgrade",
|
|
394
425
|
"host",
|
|
@@ -490,9 +521,34 @@ function rejectThread(res, tag, thread) {
|
|
|
490
521
|
);
|
|
491
522
|
}
|
|
492
523
|
|
|
524
|
+
// 407 for an absolute-form request without this session's credential: nothing is parsed,
|
|
525
|
+
// forwarded or injected, and the connection is closed.
|
|
526
|
+
function refusePlain(req, res) {
|
|
527
|
+
req.resume(); // discard any body
|
|
528
|
+
let host = "-";
|
|
529
|
+
try {
|
|
530
|
+
host = new URL(req.url).host || "-";
|
|
531
|
+
} catch {
|
|
532
|
+
/* not absolute-form: logged as "-" */
|
|
533
|
+
}
|
|
534
|
+
res.writeHead(407, {
|
|
535
|
+
"proxy-authenticate": PROXY_REALM,
|
|
536
|
+
"content-type": "text/plain",
|
|
537
|
+
connection: "close",
|
|
538
|
+
});
|
|
539
|
+
res.end("mnemom-code-rc-proxy: proxy authentication required\n");
|
|
540
|
+
log(`${req.method} ${host} -> ${PROXY_407_NOTE}`);
|
|
541
|
+
}
|
|
542
|
+
|
|
493
543
|
// One handler serves both the MITM'd TLS stream (origin-form URLs, host = api.anthropic.com)
|
|
494
544
|
// and absolute-form plaintext requests sent straight to the proxy port.
|
|
495
545
|
function handle(req, res, viaPlain) {
|
|
546
|
+
// A request on the proxy port must carry the credential. One inside the MITM'd TLS stream
|
|
547
|
+
// already did, on the CONNECT that opened the tunnel.
|
|
548
|
+
if (viaPlain && !callerAuthorized(req.headers["proxy-authorization"])) {
|
|
549
|
+
refusePlain(req, res);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
496
552
|
let host = MITM_HOST,
|
|
497
553
|
port = 443,
|
|
498
554
|
urlPath = req.url,
|
|
@@ -681,6 +737,14 @@ proxy.on("connect", (req, sock, head) => {
|
|
|
681
737
|
const [host, portStr] = req.url.split(":");
|
|
682
738
|
const port = Number(portStr || 443);
|
|
683
739
|
sock.on("error", () => {});
|
|
740
|
+
if (!callerAuthorized(req.headers["proxy-authorization"])) {
|
|
741
|
+
// Before the MITM arm and before any upstream socket: an unauthenticated caller gets neither.
|
|
742
|
+
sock.end(
|
|
743
|
+
`HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: ${PROXY_REALM}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n`,
|
|
744
|
+
);
|
|
745
|
+
log(`CONNECT ${req.url} -> ${PROXY_407_NOTE}`);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
684
748
|
if (host === MITM_HOST && port === 443) {
|
|
685
749
|
// A timer can run late (laptop sleep): never hand out a leaf that is about to expire.
|
|
686
750
|
if (leaves.current().notAfter - Date.now() < LEAF_MIN_LEFT_MS) rotateLeaf("expiring");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mnemom/mnemom",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.33",
|
|
4
4
|
"description": "Transparent AI agent tracing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"smol-toml": "^1.9.0"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
|
+
"@mnemom/aip-verifier": "https://github.com/mnemom/aip/releases/download/aip-verifier-v0.6.0/mnemom-aip-verifier-0.6.0.tgz",
|
|
27
28
|
"@types/js-yaml": "^4.0.9",
|
|
28
29
|
"@types/node": "^20.10.0",
|
|
29
30
|
"@vitest/coverage-v8": "^4.1.12",
|