@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,89 @@
1
+ /**
2
+ * `agent run --once --oidc`'s credential (ruling 34.12 (5), (6); 34.14 (2); Doors W4.12).
3
+ *
4
+ * The runner fetches the GitHub Actions ID token itself, for audience `mnemom-executor`, and posts
5
+ * it as {oidc_token} to exec_oidc_exchange, whose auth is github_oidc (the token in the body, no
6
+ * key header). The answer is a one-lease org-scope executor key, used for that single job. Every
7
+ * failure fails closed, whatever --fail-mode says, because no job is held yet: an exchange refusal
8
+ * is oidc_<code> for S1's OIDC_EXCHANGE_REFUSALS, anything else oidc_unavailable. The token never
9
+ * reaches argv, a log line or an outcome.
10
+ *
11
+ * S1 names the route but not its answer's field; `executor_key` is this runner's reading, open
12
+ * with the Durable lead until S1 freezes it (no api handler exists yet to read it from).
13
+ */
14
+ import { OIDC_EXCHANGE_AUDIENCE, OIDC_EXCHANGE_REFUSALS, routePath, } from "../../vendor/durable-contract/index.js";
15
+ import { errorCode } from "./executor-client.js";
16
+ const unavailable = (detail) => ({
17
+ ok: false,
18
+ reason: "oidc_unavailable",
19
+ detail,
20
+ });
21
+ async function readJson(res) {
22
+ try {
23
+ return JSON.parse(await res.text());
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ /** The Actions ID token for `mnemom-executor`, or why there is none. */
30
+ export async function actionsIdToken(fetch, requestUrl, requestToken) {
31
+ let url;
32
+ try {
33
+ url = new URL(requestUrl);
34
+ }
35
+ catch {
36
+ return { ok: false, detail: "ACTIONS_ID_TOKEN_REQUEST_URL is not a URL" };
37
+ }
38
+ url.searchParams.set("audience", OIDC_EXCHANGE_AUDIENCE);
39
+ try {
40
+ const res = await fetch(url.toString(), {
41
+ method: "GET",
42
+ headers: { accept: "application/json", authorization: `bearer ${requestToken}` },
43
+ });
44
+ if (res.status !== 200)
45
+ return { ok: false, detail: `ID token request answered ${res.status}` };
46
+ const value = (await readJson(res))?.value;
47
+ if (typeof value !== "string" || value === "") {
48
+ return { ok: false, detail: "ID token answer has no value" };
49
+ }
50
+ return { ok: true, token: value };
51
+ }
52
+ catch (e) {
53
+ return { ok: false, detail: `ID token request failed: ${e.message}` };
54
+ }
55
+ }
56
+ /** Fetch the ID token and exchange it for a one-lease executor key. */
57
+ export async function exchangeOidc(opts) {
58
+ const id = await actionsIdToken(opts.fetch, opts.requestUrl, opts.requestToken);
59
+ if (!id.ok)
60
+ return unavailable(id.detail);
61
+ const base = opts.apiUrl.replace(/\/+$/, "");
62
+ let res;
63
+ try {
64
+ res = await opts.fetch(`${base}${routePath("exec_oidc_exchange")}`, {
65
+ method: "POST",
66
+ headers: { accept: "application/json", "content-type": "application/json" },
67
+ body: JSON.stringify({ oidc_token: id.token }),
68
+ });
69
+ }
70
+ catch (e) {
71
+ return unavailable(`exchange failed: ${e.message}`);
72
+ }
73
+ const body = await readJson(res);
74
+ if (res.status === 200) {
75
+ const key = body?.executor_key;
76
+ if (typeof key === "string" && key !== "")
77
+ return { ok: true, executorKey: key };
78
+ return unavailable("exchange answer has no executor_key");
79
+ }
80
+ const code = errorCode(body);
81
+ if (code !== null && OIDC_EXCHANGE_REFUSALS.includes(code)) {
82
+ return {
83
+ ok: false,
84
+ reason: `oidc_${code}`,
85
+ detail: `exchange refused: ${code}`,
86
+ };
87
+ }
88
+ return unavailable(`exchange answered ${res.status}${code ? ` ${code}` : ""}`);
89
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * `mnemom agent run`'s options and credential (ruling 34.12 (1), (2), (5), (6)).
3
+ *
4
+ * Every usage or config error is decided here, before any network call, so it exits 2 with a
5
+ * message on stderr and no outcome line, with or without --json.
6
+ */
7
+ import { type RunnerFailMode } from "./settings.js";
8
+ export declare const EXECUTOR_KEY_ENV = "MNEMOM_EXECUTOR_KEY";
9
+ export declare const ACTIONS_ID_TOKEN_URL_ENV = "ACTIONS_ID_TOKEN_REQUEST_URL";
10
+ export declare const ACTIONS_ID_TOKEN_TOKEN_ENV = "ACTIONS_ID_TOKEN_REQUEST_TOKEN";
11
+ export interface RunFlags {
12
+ watch?: boolean;
13
+ once?: boolean;
14
+ failMode?: string;
15
+ json?: boolean;
16
+ oidc?: boolean;
17
+ }
18
+ export type RunCredential = {
19
+ kind: "executor_key";
20
+ key: string;
21
+ } | {
22
+ kind: "oidc";
23
+ requestUrl: string;
24
+ requestToken: string;
25
+ };
26
+ export interface RunPlan {
27
+ form: "watch" | "once";
28
+ failMode: RunnerFailMode;
29
+ json: boolean;
30
+ credential: RunCredential;
31
+ /** Warnings to print on stderr (an unknown fail mode reads as closed). */
32
+ warnings: string[];
33
+ }
34
+ export type RunPlanResult = {
35
+ ok: true;
36
+ plan: RunPlan;
37
+ } | {
38
+ ok: false;
39
+ usage: string;
40
+ };
41
+ export declare function resolveRunPlan(flags: RunFlags, env: NodeJS.ProcessEnv): RunPlanResult;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `mnemom agent run`'s options and credential (ruling 34.12 (1), (2), (5), (6)).
3
+ *
4
+ * Every usage or config error is decided here, before any network call, so it exits 2 with a
5
+ * message on stderr and no outcome line, with or without --json.
6
+ */
7
+ import { resolveFailMode, RUNNER_FAIL_MODE_ENV } from "./settings.js";
8
+ export const EXECUTOR_KEY_ENV = "MNEMOM_EXECUTOR_KEY";
9
+ export const ACTIONS_ID_TOKEN_URL_ENV = "ACTIONS_ID_TOKEN_REQUEST_URL";
10
+ export const ACTIONS_ID_TOKEN_TOKEN_ENV = "ACTIONS_ID_TOKEN_REQUEST_TOKEN";
11
+ const usage = (message) => ({ ok: false, usage: message });
12
+ const set = (v) => v !== undefined && v.trim() !== "";
13
+ export function resolveRunPlan(flags, env) {
14
+ if (Boolean(flags.watch) === Boolean(flags.once)) {
15
+ return usage("mnemom agent run: give exactly one of --watch or --once");
16
+ }
17
+ const form = flags.watch ? "watch" : "once";
18
+ if (flags.oidc && form === "watch") {
19
+ return usage("mnemom agent run: --oidc is valid only with --once");
20
+ }
21
+ const fail = resolveFailMode(flags.failMode, env[RUNNER_FAIL_MODE_ENV]);
22
+ const warnings = fail.warning ? [`mnemom agent run: ${fail.warning}`] : [];
23
+ let credential;
24
+ if (flags.oidc) {
25
+ if (set(env[EXECUTOR_KEY_ENV])) {
26
+ return usage(`mnemom agent run: --oidc with ${EXECUTOR_KEY_ENV} set holds two credentials; unset one`);
27
+ }
28
+ const requestUrl = env[ACTIONS_ID_TOKEN_URL_ENV];
29
+ const requestToken = env[ACTIONS_ID_TOKEN_TOKEN_ENV];
30
+ if (!set(requestUrl) || !set(requestToken)) {
31
+ return usage(`mnemom agent run: --oidc needs ${ACTIONS_ID_TOKEN_URL_ENV} and ${ACTIONS_ID_TOKEN_TOKEN_ENV} ` +
32
+ "(a GitHub Actions job with `permissions: id-token: write`)");
33
+ }
34
+ credential = { kind: "oidc", requestUrl, requestToken };
35
+ }
36
+ else {
37
+ const key = env[EXECUTOR_KEY_ENV];
38
+ if (!set(key)) {
39
+ return usage(`mnemom agent run: no credential; set ${EXECUTOR_KEY_ENV} or use --once --oidc`);
40
+ }
41
+ credential = { kind: "executor_key", key: key.trim() };
42
+ }
43
+ return {
44
+ ok: true,
45
+ plan: { form, failMode: fail.mode, json: Boolean(flags.json), credential, warnings },
46
+ };
47
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The thin runner's v1 outcome line (ruling 34.12 (6), (8); Doors W-L13).
3
+ *
4
+ * Each job ends with exactly one JSON line, the last on stdout for that job. The fields, outcomes,
5
+ * reasons and exit codes are S1's (shared/durable-contract doors.ts), imported and never restated:
6
+ * a reason outside S1's closed lists is a bug in the runner, so `outcomeLine` throws on one rather
7
+ * than print a line no consumer can read.
8
+ */
9
+ import { type RUNNER_MERGE_STRATEGIES, RUNNER_REFUSED_REASONS, type RunnerOutcome } from "../../vendor/durable-contract/index.js";
10
+ export type RefusedReason = (typeof RUNNER_REFUSED_REASONS)[number];
11
+ export type MergeStrategyName = (typeof RUNNER_MERGE_STRATEGIES)[number];
12
+ export interface OutcomeFields {
13
+ outcome: RunnerOutcome;
14
+ session_id: string | null;
15
+ lease_epoch: number | null;
16
+ contract_sha256: string | null;
17
+ contract_version: number | null;
18
+ merge_strategy: MergeStrategyName | null;
19
+ pr_url: string | null;
20
+ reason: string | null;
21
+ }
22
+ /** What the runner knows of its job so far; each field fills in as the job gets further. */
23
+ export interface JobFacts {
24
+ session_id?: string | null;
25
+ lease_epoch?: number | null;
26
+ contract_sha256?: string | null;
27
+ contract_version?: number | null;
28
+ merge_strategy?: MergeStrategyName | null;
29
+ pr_url?: string | null;
30
+ }
31
+ /** `phase_<order>_<name>`: the name lowercased, anything outside [a-z0-9_] replaced by `_`. */
32
+ export declare function phaseReason(order: number, name: string): string;
33
+ export declare function isRefusedReason(v: string): v is RefusedReason;
34
+ /** done: every field of the job, no reason. */
35
+ export declare function doneOutcome(facts: JobFacts): OutcomeFields;
36
+ export declare function noJobOutcome(): OutcomeFields;
37
+ /** failed: the job's own failure; pr_url only when the PR was opened before it. */
38
+ export declare function failedOutcome(facts: JobFacts, reason: string): OutcomeFields;
39
+ /**
40
+ * refused: session fields only once a session was claimed, merge_strategy only once a verified
41
+ * bundle was held (the caller leaves it unset until then), never a pr_url.
42
+ */
43
+ export declare function refusedOutcome(facts: JobFacts, reason: RefusedReason): OutcomeFields;
44
+ /** The outcome line: S1's fields in S1's order, each present (null, never omitted). */
45
+ export declare function outcomeLine(o: OutcomeFields): string;
46
+ export declare function exitCodeFor(outcome: RunnerOutcome): number;
47
+ export declare const USAGE_EXIT_CODE: 2;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The thin runner's v1 outcome line (ruling 34.12 (6), (8); Doors W-L13).
3
+ *
4
+ * Each job ends with exactly one JSON line, the last on stdout for that job. The fields, outcomes,
5
+ * reasons and exit codes are S1's (shared/durable-contract doors.ts), imported and never restated:
6
+ * a reason outside S1's closed lists is a bug in the runner, so `outcomeLine` throws on one rather
7
+ * than print a line no consumer can read.
8
+ */
9
+ import { RUNNER_EXIT_CODES, RUNNER_FAILED_PHASE_RE, RUNNER_FAILED_REASONS, RUNNER_OUTCOME_FIELDS, RUNNER_REFUSED_REASONS, } from "../../vendor/durable-contract/index.js";
10
+ /** `phase_<order>_<name>`: the name lowercased, anything outside [a-z0-9_] replaced by `_`. */
11
+ export function phaseReason(order, name) {
12
+ return `phase_${order}_${name.toLowerCase().replace(/[^a-z0-9_]/g, "_")}`;
13
+ }
14
+ export function isRefusedReason(v) {
15
+ return RUNNER_REFUSED_REASONS.includes(v);
16
+ }
17
+ function reasonOk(outcome, reason) {
18
+ if (outcome === "done" || outcome === "no_job")
19
+ return reason === null;
20
+ if (reason === null)
21
+ return false;
22
+ if (outcome === "failed")
23
+ return (RUNNER_FAILED_REASONS.includes(reason) ||
24
+ RUNNER_FAILED_PHASE_RE.test(reason));
25
+ return isRefusedReason(reason);
26
+ }
27
+ const pick = (facts, keep) => ({
28
+ session_id: keep ? (facts.session_id ?? null) : null,
29
+ lease_epoch: keep ? (facts.lease_epoch ?? null) : null,
30
+ contract_sha256: keep ? (facts.contract_sha256 ?? null) : null,
31
+ contract_version: keep ? (facts.contract_version ?? null) : null,
32
+ });
33
+ /** done: every field of the job, no reason. */
34
+ export function doneOutcome(facts) {
35
+ return {
36
+ outcome: "done",
37
+ ...pick(facts, true),
38
+ merge_strategy: facts.merge_strategy ?? null,
39
+ pr_url: facts.pr_url ?? null,
40
+ reason: null,
41
+ };
42
+ }
43
+ export function noJobOutcome() {
44
+ return {
45
+ outcome: "no_job",
46
+ ...pick({}, false),
47
+ merge_strategy: null,
48
+ pr_url: null,
49
+ reason: null,
50
+ };
51
+ }
52
+ /** failed: the job's own failure; pr_url only when the PR was opened before it. */
53
+ export function failedOutcome(facts, reason) {
54
+ return {
55
+ outcome: "failed",
56
+ ...pick(facts, true),
57
+ merge_strategy: facts.merge_strategy ?? null,
58
+ pr_url: facts.pr_url ?? null,
59
+ reason,
60
+ };
61
+ }
62
+ /**
63
+ * refused: session fields only once a session was claimed, merge_strategy only once a verified
64
+ * bundle was held (the caller leaves it unset until then), never a pr_url.
65
+ */
66
+ export function refusedOutcome(facts, reason) {
67
+ return {
68
+ outcome: "refused",
69
+ ...pick(facts, facts.session_id != null),
70
+ merge_strategy: facts.merge_strategy ?? null,
71
+ pr_url: null,
72
+ reason,
73
+ };
74
+ }
75
+ /** The outcome line: S1's fields in S1's order, each present (null, never omitted). */
76
+ export function outcomeLine(o) {
77
+ if (!reasonOk(o.outcome, o.reason)) {
78
+ throw new Error(`runner bug: reason ${JSON.stringify(o.reason)} is not valid for ${o.outcome}`);
79
+ }
80
+ const values = { v: 1, ...o };
81
+ const out = {};
82
+ for (const f of RUNNER_OUTCOME_FIELDS)
83
+ out[f] = values[f] ?? null;
84
+ return JSON.stringify(out);
85
+ }
86
+ export function exitCodeFor(outcome) {
87
+ return RUNNER_EXIT_CODES[outcome];
88
+ }
89
+ export const USAGE_EXIT_CODE = RUNNER_EXIT_CODES.usage;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Running a verified bundle's phases (Doors W-L13, W3.11).
3
+ *
4
+ * The runner holds no prompts: each phase's prompt, capability ids or code come only from the
5
+ * verified bundle, in its order. A capabilities phase runs headless Claude Code with exactly the
6
+ * tools its capability ids grant (an unknown id fails the phase; nothing is ever widened). A code
7
+ * phase runs the bundle's JavaScript in a child node. Both get a scrubbed environment: a fresh
8
+ * HOME, the model URL of the runner's own proxy and the model credential, never the executor key,
9
+ * the lease key or a GitHub token.
10
+ */
11
+ import type { ServedPhase } from "./bundle.js";
12
+ /** W-L12's capability ids (workflow mnemom.door2 v1), and the Claude Code tools each grants. */
13
+ export declare const CAPABILITY_TOOLS: Readonly<Record<string, {
14
+ tools: string[];
15
+ allowed: string[];
16
+ }>>;
17
+ /** Env names a phase may inherit: the model credential and a working PATH. Nothing else. */
18
+ export declare const PHASE_ENV_PASSTHROUGH: readonly ["PATH", "ANTHROPIC_API_KEY", "LANG", "TZ"];
19
+ export type ToolGrant = {
20
+ ok: true;
21
+ tools: string[];
22
+ allowed: string[];
23
+ opensPr: boolean;
24
+ } | {
25
+ ok: false;
26
+ unknown: string[];
27
+ };
28
+ export declare function toolsFor(capabilityIds: readonly string[]): ToolGrant;
29
+ export declare function phaseEnv(parent: NodeJS.ProcessEnv, home: string, modelUrl: string): Record<string, string>;
30
+ /** One child process to start: the command, its argv and stdin. */
31
+ export interface PhaseInvocation {
32
+ command: string;
33
+ args: string[];
34
+ stdin: string | null;
35
+ }
36
+ /** argv for one capabilities phase; the prompt is the bundle's, passed through unchanged. */
37
+ export declare function claudeInvocation(prompt: string, grant: Extract<ToolGrant, {
38
+ ok: true;
39
+ }>): PhaseInvocation;
40
+ export declare function codeInvocation(source: string): PhaseInvocation;
41
+ export type PhasePlan = {
42
+ ok: true;
43
+ invocation: PhaseInvocation;
44
+ opensPr: boolean;
45
+ } | {
46
+ ok: false;
47
+ detail: string;
48
+ };
49
+ export declare function planPhase(phase: ServedPhase): PhasePlan;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Running a verified bundle's phases (Doors W-L13, W3.11).
3
+ *
4
+ * The runner holds no prompts: each phase's prompt, capability ids or code come only from the
5
+ * verified bundle, in its order. A capabilities phase runs headless Claude Code with exactly the
6
+ * tools its capability ids grant (an unknown id fails the phase; nothing is ever widened). A code
7
+ * phase runs the bundle's JavaScript in a child node. Both get a scrubbed environment: a fresh
8
+ * HOME, the model URL of the runner's own proxy and the model credential, never the executor key,
9
+ * the lease key or a GitHub token.
10
+ */
11
+ /** W-L12's capability ids (workflow mnemom.door2 v1), and the Claude Code tools each grants. */
12
+ export const CAPABILITY_TOOLS = Object.freeze({
13
+ "repo.read": { tools: ["Read", "Glob", "Grep"], allowed: ["Read", "Glob", "Grep"] },
14
+ "repo.write": { tools: ["Edit", "Write"], allowed: ["Edit", "Write"] },
15
+ "shell.run": { tools: ["Bash"], allowed: ["Bash"] },
16
+ "git.commit": {
17
+ tools: ["Bash"],
18
+ allowed: ["Bash(git add:*)", "Bash(git commit:*)", "Bash(git status:*)", "Bash(git diff:*)"],
19
+ },
20
+ // The runner opens the PR itself once every phase has passed; the model gets no tool for it.
21
+ "pr.open": { tools: [], allowed: [] },
22
+ });
23
+ /** Env names a phase may inherit: the model credential and a working PATH. Nothing else. */
24
+ export const PHASE_ENV_PASSTHROUGH = ["PATH", "ANTHROPIC_API_KEY", "LANG", "TZ"];
25
+ export function toolsFor(capabilityIds) {
26
+ const unknown = capabilityIds.filter((id) => !Object.hasOwn(CAPABILITY_TOOLS, id));
27
+ if (unknown.length > 0)
28
+ return { ok: false, unknown };
29
+ const tools = new Set();
30
+ const allowed = new Set();
31
+ for (const id of capabilityIds) {
32
+ for (const t of CAPABILITY_TOOLS[id].tools)
33
+ tools.add(t);
34
+ for (const a of CAPABILITY_TOOLS[id].allowed)
35
+ allowed.add(a);
36
+ }
37
+ // A plain Bash grant already covers the git patterns.
38
+ if (allowed.has("Bash"))
39
+ for (const a of [...allowed])
40
+ if (a.startsWith("Bash("))
41
+ allowed.delete(a);
42
+ return {
43
+ ok: true,
44
+ tools: [...tools].sort(),
45
+ allowed: [...allowed].sort(),
46
+ opensPr: capabilityIds.includes("pr.open"),
47
+ };
48
+ }
49
+ export function phaseEnv(parent, home, modelUrl) {
50
+ const env = {};
51
+ for (const name of PHASE_ENV_PASSTHROUGH) {
52
+ const v = parent[name];
53
+ if (v !== undefined)
54
+ env[name] = v;
55
+ }
56
+ env.HOME = home;
57
+ env.ANTHROPIC_BASE_URL = modelUrl;
58
+ env.MNEMOM_RUNNER_MODEL_URL = modelUrl;
59
+ env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
60
+ return env;
61
+ }
62
+ /** argv for one capabilities phase; the prompt is the bundle's, passed through unchanged. */
63
+ export function claudeInvocation(prompt, grant) {
64
+ const args = [
65
+ "-p",
66
+ "--output-format",
67
+ "json",
68
+ "--permission-mode",
69
+ "dontAsk",
70
+ "--setting-sources",
71
+ "user",
72
+ "--strict-mcp-config",
73
+ "--no-session-persistence",
74
+ "--tools",
75
+ grant.tools.length > 0 ? grant.tools.join(",") : "",
76
+ ];
77
+ if (grant.allowed.length > 0)
78
+ args.push("--allowedTools", grant.allowed.join(","));
79
+ return { command: "claude", args, stdin: prompt };
80
+ }
81
+ export function codeInvocation(source) {
82
+ return { command: process.execPath, args: ["--input-type=module", "-"], stdin: source };
83
+ }
84
+ export function planPhase(phase) {
85
+ if (phase.runner.kind === "code") {
86
+ if (phase.runner.language !== "javascript") {
87
+ return {
88
+ ok: false,
89
+ detail: `code phase language ${phase.runner.language} is not javascript`,
90
+ };
91
+ }
92
+ return { ok: true, invocation: codeInvocation(phase.runner.source), opensPr: false };
93
+ }
94
+ const grant = toolsFor(phase.runner.capability_ids);
95
+ if (!grant.ok) {
96
+ return { ok: false, detail: `unknown capability ids: ${grant.unknown.join(", ")}` };
97
+ }
98
+ return { ok: true, invocation: claudeInvocation(phase.prompt, grant), opensPr: grant.opensPr };
99
+ }
@@ -0,0 +1,45 @@
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 { request as httpRequest, type IncomingMessage, type ServerResponse } from "node:http";
12
+ export declare const LEASE_KEY_HEADER = "x-mnemom-api-key";
13
+ export interface ProxyState {
14
+ /** The sealed header value to send; swapped in place after an accepted amend. */
15
+ contractValue: string;
16
+ leaseKey: string;
17
+ }
18
+ export interface ForwardedRequest {
19
+ method: string;
20
+ path: string;
21
+ contract: string;
22
+ status: number;
23
+ }
24
+ export interface ModelProxy {
25
+ url: string;
26
+ /** Every request forwarded so far, with the contract header it carried. */
27
+ forwarded: ForwardedRequest[];
28
+ /** Set when the gateway answered 409 contract_mismatch; the job decides what to do. */
29
+ contractMismatch: {
30
+ expected_version: number | null;
31
+ } | null;
32
+ close(): Promise<void>;
33
+ }
34
+ /** Outbound headers for one request: the phase's own, minus anything the proxy decides. */
35
+ export declare function outboundHeaders(incoming: IncomingMessage["headers"], state: ProxyState): Record<string, string | string[] | undefined>;
36
+ /** The largest 409 body the proxy reads for a contract mismatch. */
37
+ export declare const MISMATCH_BODY_MAX: number;
38
+ /** The client for the gateway's scheme: TLS for https, plain only for a local http gateway. */
39
+ export declare function requestFor(protocol: string): typeof httpRequest;
40
+ /**
41
+ * The phase's answer when the gateway fails: a 502 if nothing was sent yet, else the connection is
42
+ * cut, so a truncated body is never mistaken for a whole one.
43
+ */
44
+ export declare function upstreamFailed(res: Pick<ServerResponse, "headersSent" | "writeHead" | "end" | "destroy">, e: Error): void;
45
+ export declare function startModelProxy(gatewayUrl: string, state: ProxyState): Promise<ModelProxy>;
@@ -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
+ }