@mnemom/mnemom 0.17.39-next.1 → 0.17.39-next.3

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.
@@ -1,5 +1,50 @@
1
+ /**
2
+ * `mnemom whoami --json`: who is logged in, and which org is active, as ONE
3
+ * JSON object for a program to read. It carries identity only, never a
4
+ * credential: no access or refresh token, no API key, no scope string.
5
+ *
6
+ * {"logged_in": bool, "user_id": str|null, "email": str|null,
7
+ * "key_id": str|null, "org_id": str|null,
8
+ * "active_org": {"org_id", "slug", "name"}|null, "expires_at": ISO|null,
9
+ * "api_url": the API origin this identity was resolved against}
10
+ *
11
+ * A person (an OAuth login) is user_id + email, read from GET /v1/auth/session.
12
+ * An API key is key_id + org_id (its own org), read from GET
13
+ * /v1/api-keys/whoami; its user_id is always null, because a key's owning
14
+ * person is not the caller and a reader must key a key on key_id, never on
15
+ * that person. When the identity read fails, user_id, email, key_id and org_id
16
+ * are null: never a stale stored value.
17
+ *
18
+ * api_url is getApiUrl() in this process (--api / MNEMOM_API_URL > MNEMOM_RING
19
+ * > MNEMOM_ENV). Identities never cross environments (test, us-1 and
20
+ * production each have their own), so a reader pins one origin and refuses a
21
+ * whoami whose api_url is not it.
22
+ *
23
+ * A reader beside the CLI must never read ~/.mnemom/auth.json or call the API
24
+ * with the stored token itself: the CLI refreshes and rotates its tokens under
25
+ * a lock (MNE-9232), and a second token handler would race that refresh. This
26
+ * is the read such a reader uses instead (develop's install and every acting
27
+ * develop verb, which take their operator and tenant from the login).
28
+ */
29
+ export interface WhoamiJson {
30
+ logged_in: boolean;
31
+ user_id: string | null;
32
+ email: string | null;
33
+ key_id: string | null;
34
+ org_id: string | null;
35
+ active_org: {
36
+ org_id: string;
37
+ slug: string;
38
+ name: string | null;
39
+ } | null;
40
+ expires_at: string | null;
41
+ api_url: string;
42
+ }
43
+ export declare function whoamiJson(): Promise<WhoamiJson>;
1
44
  export declare function loginCommand(options?: {
2
45
  noBrowser?: boolean;
3
46
  }): Promise<void>;
4
47
  export declare function logoutCommand(): Promise<void>;
5
- export declare function whoamiCommand(): Promise<void>;
48
+ export declare function whoamiCommand(options?: {
49
+ json?: boolean;
50
+ }): Promise<void>;
@@ -1,6 +1,51 @@
1
1
  import { getAuthInfo, clearAuthTokens, loginWithBrowser, loginWithDeviceFlow, resolveAuth, } from "../lib/auth.js";
2
- import { getMe } from "../lib/api.js";
2
+ import { getApiKeyWhoami, getMe, getSession } from "../lib/api.js";
3
+ import { getActiveOrg } from "../lib/cli-config.js";
4
+ import { getApiUrl } from "../lib/config.js";
3
5
  import { fmt } from "../lib/format.js";
6
+ export async function whoamiJson() {
7
+ const active = getActiveOrg();
8
+ const active_org = active
9
+ ? { org_id: active.org_id, slug: active.slug, name: active.name ?? null }
10
+ : null;
11
+ const out = {
12
+ logged_in: false,
13
+ user_id: null,
14
+ email: null,
15
+ key_id: null,
16
+ org_id: null,
17
+ active_org,
18
+ expires_at: null,
19
+ api_url: getApiUrl(),
20
+ };
21
+ const cred = await resolveAuth();
22
+ if (cred.type === "none")
23
+ return out;
24
+ if (cred.type === "api-key") {
25
+ // A key is keyed on its own id and org, never on its owner's user id.
26
+ const key = await getApiKeyWhoami().catch(() => null);
27
+ return { ...out, logged_in: true, key_id: key?.key_id ?? null, org_id: key?.org_id ?? null };
28
+ }
29
+ if (!getAuthInfo())
30
+ return out;
31
+ // The OAuth token carries no identity: ask the API (GET /v1/auth/session, a
32
+ // pure read, unlike /v1/auth/me's billing and SSO JIT writes), which goes
33
+ // through the CLI's own refresh. A failed read leaves the identity null: a
34
+ // stored legacy value could belong to another session or environment.
35
+ const session = await getSession().catch(() => null);
36
+ const me = session ? { user_id: session.user.id, email: session.user.email ?? null } : null;
37
+ // Read the grant after that call: a refresh inside it moves expiresAt.
38
+ const auth = getAuthInfo();
39
+ if (!auth)
40
+ return out;
41
+ return {
42
+ ...out,
43
+ logged_in: true,
44
+ user_id: me?.user_id ?? null,
45
+ email: me?.email ?? null,
46
+ expires_at: new Date(auth.expiresAt * 1000).toISOString(),
47
+ };
48
+ }
4
49
  export async function loginCommand(options = {}) {
5
50
  try {
6
51
  // --no-browser → RFC 8628 device flow (headless / SSH / no local browser).
@@ -53,7 +98,11 @@ export async function logoutCommand() {
53
98
  clearAuthTokens();
54
99
  console.log(fmt.success("Logged out."));
55
100
  }
56
- export async function whoamiCommand() {
101
+ export async function whoamiCommand(options = {}) {
102
+ if (options.json) {
103
+ console.log(JSON.stringify(await whoamiJson()));
104
+ return;
105
+ }
57
106
  const cred = await resolveAuth();
58
107
  if (cred.type === "none") {
59
108
  console.log("\nNot logged in. Run `mnemom login` to authenticate.\n");
@@ -86,7 +86,9 @@ export function entryProducer(e) {
86
86
  return typeof p === "string" && TURN_PRODUCER_RE.test(p) ? p : undefined;
87
87
  }
88
88
  /** Claude Code's open tag for a bracketed paste, which may lead a pasted prompt's text. */
89
- const PASTE_OPEN_RE = /^<pasted_content id="[^"\n]{0,128}">\n?/;
89
+ // Claude Code may record the paste after blank lines ("\n\n<pasted_content id=...>"); a delivery the marker
90
+ // misses there is never confirmed and gets pasted again (seen live: 4 pastes of one message).
91
+ const PASTE_OPEN_RE = /^\n*<pasted_content id="[^"\n]{0,128}">\n?/;
90
92
  /**
91
93
  * The inbox sha an entry answers (S1 amendment 60): the `[mnemom inbox <sha>]` marker on the first
92
94
  * line of the entry's first text (after Claude Code's paste open tag, as the host daemon delivers by
@@ -69,7 +69,8 @@ export function registerHostCommands(agent, hidden) {
69
69
  .command("enroll")
70
70
  .description("Enroll this machine as an executor (device step); `install` does this when needed")
71
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 })));
72
+ .option("--scope <scope>", "personal (default): run your own sessions. org: run the org's pool jobs (`mnemom agent run`); an org owner or admin approves it, and the org must have org runners turned on", "personal")
73
+ .action(async (o) => guarded(false, (io) => hostEnroll(io, { org: o.org, scope: o.scope })));
73
74
  host
74
75
  .command("renew")
75
76
  .description("Renew this machine's executor grant (device step)")
@@ -72,7 +72,9 @@ export const HANDLED_MAX = 32;
72
72
  /** claude's input line, and the footer it shows while a turn runs. */
73
73
  const PROMPT_MARK = "❯";
74
74
  const RUNNING_RE = /esc to interrupt/i;
75
- const PASTE_OPEN_RE = /^<pasted_content id="[^"\n]{0,128}">\n?/;
75
+ // Claude Code may record the paste after blank lines ("\n\n<pasted_content id=...>"); a delivery the marker
76
+ // misses there is never confirmed and gets pasted again (seen live: 4 pastes of one message).
77
+ const PASTE_OPEN_RE = /^\n*<pasted_content id="[^"\n]{0,128}">\n?/;
76
78
  function firstText(r) {
77
79
  const c = r.message?.content;
78
80
  if (typeof c === "string")
@@ -13,6 +13,8 @@ export interface OwnerApi {
13
13
  machine_label?: string;
14
14
  /** Ruling 34.03.4: the org this host joins; the api checks membership. */
15
15
  org_id?: string;
16
+ /** The any-machine amendment: "org" enrolls this host as a runner of org_id's pool. */
17
+ scope?: "org";
16
18
  }): Promise<OwnerHttp>;
17
19
  enrollPoll(pollSecret: string, approvalId: string | null): Promise<OwnerHttp>;
18
20
  list(): Promise<OwnerHttp>;
@@ -52,16 +52,26 @@ export declare function enrollOrg(io: VerbIo, opts: {
52
52
  orgId: string;
53
53
  from: "flag" | "active";
54
54
  } | null;
55
+ /**
56
+ * `host enroll --scope`: personal (the default) enrolls this machine for its user's own sessions;
57
+ * org enrolls it as a runner of the org's pool (the any-machine amendment, Wassim 2026-11-09:
58
+ * S1 ORG_SCOPE_EXECUTOR_KINDS includes host). The org owner or admin's device-step approval is the
59
+ * guard, and the api also requires the org on DURABLE_POOL_CLAIM.
60
+ */
61
+ export declare const ENROLL_SCOPES: readonly ["personal", "org"];
62
+ export type EnrollScope = (typeof ENROLL_SCOPES)[number];
55
63
  /** The device step: start, show the code, poll, write the state file. Returns an exit code. */
56
64
  export declare function enrollDevice(io: VerbIo, opts?: {
57
65
  label?: string;
58
66
  org?: string;
67
+ scope?: EnrollScope;
59
68
  }): Promise<number>;
60
69
  /** Write the plist and (re)load the job. */
61
70
  export declare function writeAndLoad(io: VerbIo): Promise<number>;
62
71
  export declare function hostInstall(io: VerbIo): Promise<number>;
63
72
  export declare function hostEnroll(io: VerbIo, opts?: {
64
73
  org?: string;
74
+ scope?: EnrollScope;
65
75
  }): Promise<number>;
66
76
  export declare function hostRenew(io: VerbIo): Promise<number>;
67
77
  export declare function hostUninstall(io: VerbIo): Promise<number>;
@@ -80,8 +80,26 @@ function errorCode(body) {
80
80
  const code = e && typeof e === "object" ? e.code : undefined;
81
81
  return typeof code === "string" ? code : null;
82
82
  }
83
+ /**
84
+ * `host enroll --scope`: personal (the default) enrolls this machine for its user's own sessions;
85
+ * org enrolls it as a runner of the org's pool (the any-machine amendment, Wassim 2026-11-09:
86
+ * S1 ORG_SCOPE_EXECUTOR_KINDS includes host). The org owner or admin's device-step approval is the
87
+ * guard, and the api also requires the org on DURABLE_POOL_CLAIM.
88
+ */
89
+ export const ENROLL_SCOPES = ["personal", "org"];
83
90
  /** The device step: start, show the code, poll, write the state file. Returns an exit code. */
84
91
  export async function enrollDevice(io, opts = {}) {
92
+ const orgScope = opts.scope === "org";
93
+ if (opts.scope !== undefined && !ENROLL_SCOPES.includes(opts.scope)) {
94
+ io.err(`mnemom agent host: --scope must be ${ENROLL_SCOPES.join(" or ")}`);
95
+ return 1;
96
+ }
97
+ // An org runner names its org: --org, else the active org. The api requires it (34.03.3).
98
+ if (orgScope && !enrollOrg(io, opts)) {
99
+ io.err("mnemom agent host: --scope org needs the organization this machine will run jobs for.");
100
+ io.err("fix: `mnemom agent host enroll --scope org --org <org_id>` (`mnemom org list` shows them)");
101
+ return 1;
102
+ }
85
103
  const { paths } = ctx(io);
86
104
  const lock = (io.acquireLock ?? acquireHostLock)(path.join(paths.durableDir, "enroll.lock"));
87
105
  if (!lock.ok) {
@@ -96,13 +114,14 @@ export async function enrollDevice(io, opts = {}) {
96
114
  contract_version: EXECUTOR_CONTRACT,
97
115
  ...(opts.label ? { machine_label: opts.label } : {}),
98
116
  ...(o ? { org_id: o.orgId } : {}),
117
+ ...(orgScope ? { scope: "org" } : {}),
99
118
  });
100
119
  let st = await startWith(org);
101
120
  // An api from before 34.03.4 refuses org_id on a host start with its plain 400 invalid_scope
102
121
  // (34.03.2 (1); mnemom-api enroll.ts "org_id is only accepted with scope org"; recorded beside
103
122
  // exec_enroll in S1 codes.ts). An org taken from the active org then falls back to the start this
104
123
  // CLI made before; an explicit --org cannot.
105
- if (org && st.status === 400 && errorCode(st.body) === "invalid_scope") {
124
+ if (!orgScope && org && st.status === 400 && errorCode(st.body) === "invalid_scope") {
106
125
  if (org.from === "flag") {
107
126
  io.err(`mnemom agent host: this Mnemom server cannot enroll a machine into ${org.orgId} yet.`);
108
127
  io.err("fix: `mnemom agent host enroll` without --org (joins your personal org)");
@@ -115,6 +134,21 @@ export async function enrollDevice(io, opts = {}) {
115
134
  const start = st.status === 202 || st.status === 200 || st.status === 201
116
135
  ? parseEnrollStart(st.body)
117
136
  : null;
137
+ if (!start && orgScope && org) {
138
+ const code = errorCode(st.body);
139
+ if (code === CODE.durable_approval_not_eligible) {
140
+ // 34.03.3: only an owner or admin of the org may enroll an org runner.
141
+ io.err(`mnemom agent host: only an owner or admin of ${org.orgId} can enroll a machine as its runner.`);
142
+ io.err(`fix: ask an owner or admin of ${org.orgId} to run \`mnemom agent host enroll --scope org --org ${org.orgId}\` on this machine`);
143
+ return 1;
144
+ }
145
+ if (code === CODE.kind_not_allowed) {
146
+ // The org is not on the server's pool-claim list, or the server predates host org runners.
147
+ io.err(`mnemom agent host: this Mnemom server does not accept this machine as a runner for ${org.orgId} (org runners are not turned on for it, or the server does not take host runners yet).`);
148
+ io.err("fix: `mnemom agent host enroll` without --scope org enrolls it for your own sessions");
149
+ return 1;
150
+ }
151
+ }
118
152
  if (!start && org && errorCode(st.body) === CODE.durable_approval_not_eligible) {
119
153
  // 34.03.4: only a member of the org may enroll a machine into it.
120
154
  io.err(`mnemom agent host: you are not a member of ${org.orgId}, so this machine cannot join it.`);
@@ -173,7 +207,15 @@ export async function enrollDevice(io, opts = {}) {
173
207
  boot_id: io.bootId(),
174
208
  });
175
209
  if (io.json)
176
- io.out(JSON.stringify({ event: "enrolled", executor_id: p.executorId }));
210
+ io.out(JSON.stringify({
211
+ event: "enrolled",
212
+ executor_id: p.executorId,
213
+ ...(orgScope ? { scope: "org" } : {}),
214
+ }));
215
+ else if (orgScope && org) {
216
+ io.out(`Enrolled this machine as a runner for ${org.orgId} (executor ${p.executorId.slice(0, 8)}).`);
217
+ io.out(`To claim its pool jobs: \`mnemom agent run --watch\` with MNEMOM_EXECUTOR_KEY set to executor_key in ${paths.stateFile}`);
218
+ }
177
219
  else
178
220
  io.out(`Enrolled this machine (executor ${p.executorId.slice(0, 8)}).`);
179
221
  return 0;
package/dist/index.js CHANGED
@@ -1976,9 +1976,10 @@ program
1976
1976
  program
1977
1977
  .command("whoami")
1978
1978
  .description("Show current authentication status")
1979
- .action(async () => {
1979
+ .option("--json", "Print who is logged in as one JSON object: {logged_in, user_id, email, active_org, expires_at} (never a token)")
1980
+ .action(async (options) => {
1980
1981
  try {
1981
- await whoamiCommand();
1982
+ await whoamiCommand({ json: options.json });
1982
1983
  }
1983
1984
  catch (error) {
1984
1985
  console.error("Error:", error instanceof Error ? error.message : error);
package/dist/lib/api.d.ts CHANGED
@@ -426,6 +426,25 @@ export interface MeResponse {
426
426
  } | null;
427
427
  }
428
428
  export declare function getMe(): Promise<MeResponse>;
429
+ /** Who the credential belongs to — `GET /v1/auth/session`. A pure read for a
430
+ * bearer (no billing-account ensure and no SSO JIT provisioning, which
431
+ * `GET /v1/auth/me` runs on every call), so an identity check changes nothing. */
432
+ export interface SessionResponse {
433
+ user: {
434
+ id: string;
435
+ email?: string | null;
436
+ };
437
+ method?: string;
438
+ }
439
+ export declare function getSession(): Promise<SessionResponse>;
440
+ /** What an API key is — `GET /v1/api-keys/whoami` (ADR-049). A read; the key's
441
+ * own id and org, which is how a reader keys a key caller. */
442
+ export interface ApiKeyWhoamiResponse {
443
+ key_id: string | null;
444
+ org_id: string | null;
445
+ auth_method?: string;
446
+ }
447
+ export declare function getApiKeyWhoami(): Promise<ApiKeyWhoamiResponse>;
429
448
  /**
430
449
  * The org's Mnemom Units (MU) balance — `GET /v1/billing/mu` (the frozen mu
431
450
  * v0.1 balance, values as decimal strings). Amounts come in both whole MU
package/dist/lib/api.js CHANGED
@@ -440,6 +440,14 @@ export async function getMyPersonalOrg() {
440
440
  export async function getMe() {
441
441
  return gFetch("/v1/auth/me", {}, { fallback: "Could not read your identity" });
442
442
  }
443
+ export async function getSession() {
444
+ return gFetch("/v1/auth/session", {}, { fallback: "Could not read your identity" });
445
+ }
446
+ export async function getApiKeyWhoami() {
447
+ return gFetch("/v1/api-keys/whoami", {}, {
448
+ fallback: "Could not read your API key's identity",
449
+ });
450
+ }
443
451
  export async function getRecordTotals(orgId, lineageKey, signal) {
444
452
  return gFetch(`/v1/orgs/${encodeURIComponent(orgId)}/record/totals?lineage_key=${encodeURIComponent(lineageKey)}`, signal ? { signal } : {}, { notFoundLabel: "Record totals", fallback: "Could not read record totals" });
445
453
  }
@@ -1664,8 +1664,9 @@ var M1_CLI_SURFACE = Object.freeze([
1664
1664
  entry(["agent", "done"], ["--json"]),
1665
1665
  // BACKBONE C2 (`mnemom agent host` replaces `mnemom host`, amendment 2; design-v3 row 7).
1666
1666
  entry(["agent", "host"]),
1667
- // BACKBONE C2 enroll (device step); --org: ruling 34.03.4 (a host joins an org its user belongs to).
1668
- entry(["agent", "host", "enroll"], ["--org"]),
1667
+ // BACKBONE C2 enroll (device step); --org: ruling 34.03.4 (a host joins an org its user belongs to);
1668
+ // --scope: the any-machine amendment (Wassim 2026-11-09; `--scope org` enrolls an org runner).
1669
+ entry(["agent", "host", "enroll"], ["--org", "--scope"]),
1669
1670
  // BACKBONE C2 install/status/uninstall; --json: C04 (`mnemom agent host install --json`),
1670
1671
  // ruling 32.31.
1671
1672
  entry(["agent", "host", "install"], ["--json"]),
@@ -2379,7 +2380,7 @@ function poolClaimOrgs(flagValue) {
2379
2380
  return [...new Set(orgs)];
2380
2381
  }
2381
2382
  var EXECUTOR_SCOPES = ["user", "org"];
2382
- var ORG_SCOPE_EXECUTOR_KINDS = ["container", "fleet"];
2383
+ var ORG_SCOPE_EXECUTOR_KINDS = ["container", "fleet", "host"];
2383
2384
  var OIDC_EXCHANGE_REFUSALS = [
2384
2385
  "bad_issuer",
2385
2386
  "repo_not_enrolled",
@@ -2406,6 +2407,8 @@ var OIDC_EXCHANGE_BODY_KEYS = ["oidc_token", "adopt_ticket"];
2406
2407
  var OIDC_TOKEN_MAX = 8192;
2407
2408
  var OIDC_WORKFLOW_ENV = "DISPATCH_WAKEUP_WORKFLOW";
2408
2409
  var OIDC_WORKFLOW_FILE_RE = /^[A-Za-z0-9_.-]{1,100}\.ya?ml$/;
2410
+ var OIDC_WORKFLOW_SET_ENV = "DURABLE_OIDC_WORKFLOWS";
2411
+ var OIDC_WORKFLOW_SET_MAX = 8;
2409
2412
  function adoptTicketOk(t) {
2410
2413
  return typeof t === "string" && ADOPT_TICKET_RE.test(t);
2411
2414
  }
@@ -2422,9 +2425,22 @@ function oidcPinnedWorkflow(envValue) {
2422
2425
  const wf = envValue?.trim() ?? "";
2423
2426
  return OIDC_WORKFLOW_FILE_RE.test(wf) ? wf : null;
2424
2427
  }
2428
+ function oidcWorkflowSet(setValue, pinValue) {
2429
+ if (setValue === void 0) {
2430
+ const pin = oidcPinnedWorkflow(pinValue);
2431
+ return pin === null ? null : [pin];
2432
+ }
2433
+ const names = setValue.split(",").map((n) => n.trim());
2434
+ if (names.some((n) => !OIDC_WORKFLOW_FILE_RE.test(n) || n.includes(".."))) return null;
2435
+ const set = [...new Set(names)];
2436
+ return set.length > OIDC_WORKFLOW_SET_MAX ? null : set;
2437
+ }
2425
2438
  function jobWorkflowRefOk(jobWorkflowRef, repository, workflowFile, defaultBranch) {
2426
2439
  return typeof jobWorkflowRef === "string" && jobWorkflowRef === `${repository}/.github/workflows/${workflowFile}@refs/heads/${defaultBranch}`;
2427
2440
  }
2441
+ function jobWorkflowRefInSet(jobWorkflowRef, repository, workflowFiles, defaultBranch) {
2442
+ return workflowFiles.some((f2) => jobWorkflowRefOk(jobWorkflowRef, repository, f2, defaultBranch));
2443
+ }
2428
2444
  var ORG_POLICY_LIMIT_KEYS = ["per_session_budget_usd", "live_lease_cap"];
2429
2445
  var LIVE_LEASE_CAP_MIN = 1;
2430
2446
  var LIVE_LEASE_CAP_MAX = 1e3;
@@ -5908,6 +5924,8 @@ var FREEZE_RECORD = Object.freeze({
5908
5924
  OIDC_TOKEN_MAX,
5909
5925
  OIDC_WORKFLOW_ENV,
5910
5926
  OIDC_WORKFLOW_FILE_PATTERN: OIDC_WORKFLOW_FILE_RE.source,
5927
+ OIDC_WORKFLOW_SET_ENV,
5928
+ OIDC_WORKFLOW_SET_MAX,
5911
5929
  READY_SESSIONS_LIMIT_MIN,
5912
5930
  READY_SESSIONS_LIMIT_MAX,
5913
5931
  // amendment 64
@@ -6616,6 +6634,8 @@ export {
6616
6634
  OIDC_TOKEN_MAX,
6617
6635
  OIDC_WORKFLOW_ENV,
6618
6636
  OIDC_WORKFLOW_FILE_RE,
6637
+ OIDC_WORKFLOW_SET_ENV,
6638
+ OIDC_WORKFLOW_SET_MAX,
6619
6639
  OLD_ROUTE_BRANCH,
6620
6640
  OLD_ROUTE_REFRESH,
6621
6641
  ORG_CAP_LOCK_PREFIX,
@@ -6928,6 +6948,7 @@ export {
6928
6948
  isValidSessionName,
6929
6949
  isValidStateReason,
6930
6950
  isWellFormedLeaseKey,
6951
+ jobWorkflowRefInSet,
6931
6952
  jobWorkflowRefOk,
6932
6953
  lagBucket,
6933
6954
  lintDurableOnly,
@@ -6943,6 +6964,7 @@ export {
6943
6964
  oidcExchangeBodyOk,
6944
6965
  oidcPinnedWorkflow,
6945
6966
  oidcRunClaimsOk,
6967
+ oidcWorkflowSet,
6946
6968
  openContract,
6947
6969
  ownerSessionViewOk,
6948
6970
  ownerVerbDecision,
@@ -36,8 +36,9 @@ 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); --org: ruling 34.03.4 (a host joins an org its user belongs to).
40
- entry(["agent", "host", "enroll"], ["--org"]),
39
+ // BACKBONE C2 enroll (device step); --org: ruling 34.03.4 (a host joins an org its user belongs to);
40
+ // --scope: the any-machine amendment (Wassim 2026-11-09; `--scope org` enrolls an org runner).
41
+ entry(["agent", "host", "enroll"], ["--org", "--scope"]),
41
42
  // BACKBONE C2 install/status/uninstall; --json: C04 (`mnemom agent host install --json`),
42
43
  // ruling 32.31.
43
44
  entry(["agent", "host", "install"], ["--json"]),
@@ -31,8 +31,13 @@ export declare function isPoolSession(s: PoolSessionFacts, flagValue: string | u
31
31
  export declare function poolClaimOrgs(flagValue: string | undefined): string[];
32
32
  export declare const EXECUTOR_SCOPES: readonly ["user", "org"];
33
33
  export type ExecutorScope = (typeof EXECUTOR_SCOPES)[number];
34
- /** Only these executor kinds may enroll with scope org. */
35
- export declare const ORG_SCOPE_EXECUTOR_KINDS: readonly ["container", "fleet"];
34
+ /**
35
+ * Only these executor kinds may enroll with scope org. The any-machine amendment (Wassim, 2026-11-09):
36
+ * any enrolled machine may be an org runner, so `host` (a Mac mini, laptop or VM) joins container and
37
+ * fleet, appended. The guard is the org owner or admin's aal2 device-step approval at enrollment
38
+ * and the org's DURABLE_POOL_CLAIM entry, not the machine kind.
39
+ */
40
+ export declare const ORG_SCOPE_EXECUTOR_KINDS: readonly ["container", "fleet", "host"];
36
41
  /**
37
42
  * exec_oidc_exchange refusals (ruling 34.03's S1 changes), each 403, checked in this order.
38
43
  * Amendment 65 (mnemom-test#1331) appends the two single-use codes, checked in the same transaction
@@ -83,6 +88,16 @@ export declare const OIDC_TOKEN_MAX = 8192;
83
88
  export declare const OIDC_WORKFLOW_ENV = "DISPATCH_WAKEUP_WORKFLOW";
84
89
  /** A workflow file name under .github/workflows: no path, no query. */
85
90
  export declare const OIDC_WORKFLOW_FILE_RE: RegExp;
91
+ /**
92
+ * Amendment 66, part 2 (Durable lead ruling (a), 2026-11-09): the api env var naming the set of
93
+ * workflow files the exchange accepts, comma-separated bare file names. Unset, the set is
94
+ * OIDC_WORKFLOW_ENV's one file, exactly as before. Set, it replaces that pin for the exchange only
95
+ * (the wake-up still dispatches OIDC_WORKFLOW_ENV's file), and a set that is empty or holds any
96
+ * entry that is not a workflow file name (a wildcard, a "/", a "..") turns the exchange dark.
97
+ */
98
+ export declare const OIDC_WORKFLOW_SET_ENV = "DURABLE_OIDC_WORKFLOWS";
99
+ /** The most workflow files the set may name. */
100
+ export declare const OIDC_WORKFLOW_SET_MAX = 8;
86
101
  /** True when `t` has an adopt ticket's shape. */
87
102
  export declare function adoptTicketOk(t: unknown): t is string;
88
103
  /**
@@ -93,6 +108,14 @@ export declare function adoptTicketOk(t: unknown): t is string;
93
108
  export declare function oidcExchangeBodyOk(v: unknown): boolean;
94
109
  /** The pinned workflow file from the env value, or null (then the exchange is dark). */
95
110
  export declare function oidcPinnedWorkflow(envValue: string | undefined): string | null;
111
+ /**
112
+ * The workflow files the exchange accepts, or null (then the exchange is dark).
113
+ * `setValue` is OIDC_WORKFLOW_SET_ENV's value, `pinValue` OIDC_WORKFLOW_ENV's. With the set unset
114
+ * the answer is the one pinned file, as oidcPinnedWorkflow reads it. With it set, every
115
+ * comma-separated entry, trimmed, must be a workflow file name with no "..", and there must be one
116
+ * to OIDC_WORKFLOW_SET_MAX of them; anything else is null, never a partial set. Duplicates collapse.
117
+ */
118
+ export declare function oidcWorkflowSet(setValue: string | undefined, pinValue: string | undefined): readonly string[] | null;
96
119
  /**
97
120
  * The workflow pin: the token's job_workflow_ref must be exactly
98
121
  * `<repository>/.github/workflows/<pinned file>@refs/heads/<default branch>`, with `repository`
@@ -100,6 +123,8 @@ export declare function oidcPinnedWorkflow(envValue: string | undefined): string
100
123
  * branch, a tag or sha ref) is refused ref_not_allowed.
101
124
  */
102
125
  export declare function jobWorkflowRefOk(jobWorkflowRef: unknown, repository: string, workflowFile: string, defaultBranch: string): boolean;
126
+ /** jobWorkflowRefOk for any one file of an oidcWorkflowSet answer (amendment 66, part 2). */
127
+ export declare function jobWorkflowRefInSet(jobWorkflowRef: unknown, repository: string, workflowFiles: readonly string[], defaultBranch: string): boolean;
103
128
  /** durable_org_policy.limits keys (34.05 (1)); live_lease_cap is optional. */
104
129
  export declare const ORG_POLICY_LIMIT_KEYS: readonly ["per_session_budget_usd", "live_lease_cap"];
105
130
  /** live_lease_cap and the registry default share this range. */
@@ -43,8 +43,13 @@ export function poolClaimOrgs(flagValue) {
43
43
  }
44
44
  // ---- Executor scope (ruling 34.03 (1)) ------------------------------------------------------
45
45
  export const EXECUTOR_SCOPES = ["user", "org"];
46
- /** Only these executor kinds may enroll with scope org. */
47
- export const ORG_SCOPE_EXECUTOR_KINDS = ["container", "fleet"];
46
+ /**
47
+ * Only these executor kinds may enroll with scope org. The any-machine amendment (Wassim, 2026-11-09):
48
+ * any enrolled machine may be an org runner, so `host` (a Mac mini, laptop or VM) joins container and
49
+ * fleet, appended. The guard is the org owner or admin's aal2 device-step approval at enrollment
50
+ * and the org's DURABLE_POOL_CLAIM entry, not the machine kind.
51
+ */
52
+ export const ORG_SCOPE_EXECUTOR_KINDS = ["container", "fleet", "host"];
48
53
  /**
49
54
  * exec_oidc_exchange refusals (ruling 34.03's S1 changes), each 403, checked in this order.
50
55
  * Amendment 65 (mnemom-test#1331) appends the two single-use codes, checked in the same transaction
@@ -113,6 +118,16 @@ export const OIDC_TOKEN_MAX = 8192;
113
118
  export const OIDC_WORKFLOW_ENV = "DISPATCH_WAKEUP_WORKFLOW";
114
119
  /** A workflow file name under .github/workflows: no path, no query. */
115
120
  export const OIDC_WORKFLOW_FILE_RE = /^[A-Za-z0-9_.-]{1,100}\.ya?ml$/;
121
+ /**
122
+ * Amendment 66, part 2 (Durable lead ruling (a), 2026-11-09): the api env var naming the set of
123
+ * workflow files the exchange accepts, comma-separated bare file names. Unset, the set is
124
+ * OIDC_WORKFLOW_ENV's one file, exactly as before. Set, it replaces that pin for the exchange only
125
+ * (the wake-up still dispatches OIDC_WORKFLOW_ENV's file), and a set that is empty or holds any
126
+ * entry that is not a workflow file name (a wildcard, a "/", a "..") turns the exchange dark.
127
+ */
128
+ export const OIDC_WORKFLOW_SET_ENV = "DURABLE_OIDC_WORKFLOWS";
129
+ /** The most workflow files the set may name. */
130
+ export const OIDC_WORKFLOW_SET_MAX = 8;
116
131
  /** True when `t` has an adopt ticket's shape. */
117
132
  export function adoptTicketOk(t) {
118
133
  return typeof t === "string" && ADOPT_TICKET_RE.test(t);
@@ -138,6 +153,24 @@ export function oidcPinnedWorkflow(envValue) {
138
153
  const wf = envValue?.trim() ?? "";
139
154
  return OIDC_WORKFLOW_FILE_RE.test(wf) ? wf : null;
140
155
  }
156
+ /**
157
+ * The workflow files the exchange accepts, or null (then the exchange is dark).
158
+ * `setValue` is OIDC_WORKFLOW_SET_ENV's value, `pinValue` OIDC_WORKFLOW_ENV's. With the set unset
159
+ * the answer is the one pinned file, as oidcPinnedWorkflow reads it. With it set, every
160
+ * comma-separated entry, trimmed, must be a workflow file name with no "..", and there must be one
161
+ * to OIDC_WORKFLOW_SET_MAX of them; anything else is null, never a partial set. Duplicates collapse.
162
+ */
163
+ export function oidcWorkflowSet(setValue, pinValue) {
164
+ if (setValue === undefined) {
165
+ const pin = oidcPinnedWorkflow(pinValue);
166
+ return pin === null ? null : [pin];
167
+ }
168
+ const names = setValue.split(",").map((n) => n.trim());
169
+ if (names.some((n) => !OIDC_WORKFLOW_FILE_RE.test(n) || n.includes("..")))
170
+ return null;
171
+ const set = [...new Set(names)];
172
+ return set.length > OIDC_WORKFLOW_SET_MAX ? null : set;
173
+ }
141
174
  /**
142
175
  * The workflow pin: the token's job_workflow_ref must be exactly
143
176
  * `<repository>/.github/workflows/<pinned file>@refs/heads/<default branch>`, with `repository`
@@ -148,6 +181,10 @@ export function jobWorkflowRefOk(jobWorkflowRef, repository, workflowFile, defau
148
181
  return (typeof jobWorkflowRef === "string" &&
149
182
  jobWorkflowRef === `${repository}/.github/workflows/${workflowFile}@refs/heads/${defaultBranch}`);
150
183
  }
184
+ /** jobWorkflowRefOk for any one file of an oidcWorkflowSet answer (amendment 66, part 2). */
185
+ export function jobWorkflowRefInSet(jobWorkflowRef, repository, workflowFiles, defaultBranch) {
186
+ return workflowFiles.some((f) => jobWorkflowRefOk(jobWorkflowRef, repository, f, defaultBranch));
187
+ }
151
188
  // ---- Per-org live-lease cap (rulings 34.05, 34.05.1) ----------------------------------------
152
189
  /** durable_org_policy.limits keys (34.05 (1)); live_lease_cap is optional. */
153
190
  export const ORG_POLICY_LIMIT_KEYS = ["per_session_budget_usd", "live_lease_cap"];
@@ -128,6 +128,8 @@ export declare const FREEZE_RECORD: Readonly<{
128
128
  OIDC_TOKEN_MAX: 8192;
129
129
  OIDC_WORKFLOW_ENV: "DISPATCH_WAKEUP_WORKFLOW";
130
130
  OIDC_WORKFLOW_FILE_PATTERN: string;
131
+ OIDC_WORKFLOW_SET_ENV: "DURABLE_OIDC_WORKFLOWS";
132
+ OIDC_WORKFLOW_SET_MAX: 8;
131
133
  READY_SESSIONS_LIMIT_MIN: 1;
132
134
  READY_SESSIONS_LIMIT_MAX: 50;
133
135
  ADOPT_SOURCE_REF_MAX: 500;
@@ -748,7 +750,7 @@ export declare const FREEZE_STRUCTURED: Readonly<{
748
750
  }>;
749
751
  CLEAR_WAIT_OUTCOMES: readonly ["cleared", "stale_ref", "already_clear"];
750
752
  EXECUTOR_SCOPES: readonly ["user", "org"];
751
- ORG_SCOPE_EXECUTOR_KINDS: readonly ["container", "fleet"];
753
+ ORG_SCOPE_EXECUTOR_KINDS: readonly ["container", "fleet", "host"];
752
754
  OIDC_EXCHANGE_REFUSALS: readonly ["bad_issuer", "repo_not_enrolled", "ref_not_allowed", "pool_flag_off", "adopt_ticket_invalid", "token_replayed", "run_already_exchanged"];
753
755
  OIDC_EXCHANGE_BODY_KEYS: readonly ["oidc_token", "adopt_ticket"];
754
756
  OIDC_EXCHANGE_RUN_CLAIMS: readonly ["jti", "run_id", "run_attempt"];
@@ -159,6 +159,8 @@ export const FREEZE_RECORD = Object.freeze({
159
159
  OIDC_TOKEN_MAX: D.OIDC_TOKEN_MAX,
160
160
  OIDC_WORKFLOW_ENV: D.OIDC_WORKFLOW_ENV,
161
161
  OIDC_WORKFLOW_FILE_PATTERN: D.OIDC_WORKFLOW_FILE_RE.source,
162
+ OIDC_WORKFLOW_SET_ENV: D.OIDC_WORKFLOW_SET_ENV,
163
+ OIDC_WORKFLOW_SET_MAX: D.OIDC_WORKFLOW_SET_MAX,
162
164
  READY_SESSIONS_LIMIT_MIN: D.READY_SESSIONS_LIMIT_MIN,
163
165
  READY_SESSIONS_LIMIT_MAX: D.READY_SESSIONS_LIMIT_MAX,
164
166
  // amendment 64
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.39-next.1",
3
+ "version": "0.17.39-next.3",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {