@mnemom/mnemom 0.17.35 → 0.17.37

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 (101) hide show
  1. package/dist/commands/agent-run.d.ts +4 -0
  2. package/dist/commands/agent-run.js +9 -1
  3. package/dist/commands/agent-share-render.js +1 -1
  4. package/dist/commands/scan.d.ts +13 -3
  5. package/dist/commands/scan.js +48 -10
  6. package/dist/host/commands.js +4 -1
  7. package/dist/host/owner.d.ts +2 -0
  8. package/dist/host/verbs.d.ts +16 -1
  9. package/dist/host/verbs.js +45 -3
  10. package/dist/index.js +3 -2
  11. package/dist/lib/config.d.ts +7 -0
  12. package/dist/lib/config.js +21 -0
  13. package/dist/lib/scan/pr-source.js +2 -0
  14. package/dist/lib/scan/repo-picker.d.ts +32 -0
  15. package/dist/lib/scan/repo-picker.js +80 -0
  16. package/dist/vendor/durable-contract/index.js +6 -3
  17. package/dist/vendor/shared/durable-contract/cli-surface.js +2 -2
  18. package/dist/vendor/shared/durable-contract/codes.js +4 -1
  19. package/node_modules/@mnemom/aip-verifier/LICENSE +190 -0
  20. package/node_modules/@mnemom/aip-verifier/dist/claim-lint-CVITIRcU.d.cts +478 -0
  21. package/node_modules/@mnemom/aip-verifier/dist/claim-lint-CVITIRcU.d.ts +478 -0
  22. package/node_modules/@mnemom/aip-verifier/dist/index.cjs +971 -0
  23. package/node_modules/@mnemom/aip-verifier/dist/index.cjs.map +1 -0
  24. package/node_modules/@mnemom/aip-verifier/dist/index.d.cts +330 -0
  25. package/node_modules/@mnemom/aip-verifier/dist/index.d.ts +330 -0
  26. package/node_modules/@mnemom/aip-verifier/dist/index.js +933 -0
  27. package/node_modules/@mnemom/aip-verifier/dist/index.js.map +1 -0
  28. package/node_modules/@mnemom/aip-verifier/dist/render-lines.cjs +879 -0
  29. package/node_modules/@mnemom/aip-verifier/dist/render-lines.cjs.map +1 -0
  30. package/node_modules/@mnemom/aip-verifier/dist/render-lines.d.cts +96 -0
  31. package/node_modules/@mnemom/aip-verifier/dist/render-lines.d.ts +96 -0
  32. package/node_modules/@mnemom/aip-verifier/dist/render-lines.js +875 -0
  33. package/node_modules/@mnemom/aip-verifier/dist/render-lines.js.map +1 -0
  34. package/node_modules/@mnemom/aip-verifier/package.json +86 -0
  35. package/node_modules/@noble/ed25519/LICENSE +21 -0
  36. package/node_modules/@noble/ed25519/README.md +371 -0
  37. package/node_modules/@noble/ed25519/index.d.ts +391 -0
  38. package/node_modules/@noble/ed25519/index.js +933 -0
  39. package/node_modules/@noble/ed25519/index.ts +1134 -0
  40. package/node_modules/@noble/ed25519/package.json +54 -0
  41. package/node_modules/@noble/hashes/LICENSE +21 -0
  42. package/node_modules/@noble/hashes/README.md +478 -0
  43. package/node_modules/@noble/hashes/_blake.d.ts +20 -0
  44. package/node_modules/@noble/hashes/_blake.js +59 -0
  45. package/node_modules/@noble/hashes/_md.d.ts +95 -0
  46. package/node_modules/@noble/hashes/_md.js +209 -0
  47. package/node_modules/@noble/hashes/_u64.d.ts +36 -0
  48. package/node_modules/@noble/hashes/_u64.js +77 -0
  49. package/node_modules/@noble/hashes/argon2.d.ts +148 -0
  50. package/node_modules/@noble/hashes/argon2.js +577 -0
  51. package/node_modules/@noble/hashes/blake1.d.ts +162 -0
  52. package/node_modules/@noble/hashes/blake1.js +630 -0
  53. package/node_modules/@noble/hashes/blake2.d.ts +203 -0
  54. package/node_modules/@noble/hashes/blake2.js +573 -0
  55. package/node_modules/@noble/hashes/blake3.d.ts +68 -0
  56. package/node_modules/@noble/hashes/blake3.js +310 -0
  57. package/node_modules/@noble/hashes/eskdf.d.ts +91 -0
  58. package/node_modules/@noble/hashes/eskdf.js +225 -0
  59. package/node_modules/@noble/hashes/hkdf.d.ts +70 -0
  60. package/node_modules/@noble/hashes/hkdf.js +152 -0
  61. package/node_modules/@noble/hashes/hmac.d.ts +50 -0
  62. package/node_modules/@noble/hashes/hmac.js +93 -0
  63. package/node_modules/@noble/hashes/index.d.ts +1 -0
  64. package/node_modules/@noble/hashes/index.js +34 -0
  65. package/node_modules/@noble/hashes/legacy.d.ts +107 -0
  66. package/node_modules/@noble/hashes/legacy.js +348 -0
  67. package/node_modules/@noble/hashes/package.json +91 -0
  68. package/node_modules/@noble/hashes/pbkdf2.d.ts +66 -0
  69. package/node_modules/@noble/hashes/pbkdf2.js +176 -0
  70. package/node_modules/@noble/hashes/scrypt.d.ts +98 -0
  71. package/node_modules/@noble/hashes/scrypt.js +304 -0
  72. package/node_modules/@noble/hashes/sha2.d.ts +176 -0
  73. package/node_modules/@noble/hashes/sha2.js +446 -0
  74. package/node_modules/@noble/hashes/sha3-addons.d.ts +533 -0
  75. package/node_modules/@noble/hashes/sha3-addons.js +973 -0
  76. package/node_modules/@noble/hashes/sha3.d.ts +208 -0
  77. package/node_modules/@noble/hashes/sha3.js +475 -0
  78. package/node_modules/@noble/hashes/src/_blake.ts +67 -0
  79. package/node_modules/@noble/hashes/src/_md.ts +227 -0
  80. package/node_modules/@noble/hashes/src/_u64.ts +111 -0
  81. package/node_modules/@noble/hashes/src/argon2.ts +693 -0
  82. package/node_modules/@noble/hashes/src/blake1.ts +677 -0
  83. package/node_modules/@noble/hashes/src/blake2.ts +648 -0
  84. package/node_modules/@noble/hashes/src/blake3.ts +344 -0
  85. package/node_modules/@noble/hashes/src/eskdf.ts +265 -0
  86. package/node_modules/@noble/hashes/src/hkdf.ts +161 -0
  87. package/node_modules/@noble/hashes/src/hmac.ts +130 -0
  88. package/node_modules/@noble/hashes/src/index.ts +33 -0
  89. package/node_modules/@noble/hashes/src/legacy.ts +361 -0
  90. package/node_modules/@noble/hashes/src/pbkdf2.ts +219 -0
  91. package/node_modules/@noble/hashes/src/scrypt.ts +375 -0
  92. package/node_modules/@noble/hashes/src/sha2.ts +486 -0
  93. package/node_modules/@noble/hashes/src/sha3-addons.ts +1157 -0
  94. package/node_modules/@noble/hashes/src/sha3.ts +525 -0
  95. package/node_modules/@noble/hashes/src/utils.ts +978 -0
  96. package/node_modules/@noble/hashes/src/webcrypto.ts +283 -0
  97. package/node_modules/@noble/hashes/utils.d.ts +560 -0
  98. package/node_modules/@noble/hashes/utils.js +687 -0
  99. package/node_modules/@noble/hashes/webcrypto.d.ts +121 -0
  100. package/node_modules/@noble/hashes/webcrypto.js +228 -0
  101. package/package.json +5 -2
@@ -24,6 +24,8 @@ export interface RunDeps {
24
24
  env: NodeJS.ProcessEnv;
25
25
  fetch: FetchFn;
26
26
  apiUrl: string;
27
+ /** The gateway the model proxy forwards to. */
28
+ gatewayUrl: string;
27
29
  /** stdout: one outcome line per job. */
28
30
  emit(line: string): void;
29
31
  /** stderr. */
@@ -36,6 +38,8 @@ export interface RunDeps {
36
38
  }
37
39
  /** The refusal of a runner with no ANTHROPIC_API_KEY (34.30.1); a config error, exit 2. */
38
40
  export declare const RUNNER_MODEL_KEY_MISSING = "runner_model_key_missing";
41
+ /** The refusal of a non-production API paired with the production gateway; a config error, exit 2. */
42
+ export declare const RUNNER_GATEWAY_RING_MISMATCH = "runner_gateway_ring_mismatch";
39
43
  /** Resolves the process exit code; never throws for a usage, config or Mnemom error. */
40
44
  export declare function agentRunCommand(flags: RunFlags, deps: RunDeps): Promise<number>;
41
45
  export declare const GITHUB_API: string;
@@ -20,7 +20,7 @@ import { randomUUID } from "node:crypto";
20
20
  import { mkdtemp, readFile, rm } from "node:fs/promises";
21
21
  import { homedir, tmpdir } from "node:os";
22
22
  import { join } from "node:path";
23
- import { getApiUrl, getGatewayUrl } from "../lib/config.js";
23
+ import { gatewayRingMismatch, getApiUrl, getGatewayUrl } from "../lib/config.js";
24
24
  import { runnerApi } from "../lib/runner/executor-client.js";
25
25
  import { resolveClaudeBin } from "../lib/claude-bin.js";
26
26
  import { executorIdFromState } from "../lib/runner/executor-state.js";
@@ -35,6 +35,8 @@ import { resolveGithubToken } from "./agent-fuel.js";
35
35
  import { executorStatePath } from "../vendor/durable-contract/index.js";
36
36
  /** The refusal of a runner with no ANTHROPIC_API_KEY (34.30.1); a config error, exit 2. */
37
37
  export const RUNNER_MODEL_KEY_MISSING = "runner_model_key_missing";
38
+ /** The refusal of a non-production API paired with the production gateway; a config error, exit 2. */
39
+ export const RUNNER_GATEWAY_RING_MISMATCH = "runner_gateway_ring_mismatch";
38
40
  /** Resolves the process exit code; never throws for a usage, config or Mnemom error. */
39
41
  export async function agentRunCommand(flags, deps) {
40
42
  const resolved = resolveRunPlan(flags, deps.env);
@@ -45,6 +47,11 @@ export async function agentRunCommand(flags, deps) {
45
47
  const plan = resolved.plan;
46
48
  for (const w of plan.warnings)
47
49
  deps.err(w);
50
+ if (gatewayRingMismatch(deps.apiUrl, deps.gatewayUrl)) {
51
+ deps.err(`mnemom agent run: refused (${RUNNER_GATEWAY_RING_MISMATCH}): the API is ${deps.apiUrl} but the gateway is the production one, ${deps.gatewayUrl}; nothing was claimed`);
52
+ deps.err(" fix: set MNEMOM_GATEWAY_URL to the same cell's gateway as MNEMOM_API_URL");
53
+ return USAGE_EXIT_CODE;
54
+ }
48
55
  const modelKey = deps.env.ANTHROPIC_API_KEY?.trim();
49
56
  if (!modelKey) {
50
57
  deps.err(`mnemom agent run: refused (${RUNNER_MODEL_KEY_MISSING}): this runner has no ANTHROPIC_API_KEY for its phases' model calls; nothing was claimed`);
@@ -247,6 +254,7 @@ export function runAgentRun(flags) {
247
254
  env: process.env,
248
255
  fetch,
249
256
  apiUrl: getApiUrl(),
257
+ gatewayUrl: getGatewayUrl(),
250
258
  emit: (line) => process.stdout.write(`${line}\n`),
251
259
  err: (line) => process.stderr.write(`${line}\n`),
252
260
  stop: stop.signal,
@@ -20,7 +20,7 @@ async function loadVerifier(load) {
20
20
  catch (error) {
21
21
  const e = error;
22
22
  if (e.code === "ERR_MODULE_NOT_FOUND" || e.code === "MODULE_NOT_FOUND") {
23
- throw new Error("the aip verifier (@mnemom/aip-verifier) is not installed: run `npm install https://github.com/mnemom/aip/releases/download/aip-verifier-v0.6.0/mnemom-aip-verifier-0.6.0.tgz`", { cause: error });
23
+ throw new Error("the aip verifier (@mnemom/aip-verifier) is not installed: it ships bundled inside @mnemom/mnemom, so reinstall with `npm install -g @mnemom/mnemom`", { cause: error });
24
24
  }
25
25
  throw new Error(`the aip verifier (@mnemom/aip-verifier) failed to load: ${e.message ?? error}`, { cause: error });
26
26
  }
@@ -11,6 +11,7 @@ import { type AnthropicUsageDeps } from "../lib/scan/spend/anthropic-usage.js";
11
11
  import { type RecordDeps, type RecordOutcome } from "../lib/scan/record.js";
12
12
  import { type CostSpend } from "../lib/scan/cost-per-pr.js";
13
13
  import { type ReportEmailDeps } from "../lib/scan/report-email.js";
14
+ import { type RepoListDeps } from "../lib/scan/repo-picker.js";
14
15
  import { type ReportRecipients } from "../lib/scan/share.js";
15
16
  export interface ScanCommandOptions {
16
17
  repos: string[];
@@ -42,12 +43,20 @@ export interface ScanCommandOptions {
42
43
  recordDeps?: RecordDeps;
43
44
  /** Test seam: the report-email POST. */
44
45
  emailDeps?: ReportEmailDeps;
46
+ /** Test seam: the org this login was authorized for (none of the stored tokens carry one today). */
47
+ authorizedOrg?: () => string | undefined;
48
+ /** Test seam: the `mnemom org use` default. */
49
+ activeOrg?: () => {
50
+ org_id: string;
51
+ } | undefined;
45
52
  /** Test seam: the signed-in user's orgs, used to infer --org when it is omitted. */
46
53
  listOrgs?: () => Promise<Array<{
47
54
  org_id: string;
48
55
  name?: string;
49
56
  is_personal?: boolean;
50
57
  }>>;
58
+ /** Test seam: the fetch behind the repo picker's list read. */
59
+ repoListDeps?: RepoListDeps;
51
60
  /** Test seams. */
52
61
  isTty?: boolean;
53
62
  env?: NodeJS.ProcessEnv;
@@ -93,9 +102,10 @@ export declare function resolveMeasuredUsage(o: ScanCommandOptions, period: {
93
102
  } | null>;
94
103
  /**
95
104
  * --org is optional only because it can be inferred: the report is emailed on every scan, so the scan
96
- * needs an org. Inference uses the signed-in user's own memberships (GET /v1/orgs, personal org
97
- * excluded): exactly one org is used; none, several, or a failed read stops the scan loudly BEFORE any
98
- * scan work, naming the fix. It never guesses between orgs and never skips the email.
105
+ * needs an org. Order: --org; the org the login was authorized for; the `mnemom org use` default; the
106
+ * signed-in user's single non-personal membership (GET /v1/orgs); a terminal picker over several; else it
107
+ * stops loudly BEFORE any scan work, naming each org beside its id. It never guesses between orgs and
108
+ * never skips the email.
99
109
  */
100
110
  export declare function resolveScanOrg(o: ScanCommandOptions): Promise<string>;
101
111
  export declare const INVALID_ORG_MSG = "--org must be an org id like org-1a2b3c4d.";
@@ -12,10 +12,12 @@ import { isSpendTiersEnabled, NOT_ENABLED_MSG, parseMonthlySpendUsd, selectSpend
12
12
  import { ADMIN_KEY_ENV, AnthropicUsageError, fetchMeasuredUsage, isAdminKeyShape, usageFailureText, } from "../lib/scan/spend/anthropic-usage.js";
13
13
  import { getAuthInfo } from "../lib/auth.js";
14
14
  import { listMyOrgs } from "../lib/api.js";
15
+ import { getActiveOrg } from "../lib/cli-config.js";
15
16
  import { fetchRecordTotals, isOrgId, } from "../lib/scan/record.js";
16
17
  import { modelledSpend } from "../lib/scan/cost-per-pr.js";
17
18
  import { RECORD_NO_ORG_NOTE, renderRecordSection } from "../lib/scan/report.js";
18
19
  import { buildEmailBody, buildEmailReport, DRY_RUN_MSG, UNAVAILABLE_MSG, failureMessage, ReportEmailError, sendReportEmail, } from "../lib/scan/report-email.js";
20
+ import { pickRepos, REPO_PICKER_NO_TERMINAL_HINT, } from "../lib/scan/repo-picker.js";
19
21
  import { buildSharePayload, parseShareLevel, promptShareLevel, } from "../lib/scan/share.js";
20
22
  /** Parses repeated/comma-separated `--repo owner/name`. Returns null on any invalid slug. */
21
23
  export function parseRepoFlags(raw) {
@@ -126,9 +128,10 @@ export async function resolveMeasuredUsage(o, period) {
126
128
  }
127
129
  /**
128
130
  * --org is optional only because it can be inferred: the report is emailed on every scan, so the scan
129
- * needs an org. Inference uses the signed-in user's own memberships (GET /v1/orgs, personal org
130
- * excluded): exactly one org is used; none, several, or a failed read stops the scan loudly BEFORE any
131
- * scan work, naming the fix. It never guesses between orgs and never skips the email.
131
+ * needs an org. Order: --org; the org the login was authorized for; the `mnemom org use` default; the
132
+ * signed-in user's single non-personal membership (GET /v1/orgs); a terminal picker over several; else it
133
+ * stops loudly BEFORE any scan work, naming each org beside its id. It never guesses between orgs and
134
+ * never skips the email.
132
135
  */
133
136
  export async function resolveScanOrg(o) {
134
137
  if (o.org !== undefined) {
@@ -136,6 +139,18 @@ export async function resolveScanOrg(o) {
136
139
  throw new Error(INVALID_ORG_MSG);
137
140
  return o.org;
138
141
  }
142
+ // 2. The org this login was authorized for, when the token or session carries one.
143
+ const authorized = o.authorizedOrg?.();
144
+ if (authorized !== undefined) {
145
+ if (!isOrgId(authorized))
146
+ throw new Error(INVALID_ORG_MSG);
147
+ return authorized;
148
+ }
149
+ // 3. The default set with `mnemom org use <slug>` (a malformed stored id is ignored, not trusted).
150
+ const active = (o.activeOrg ?? getActiveOrg)()?.org_id;
151
+ if (active !== undefined && isOrgId(active))
152
+ return active;
153
+ // 4-6. Memberships: a single one, else a terminal picker, else fail closed.
139
154
  let orgs;
140
155
  try {
141
156
  orgs = (await (o.listOrgs ?? listMyOrgs)()).filter((x) => !x.is_personal);
@@ -147,8 +162,20 @@ export async function resolveScanOrg(o) {
147
162
  return orgs[0].org_id;
148
163
  if (orgs.length === 0)
149
164
  throw new Error("You belong to no organization, so the report has nowhere to be emailed from. Pass --org <org_id>.");
150
- throw new Error(`You belong to ${orgs.length} organizations; pass --org <org_id> (${orgs.map((x) => x.org_id).join(", ")}).`);
165
+ // Several orgs: ids alone mean nothing, so show each name beside its id. A terminal picks one; anything
166
+ // else fails closed (never a guess) with the same labels in the message.
167
+ const labels = orgs.map(orgLabel);
168
+ if (o.isTty ?? process.stdin.isTTY) {
169
+ const chosen = await askSelect("Which organization should the report be emailed for?", labels);
170
+ const hit = orgs.find((x, i) => labels[i] === chosen);
171
+ if (hit && isOrgId(hit.org_id))
172
+ return hit.org_id;
173
+ throw new Error("No organization selected. Pass --org <org_id>.");
174
+ }
175
+ throw new Error(`You belong to ${orgs.length} organizations; pass --org <org_id> (${labels.join(", ")}).`);
151
176
  }
177
+ /** "Name (org-id)", or just the id when the org has no usable name. */
178
+ const orgLabel = (x) => typeof x.name === "string" && x.name.trim() !== "" ? `${x.name.trim()} (${x.org_id})` : x.org_id;
152
179
  export const INVALID_ORG_MSG = "--org must be an org id like org-1a2b3c4d.";
153
180
  /**
154
181
  * The Record read for the preview: null when the flag gate is off (no read, no output, as before).
@@ -168,23 +195,34 @@ export async function scanCommand(o) {
168
195
  if (o.share !== undefined && parseShareLevel(o.share) === null) {
169
196
  throw new Error("--share must be one of: summary, full, none");
170
197
  }
171
- if (o.repos.length === 0)
172
- throw new Error("--repo owner/name is required (repeatable)");
198
+ const noRepoFlag = o.repos.length === 0;
199
+ // No --repo: on a terminal pick from the repos this GitHub login can read; otherwise the error stays, with a hint.
200
+ if (noRepoFlag && !(o.isTty ?? process.stdin.isTTY))
201
+ throw new Error(`--repo owner/name is required (repeatable). ${REPO_PICKER_NO_TERMINAL_HINT}`);
173
202
  // The report is emailed on every scan, so resolve the org before any scan work; failure is loud.
174
203
  const emailOrg = await resolveScanOrg(o);
175
204
  if (o.ctoEmail !== undefined && !isEmail(o.ctoEmail))
176
205
  throw new Error("--cto-email must be a valid email address");
206
+ const getCredential = async () => {
207
+ const c = o.credential ?? (await resolveCredential().catch(() => null));
208
+ if (!c)
209
+ throw new Error(failureText("credential"));
210
+ return c;
211
+ };
212
+ // Asked before the spend prompts so the repo comes first; the credential is reused for the scan.
213
+ const pickedCredential = noRepoFlag ? await getCredential() : null;
214
+ const repos = pickedCredential
215
+ ? await pickRepos(pickedCredential, { isTty: true, deps: o.repoListDeps })
216
+ : o.repos;
177
217
  const selfReported = await resolveSelfReportedSpend(o);
178
218
  const now = o.now ?? Date.now;
179
219
  const end = new Date(now()).toISOString().slice(0, 10);
180
220
  const period = o.period ?? { start: end, end };
181
221
  const measured = await resolveMeasuredUsage(o, period);
182
- const credential = o.credential ?? (await resolveCredential().catch(() => null));
183
- if (!credential)
184
- throw new Error(failureText("credential"));
222
+ const credential = pickedCredential ?? (await getCredential());
185
223
  // Before the local scan: a refused credential fails closed without spending the GitHub reads.
186
224
  const record = await resolveRecordOutcome(o);
187
- const outcome = await runLocalScan({ credential, now, ...o.deps }, { repos: o.repos, period });
225
+ const outcome = await runLocalScan({ credential, now, ...o.deps }, { repos, period });
188
226
  if (outcome.status === "withheld")
189
227
  throw new Error(`Scan withheld: ${failureText(outcome.reason)}`);
190
228
  const { result } = outcome;
@@ -8,6 +8,7 @@ import * as os from "node:os";
8
8
  import { CLI_VERSION } from "../version.js";
9
9
  import { getApiUrl } from "../lib/config.js";
10
10
  import { getAccessToken } from "../lib/auth.js";
11
+ import { getActiveOrg } from "../lib/cli-config.js";
11
12
  import { resolveBootId, defaultBootIdDeps } from "./bootid.js";
12
13
  import { realLaunchctl } from "./launchd.js";
13
14
  import { ownerApi, parseExecutorList } from "./owner.js";
@@ -37,6 +38,7 @@ function verbIo(json) {
37
38
  bootId: () => resolveBootId({ ...defaultBootIdDeps(), env }).bootId,
38
39
  programArguments: () => [...cliExec(), "agent", "host", "run"],
39
40
  claudeBin: () => resolveClaudeBin(env),
41
+ activeOrg: () => getActiveOrg()?.org_id ?? null,
40
42
  };
41
43
  }
42
44
  async function guarded(json, fn) {
@@ -66,7 +68,8 @@ export function registerHostCommands(agent, hidden) {
66
68
  host
67
69
  .command("enroll")
68
70
  .description("Enroll this machine as an executor (device step); `install` does this when needed")
69
- .action(async () => guarded(false, hostEnroll));
71
+ .option("--org <org_id>", "Join this organization (you must be a member); defaults to the active org, else your personal org")
72
+ .action(async (o) => guarded(false, (io) => hostEnroll(io, { org: o.org })));
70
73
  host
71
74
  .command("renew")
72
75
  .description("Renew this machine's executor grant (device step)")
@@ -11,6 +11,8 @@ export interface OwnerApi {
11
11
  runtime: string;
12
12
  contract_version: string;
13
13
  machine_label?: string;
14
+ /** Ruling 34.03.4: the org this host joins; the api checks membership. */
15
+ org_id?: string;
14
16
  }): Promise<OwnerHttp>;
15
17
  enrollPoll(pollSecret: string, approvalId: string | null): Promise<OwnerHttp>;
16
18
  list(): Promise<OwnerHttp>;
@@ -20,6 +20,8 @@ export interface VerbIo {
20
20
  readonly pollSleep?: (ms: number) => Promise<void>;
21
21
  /** Takes the enrollment lock; acquireHostLock (macOS flock) when absent. */
22
22
  readonly acquireLock?: (lockFile: string) => LockResult;
23
+ /** The CLI's active org id (`mnemom org use`), or null with none set; never read when absent. */
24
+ readonly activeOrg?: () => string | null;
23
25
  }
24
26
  /** Fresh-invocation gate for every host verb (C12-R25): exit 2 with the gated text, nothing else. */
25
27
  export declare function freshGate(io: VerbIo): number | null;
@@ -40,13 +42,26 @@ export declare function executorLiveness(io: VerbIo, executorId: string): Promis
40
42
  export declare function printRiskLine(io: VerbIo): void;
41
43
  /** "<org name> (<org_id>)", or the bare id when the api sent no name. */
42
44
  export declare function joinTarget(orgId: string, orgName: string | null): string;
45
+ /**
46
+ * The org a start names (ruling 34.03.4): `--org`, else the active org, else none, so the api
47
+ * keeps its fallback (the token's org, else the personal org).
48
+ */
49
+ export declare function enrollOrg(io: VerbIo, opts: {
50
+ org?: string;
51
+ }): {
52
+ orgId: string;
53
+ from: "flag" | "active";
54
+ } | null;
43
55
  /** The device step: start, show the code, poll, write the state file. Returns an exit code. */
44
56
  export declare function enrollDevice(io: VerbIo, opts?: {
45
57
  label?: string;
58
+ org?: string;
46
59
  }): Promise<number>;
47
60
  /** Write the plist and (re)load the job. */
48
61
  export declare function writeAndLoad(io: VerbIo): Promise<number>;
49
62
  export declare function hostInstall(io: VerbIo): Promise<number>;
50
- export declare function hostEnroll(io: VerbIo): Promise<number>;
63
+ export declare function hostEnroll(io: VerbIo, opts?: {
64
+ org?: string;
65
+ }): Promise<number>;
51
66
  export declare function hostRenew(io: VerbIo): Promise<number>;
52
67
  export declare function hostUninstall(io: VerbIo): Promise<number>;
@@ -23,6 +23,7 @@ import { renderPlist } from "./plist.js";
23
23
  import { loadJob, unloadJob } from "./launchd.js";
24
24
  import { classifyPoll, parseEnrollStart, parseExecutorList, EXECUTOR_CONTRACT, } from "./owner.js";
25
25
  import { readRuntime } from "./runtime.js";
26
+ import { CODE } from "./s1-names.js";
26
27
  import { RENEW_FIX, GRANT_EXPIRED_LINE } from "./grant.js";
27
28
  export const INSTALL_FIX = "`mnemom agent host install`";
28
29
  export const UNINSTALL_SESSION_LINE = (name) => `${name}: they stop restarting until ${INSTALL_FIX}`;
@@ -63,6 +64,22 @@ export function printRiskLine(io) {
63
64
  export function joinTarget(orgId, orgName) {
64
65
  return orgName ? `${orgName} (${orgId})` : orgId;
65
66
  }
67
+ /**
68
+ * The org a start names (ruling 34.03.4): `--org`, else the active org, else none, so the api
69
+ * keeps its fallback (the token's org, else the personal org).
70
+ */
71
+ export function enrollOrg(io, opts) {
72
+ if (opts.org)
73
+ return { orgId: opts.org, from: "flag" };
74
+ const active = io.activeOrg?.() ?? null;
75
+ return active ? { orgId: active, from: "active" } : null;
76
+ }
77
+ /** The api's refusal code from an error body, or null. */
78
+ function errorCode(body) {
79
+ const e = body && typeof body === "object" ? body.error : undefined;
80
+ const code = e && typeof e === "object" ? e.code : undefined;
81
+ return typeof code === "string" ? code : null;
82
+ }
66
83
  /** The device step: start, show the code, poll, write the state file. Returns an exit code. */
67
84
  export async function enrollDevice(io, opts = {}) {
68
85
  const { paths } = ctx(io);
@@ -71,16 +88,41 @@ export async function enrollDevice(io, opts = {}) {
71
88
  io.err(`mnemom agent host: an enrollment is already in progress here (${lock.reason}): wait for it, then ${INSTALL_FIX}`);
72
89
  return 1;
73
90
  }
91
+ let org = enrollOrg(io, opts);
74
92
  try {
75
- const st = await io.owner.enrollStart({
93
+ const startWith = (o) => io.owner.enrollStart({
76
94
  kind: "host",
77
95
  runtime: "node",
78
96
  contract_version: EXECUTOR_CONTRACT,
79
97
  ...(opts.label ? { machine_label: opts.label } : {}),
98
+ ...(o ? { org_id: o.orgId } : {}),
80
99
  });
100
+ let st = await startWith(org);
101
+ // An api from before 34.03.4 refuses org_id on a host start with its plain 400 invalid_scope
102
+ // (34.03.2 (1); mnemom-api enroll.ts "org_id is only accepted with scope org"; recorded beside
103
+ // exec_enroll in S1 codes.ts). An org taken from the active org then falls back to the start this
104
+ // CLI made before; an explicit --org cannot.
105
+ if (org && st.status === 400 && errorCode(st.body) === "invalid_scope") {
106
+ if (org.from === "flag") {
107
+ io.err(`mnemom agent host: this Mnemom server cannot enroll a machine into ${org.orgId} yet.`);
108
+ io.err("fix: `mnemom agent host enroll` without --org (joins your personal org)");
109
+ return 1;
110
+ }
111
+ io.err(`mnemom agent host: this Mnemom server cannot enroll a machine into your active org ${org.orgId} yet; it joins your personal org.`);
112
+ org = null;
113
+ st = await startWith(null);
114
+ }
81
115
  const start = st.status === 202 || st.status === 200 || st.status === 201
82
116
  ? parseEnrollStart(st.body)
83
117
  : null;
118
+ if (!start && org && errorCode(st.body) === CODE.durable_approval_not_eligible) {
119
+ // 34.03.4: only a member of the org may enroll a machine into it.
120
+ io.err(`mnemom agent host: you are not a member of ${org.orgId}, so this machine cannot join it.`);
121
+ io.err(org.from === "flag"
122
+ ? "fix: `mnemom org list` shows the organizations you belong to"
123
+ : "fix: `mnemom org use --clear`, then `mnemom agent host enroll` (joins your personal org)");
124
+ return 1;
125
+ }
84
126
  if (!start) {
85
127
  const code = st.body && typeof st.body === "object"
86
128
  ? JSON.stringify(st.body).slice(0, 300)
@@ -211,8 +253,8 @@ export async function hostInstall(io) {
211
253
  io.out(reload ? "Host daemon reloaded on this machine's enrollment." : "Host daemon installed.");
212
254
  return 0;
213
255
  }
214
- export async function hostEnroll(io) {
215
- return enrollDevice(io);
256
+ export async function hostEnroll(io, opts = {}) {
257
+ return enrollDevice(io, opts);
216
258
  }
217
259
  export async function hostRenew(io) {
218
260
  const { paths } = ctx(io);
package/dist/index.js CHANGED
@@ -2384,7 +2384,7 @@ program
2384
2384
  program
2385
2385
  .command("scan")
2386
2386
  .description("Read-only local scan of GitHub PR metadata; the report is emailed to you every time (counts only, no repo names) and Mnemom keeps nothing at share level none")
2387
- .option("--repo <owner/name...>", "Repo to scan (repeatable or comma-separated)")
2387
+ .option("--repo <owner/name...>", "Repo to scan (repeatable or comma-separated; omit on a terminal to pick from your repos)")
2388
2388
  .option("--start <YYYY-MM-DD>", "Period start (default: today)")
2389
2389
  .option("--end <YYYY-MM-DD>", "Period end (default: today)")
2390
2390
  .option("--share <level>", "summary | full | none (default: full when asked; none when not interactive)")
@@ -2395,7 +2395,8 @@ program
2395
2395
  .option("--org <org_id>", "Your org id; the report is emailed to you for this org (default: your only organization, else the scan stops and asks). Also selects the Record totals preview (needs MNEMOM_ORG_SCAN_MODE=true)")
2396
2396
  .action(async (options) => {
2397
2397
  try {
2398
- const repos = parseRepoFlags(options.repo);
2398
+ // Omitted --repo is allowed here: scanCommand picks from your repos on a terminal, else stops with a hint.
2399
+ const repos = options.repo === undefined ? [] : parseRepoFlags(options.repo);
2399
2400
  if (!repos)
2400
2401
  throw new Error("--repo owner/name is required (repeatable)");
2401
2402
  const today = new Date().toISOString().slice(0, 10);
@@ -42,6 +42,13 @@ export declare function setRing(ring: CellRing | undefined): void;
42
42
  export declare function getRing(): CellRing | undefined;
43
43
  /** Override the active API base for this process (clears on undefined/empty). */
44
44
  export declare function setApiUrlOverride(url: string | undefined): void;
45
+ /**
46
+ * True when the API is not production but the gateway is the production one. A lease key minted
47
+ * by a non-production cell's API means nothing to the production gateway, which answers every
48
+ * model call 401 and still receives the key (door2-path run 41783208169), so `agent run` refuses
49
+ * this pairing. A base that does not parse decides nothing. Pure.
50
+ */
51
+ export declare function gatewayRingMismatch(apiUrl: string, gatewayUrl: string): boolean;
45
52
  export declare function getApiUrl(): string;
46
53
  export declare function getGatewayUrl(): string;
47
54
  export declare function getWebsiteUrl(): string;
@@ -113,6 +113,27 @@ function ringUrls() {
113
113
  const ring = getRing();
114
114
  return ring ? RING_URLS[ring] : undefined;
115
115
  }
116
+ function originOf(url) {
117
+ try {
118
+ return new URL(url).origin.toLowerCase();
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ }
124
+ /**
125
+ * True when the API is not production but the gateway is the production one. A lease key minted
126
+ * by a non-production cell's API means nothing to the production gateway, which answers every
127
+ * model call 401 and still receives the key (door2-path run 41783208169), so `agent run` refuses
128
+ * this pairing. A base that does not parse decides nothing. Pure.
129
+ */
130
+ export function gatewayRingMismatch(apiUrl, gatewayUrl) {
131
+ const api = originOf(apiUrl);
132
+ const gateway = originOf(gatewayUrl);
133
+ if (api === null || gateway === null)
134
+ return false;
135
+ return api !== originOf(API_URLS.production) && gateway === originOf(GATEWAY_URLS.production);
136
+ }
116
137
  export function getApiUrl() {
117
138
  return (apiUrlOverride ?? envBase("MNEMOM_API_URL") ?? ringUrls()?.api ?? API_URLS[getEnvironment()]);
118
139
  }
@@ -31,6 +31,8 @@ const ALLOWED_PATHS = [
31
31
  new RegExp(`^/repos/${SLUG}/pulls\\?state=closed&sort=updated&direction=desc&per_page=\\d{1,3}&page=\\d{1,4}$`),
32
32
  new RegExp(`^/repos/${SLUG}/pulls/\\d{1,9}$`),
33
33
  new RegExp(`^/repos/${SLUG}/commits/[0-9a-f]{40}/check-runs\\?per_page=100$`),
34
+ // The repo picker's one list read (MNE-9643): the repos this login can read, newest push first.
35
+ /^\/user\/repos\?sort=pushed&direction=desc&per_page=\d{1,3}$/,
34
36
  ];
35
37
  export function isAllowedScanPath(path) {
36
38
  return ALLOWED_PATHS.some((re) => re.test(path));
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Repo picker for `mnemom scan` when --repo is omitted on a terminal (MNE-9643). Read-only: one GET of
3
+ * the repos the signed-in GitHub account can read, through the same credential seam the scan uses.
4
+ * Only `full_name` is kept; descriptions, owners' logins beyond the slug, URLs and the token never leave
5
+ * this module. Failures throw fixed text (never a GitHub body, header or URL). Prompt style and fail-closed
6
+ * handling follow promptShareLevel in ./share.ts: no terminal, no answer, or a bad answer selects nothing.
7
+ */
8
+ import { askMultiSelect } from "../prompt.js";
9
+ import { type GitHubCredential } from "./credential.js";
10
+ /** Most repos listed: the most recently pushed, so the list stays pickable. `--repo` names any other. */
11
+ export declare const REPO_PICKER_LIMIT = 30;
12
+ /** The only path the picker may read. */
13
+ export declare const REPO_LIST_PATH = "/user/repos?sort=pushed&direction=desc&per_page=30";
14
+ export declare const REPO_PICKER_NO_TERMINAL_HINT = "Pass --repo owner/name (repeatable), or run in a terminal to pick from your repos.";
15
+ export declare const REPO_LIST_FAILED_MSG = "Could not list your GitHub repos. Pass --repo owner/name, or check `gh auth status`.";
16
+ export declare const NO_REPOS_MSG = "No GitHub repos are readable by this account. Pass --repo owner/name.";
17
+ export declare const NO_REPO_SELECTED_MSG = "No repo selected. Pass --repo owner/name, or choose at least one number from the list.";
18
+ export interface RepoListDeps {
19
+ fetchImpl?: typeof fetch;
20
+ timeoutMs?: number;
21
+ }
22
+ /** GET-only read of the repos the credential can see. Returns valid slugs only, newest push first. */
23
+ export declare function listReadableRepos(credential: GitHubCredential, deps?: RepoListDeps): Promise<string[]>;
24
+ /**
25
+ * Lists the readable repos and asks which to scan (comma-separated numbers, as the other multi-select
26
+ * prompts do). Throws fixed text when there is no terminal, nothing is listed, or nothing valid is chosen.
27
+ */
28
+ export declare function pickRepos(credential: GitHubCredential, o: {
29
+ isTty: boolean;
30
+ deps?: RepoListDeps;
31
+ ask?: typeof askMultiSelect;
32
+ }): Promise<string[]>;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Repo picker for `mnemom scan` when --repo is omitted on a terminal (MNE-9643). Read-only: one GET of
3
+ * the repos the signed-in GitHub account can read, through the same credential seam the scan uses.
4
+ * Only `full_name` is kept; descriptions, owners' logins beyond the slug, URLs and the token never leave
5
+ * this module. Failures throw fixed text (never a GitHub body, header or URL). Prompt style and fail-closed
6
+ * handling follow promptShareLevel in ./share.ts: no terminal, no answer, or a bad answer selects nothing.
7
+ */
8
+ import { askMultiSelect } from "../prompt.js";
9
+ import { GITHUB_API, isAllowedScanPath } from "./pr-source.js";
10
+ import { assertReadOnlyCredential, isValidRepoSlug } from "./credential.js";
11
+ /** Most repos listed: the most recently pushed, so the list stays pickable. `--repo` names any other. */
12
+ export const REPO_PICKER_LIMIT = 30;
13
+ /** The only path the picker may read. */
14
+ export const REPO_LIST_PATH = `/user/repos?sort=pushed&direction=desc&per_page=${REPO_PICKER_LIMIT}`;
15
+ export const REPO_PICKER_NO_TERMINAL_HINT = "Pass --repo owner/name (repeatable), or run in a terminal to pick from your repos.";
16
+ export const REPO_LIST_FAILED_MSG = "Could not list your GitHub repos. Pass --repo owner/name, or check `gh auth status`.";
17
+ export const NO_REPOS_MSG = "No GitHub repos are readable by this account. Pass --repo owner/name.";
18
+ export const NO_REPO_SELECTED_MSG = "No repo selected. Pass --repo owner/name, or choose at least one number from the list.";
19
+ /** GET-only read of the repos the credential can see. Returns valid slugs only, newest push first. */
20
+ export async function listReadableRepos(credential, deps = {}) {
21
+ if (!isAllowedScanPath(REPO_LIST_PATH))
22
+ throw new Error(REPO_LIST_FAILED_MSG);
23
+ // Same order as runLocalScan: a credential GitHub granted more than read is refused before any read.
24
+ await assertReadOnlyCredential(credential).catch(() => {
25
+ throw new Error(REPO_LIST_FAILED_MSG);
26
+ });
27
+ const doFetch = deps.fetchImpl ?? fetch;
28
+ let res;
29
+ try {
30
+ // User-level read: there is no single repo to scope to (the gh-login credential ignores the argument).
31
+ const bearer = await credential.tokenFor("");
32
+ res = await doFetch(`${GITHUB_API}${REPO_LIST_PATH}`, {
33
+ method: "GET",
34
+ headers: {
35
+ accept: "application/vnd.github+json",
36
+ "x-github-api-version": "2022-11-28",
37
+ "user-agent": "mnemom-cli-scan/1",
38
+ authorization: `Bearer ${bearer}`,
39
+ },
40
+ redirect: "error",
41
+ signal: AbortSignal.timeout(deps.timeoutMs ?? 10_000),
42
+ });
43
+ }
44
+ catch {
45
+ throw new Error(REPO_LIST_FAILED_MSG);
46
+ }
47
+ if (!res.ok) {
48
+ await res.body?.cancel().catch(() => { });
49
+ throw new Error(REPO_LIST_FAILED_MSG);
50
+ }
51
+ let json;
52
+ try {
53
+ json = await res.json();
54
+ }
55
+ catch {
56
+ throw new Error(REPO_LIST_FAILED_MSG);
57
+ }
58
+ if (!Array.isArray(json))
59
+ throw new Error(REPO_LIST_FAILED_MSG);
60
+ const slugs = json
61
+ .map((r) => (r && typeof r === "object" ? r.full_name : undefined))
62
+ .filter(isValidRepoSlug);
63
+ return [...new Set(slugs)].slice(0, REPO_PICKER_LIMIT);
64
+ }
65
+ /**
66
+ * Lists the readable repos and asks which to scan (comma-separated numbers, as the other multi-select
67
+ * prompts do). Throws fixed text when there is no terminal, nothing is listed, or nothing valid is chosen.
68
+ */
69
+ export async function pickRepos(credential, o) {
70
+ if (!o.isTty)
71
+ throw new Error(`--repo owner/name is required (repeatable). ${REPO_PICKER_NO_TERMINAL_HINT}`);
72
+ const repos = await listReadableRepos(credential, o.deps);
73
+ if (repos.length === 0)
74
+ throw new Error(NO_REPOS_MSG);
75
+ const chosen = await (o.ask ?? askMultiSelect)("Which repos should be scanned (read-only)?", repos);
76
+ const picked = chosen.filter((c) => repos.includes(c));
77
+ if (picked.length === 0)
78
+ throw new Error(NO_REPO_SELECTED_MSG);
79
+ return picked;
80
+ }
@@ -1196,7 +1196,10 @@ var DURABLE_CODES = {
1196
1196
  // ---- api executor enrollment start, POST ROUTES.exec_enroll (C04-R02, R03; wave-0 fix 3b) ----
1197
1197
  // Names kept verbatim from C04-R03. A machine principal keeps 403 machine_principal_refused
1198
1198
  // (C04-R02); a non-starter approver gets durable_approval_not_eligible (ruling 1.13). Device-poll
1199
- // errors are not codes here: see ENROLL_POLL_ERRORS.
1199
+ // errors are not codes here: see ENROLL_POLL_ERRORS. Ruling 34.03.4: a host start naming an org_id
1200
+ // the caller is not a member of is also durable_approval_not_eligible (403, no approval row). A
1201
+ // malformed or refused org_id is the api's plain 400 invalid_scope, which is not a durable code;
1202
+ // an api from before 34.03.4 answers it to any org_id on a host start (34.03.2 (1)).
1200
1203
  /** The caller lacks the entitlement durable.locked. */
1201
1204
  not_entitled: { status: 403, surfaces: ["api"], fields: [] },
1202
1205
  /** The declared kind is not in durable.executors (M1 allows 'host' only). */
@@ -1643,8 +1646,8 @@ var M1_CLI_SURFACE = Object.freeze([
1643
1646
  entry(["agent", "done"], ["--json"]),
1644
1647
  // BACKBONE C2 (`mnemom agent host` replaces `mnemom host`, amendment 2; design-v3 row 7).
1645
1648
  entry(["agent", "host"]),
1646
- // BACKBONE C2 enroll (device step).
1647
- entry(["agent", "host", "enroll"]),
1649
+ // BACKBONE C2 enroll (device step); --org: ruling 34.03.4 (a host joins an org its user belongs to).
1650
+ entry(["agent", "host", "enroll"], ["--org"]),
1648
1651
  // BACKBONE C2 install/status/uninstall; --json: C04 (`mnemom agent host install --json`),
1649
1652
  // ruling 32.31.
1650
1653
  entry(["agent", "host", "install"], ["--json"]),
@@ -36,8 +36,8 @@ export const M1_CLI_SURFACE = Object.freeze([
36
36
  entry(["agent", "done"], ["--json"]),
37
37
  // BACKBONE C2 (`mnemom agent host` replaces `mnemom host`, amendment 2; design-v3 row 7).
38
38
  entry(["agent", "host"]),
39
- // BACKBONE C2 enroll (device step).
40
- entry(["agent", "host", "enroll"]),
39
+ // BACKBONE C2 enroll (device step); --org: ruling 34.03.4 (a host joins an org its user belongs to).
40
+ entry(["agent", "host", "enroll"], ["--org"]),
41
41
  // BACKBONE C2 install/status/uninstall; --json: C04 (`mnemom agent host install --json`),
42
42
  // ruling 32.31.
43
43
  entry(["agent", "host", "install"], ["--json"]),
@@ -157,7 +157,10 @@ export const DURABLE_CODES = {
157
157
  // ---- api executor enrollment start, POST ROUTES.exec_enroll (C04-R02, R03; wave-0 fix 3b) ----
158
158
  // Names kept verbatim from C04-R03. A machine principal keeps 403 machine_principal_refused
159
159
  // (C04-R02); a non-starter approver gets durable_approval_not_eligible (ruling 1.13). Device-poll
160
- // errors are not codes here: see ENROLL_POLL_ERRORS.
160
+ // errors are not codes here: see ENROLL_POLL_ERRORS. Ruling 34.03.4: a host start naming an org_id
161
+ // the caller is not a member of is also durable_approval_not_eligible (403, no approval row). A
162
+ // malformed or refused org_id is the api's plain 400 invalid_scope, which is not a durable code;
163
+ // an api from before 34.03.4 answers it to any org_id on a host start (34.03.2 (1)).
161
164
  /** The caller lacks the entitlement durable.locked. */
162
165
  not_entitled: { status: 403, surfaces: ["api"], fields: [] },
163
166
  /** The declared kind is not in durable.executors (M1 allows 'host' only). */