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

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
@@ -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")
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
  }
@@ -2406,6 +2406,8 @@ var OIDC_EXCHANGE_BODY_KEYS = ["oidc_token", "adopt_ticket"];
2406
2406
  var OIDC_TOKEN_MAX = 8192;
2407
2407
  var OIDC_WORKFLOW_ENV = "DISPATCH_WAKEUP_WORKFLOW";
2408
2408
  var OIDC_WORKFLOW_FILE_RE = /^[A-Za-z0-9_.-]{1,100}\.ya?ml$/;
2409
+ var OIDC_WORKFLOW_SET_ENV = "DURABLE_OIDC_WORKFLOWS";
2410
+ var OIDC_WORKFLOW_SET_MAX = 8;
2409
2411
  function adoptTicketOk(t) {
2410
2412
  return typeof t === "string" && ADOPT_TICKET_RE.test(t);
2411
2413
  }
@@ -2422,9 +2424,22 @@ function oidcPinnedWorkflow(envValue) {
2422
2424
  const wf = envValue?.trim() ?? "";
2423
2425
  return OIDC_WORKFLOW_FILE_RE.test(wf) ? wf : null;
2424
2426
  }
2427
+ function oidcWorkflowSet(setValue, pinValue) {
2428
+ if (setValue === void 0) {
2429
+ const pin = oidcPinnedWorkflow(pinValue);
2430
+ return pin === null ? null : [pin];
2431
+ }
2432
+ const names = setValue.split(",").map((n) => n.trim());
2433
+ if (names.some((n) => !OIDC_WORKFLOW_FILE_RE.test(n) || n.includes(".."))) return null;
2434
+ const set = [...new Set(names)];
2435
+ return set.length > OIDC_WORKFLOW_SET_MAX ? null : set;
2436
+ }
2425
2437
  function jobWorkflowRefOk(jobWorkflowRef, repository, workflowFile, defaultBranch) {
2426
2438
  return typeof jobWorkflowRef === "string" && jobWorkflowRef === `${repository}/.github/workflows/${workflowFile}@refs/heads/${defaultBranch}`;
2427
2439
  }
2440
+ function jobWorkflowRefInSet(jobWorkflowRef, repository, workflowFiles, defaultBranch) {
2441
+ return workflowFiles.some((f2) => jobWorkflowRefOk(jobWorkflowRef, repository, f2, defaultBranch));
2442
+ }
2428
2443
  var ORG_POLICY_LIMIT_KEYS = ["per_session_budget_usd", "live_lease_cap"];
2429
2444
  var LIVE_LEASE_CAP_MIN = 1;
2430
2445
  var LIVE_LEASE_CAP_MAX = 1e3;
@@ -5908,6 +5923,8 @@ var FREEZE_RECORD = Object.freeze({
5908
5923
  OIDC_TOKEN_MAX,
5909
5924
  OIDC_WORKFLOW_ENV,
5910
5925
  OIDC_WORKFLOW_FILE_PATTERN: OIDC_WORKFLOW_FILE_RE.source,
5926
+ OIDC_WORKFLOW_SET_ENV,
5927
+ OIDC_WORKFLOW_SET_MAX,
5911
5928
  READY_SESSIONS_LIMIT_MIN,
5912
5929
  READY_SESSIONS_LIMIT_MAX,
5913
5930
  // amendment 64
@@ -6616,6 +6633,8 @@ export {
6616
6633
  OIDC_TOKEN_MAX,
6617
6634
  OIDC_WORKFLOW_ENV,
6618
6635
  OIDC_WORKFLOW_FILE_RE,
6636
+ OIDC_WORKFLOW_SET_ENV,
6637
+ OIDC_WORKFLOW_SET_MAX,
6619
6638
  OLD_ROUTE_BRANCH,
6620
6639
  OLD_ROUTE_REFRESH,
6621
6640
  ORG_CAP_LOCK_PREFIX,
@@ -6928,6 +6947,7 @@ export {
6928
6947
  isValidSessionName,
6929
6948
  isValidStateReason,
6930
6949
  isWellFormedLeaseKey,
6950
+ jobWorkflowRefInSet,
6931
6951
  jobWorkflowRefOk,
6932
6952
  lagBucket,
6933
6953
  lintDurableOnly,
@@ -6943,6 +6963,7 @@ export {
6943
6963
  oidcExchangeBodyOk,
6944
6964
  oidcPinnedWorkflow,
6945
6965
  oidcRunClaimsOk,
6966
+ oidcWorkflowSet,
6946
6967
  openContract,
6947
6968
  ownerSessionViewOk,
6948
6969
  ownerVerbDecision,
@@ -83,6 +83,16 @@ export declare const OIDC_TOKEN_MAX = 8192;
83
83
  export declare const OIDC_WORKFLOW_ENV = "DISPATCH_WAKEUP_WORKFLOW";
84
84
  /** A workflow file name under .github/workflows: no path, no query. */
85
85
  export declare const OIDC_WORKFLOW_FILE_RE: RegExp;
86
+ /**
87
+ * Amendment 66, part 2 (Durable lead ruling (a), 2026-11-09): the api env var naming the set of
88
+ * workflow files the exchange accepts, comma-separated bare file names. Unset, the set is
89
+ * OIDC_WORKFLOW_ENV's one file, exactly as before. Set, it replaces that pin for the exchange only
90
+ * (the wake-up still dispatches OIDC_WORKFLOW_ENV's file), and a set that is empty or holds any
91
+ * entry that is not a workflow file name (a wildcard, a "/", a "..") turns the exchange dark.
92
+ */
93
+ export declare const OIDC_WORKFLOW_SET_ENV = "DURABLE_OIDC_WORKFLOWS";
94
+ /** The most workflow files the set may name. */
95
+ export declare const OIDC_WORKFLOW_SET_MAX = 8;
86
96
  /** True when `t` has an adopt ticket's shape. */
87
97
  export declare function adoptTicketOk(t: unknown): t is string;
88
98
  /**
@@ -93,6 +103,14 @@ export declare function adoptTicketOk(t: unknown): t is string;
93
103
  export declare function oidcExchangeBodyOk(v: unknown): boolean;
94
104
  /** The pinned workflow file from the env value, or null (then the exchange is dark). */
95
105
  export declare function oidcPinnedWorkflow(envValue: string | undefined): string | null;
106
+ /**
107
+ * The workflow files the exchange accepts, or null (then the exchange is dark).
108
+ * `setValue` is OIDC_WORKFLOW_SET_ENV's value, `pinValue` OIDC_WORKFLOW_ENV's. With the set unset
109
+ * the answer is the one pinned file, as oidcPinnedWorkflow reads it. With it set, every
110
+ * comma-separated entry, trimmed, must be a workflow file name with no "..", and there must be one
111
+ * to OIDC_WORKFLOW_SET_MAX of them; anything else is null, never a partial set. Duplicates collapse.
112
+ */
113
+ export declare function oidcWorkflowSet(setValue: string | undefined, pinValue: string | undefined): readonly string[] | null;
96
114
  /**
97
115
  * The workflow pin: the token's job_workflow_ref must be exactly
98
116
  * `<repository>/.github/workflows/<pinned file>@refs/heads/<default branch>`, with `repository`
@@ -100,6 +118,8 @@ export declare function oidcPinnedWorkflow(envValue: string | undefined): string
100
118
  * branch, a tag or sha ref) is refused ref_not_allowed.
101
119
  */
102
120
  export declare function jobWorkflowRefOk(jobWorkflowRef: unknown, repository: string, workflowFile: string, defaultBranch: string): boolean;
121
+ /** jobWorkflowRefOk for any one file of an oidcWorkflowSet answer (amendment 66, part 2). */
122
+ export declare function jobWorkflowRefInSet(jobWorkflowRef: unknown, repository: string, workflowFiles: readonly string[], defaultBranch: string): boolean;
103
123
  /** durable_org_policy.limits keys (34.05 (1)); live_lease_cap is optional. */
104
124
  export declare const ORG_POLICY_LIMIT_KEYS: readonly ["per_session_budget_usd", "live_lease_cap"];
105
125
  /** live_lease_cap and the registry default share this range. */
@@ -113,6 +113,16 @@ export const OIDC_TOKEN_MAX = 8192;
113
113
  export const OIDC_WORKFLOW_ENV = "DISPATCH_WAKEUP_WORKFLOW";
114
114
  /** A workflow file name under .github/workflows: no path, no query. */
115
115
  export const OIDC_WORKFLOW_FILE_RE = /^[A-Za-z0-9_.-]{1,100}\.ya?ml$/;
116
+ /**
117
+ * Amendment 66, part 2 (Durable lead ruling (a), 2026-11-09): the api env var naming the set of
118
+ * workflow files the exchange accepts, comma-separated bare file names. Unset, the set is
119
+ * OIDC_WORKFLOW_ENV's one file, exactly as before. Set, it replaces that pin for the exchange only
120
+ * (the wake-up still dispatches OIDC_WORKFLOW_ENV's file), and a set that is empty or holds any
121
+ * entry that is not a workflow file name (a wildcard, a "/", a "..") turns the exchange dark.
122
+ */
123
+ export const OIDC_WORKFLOW_SET_ENV = "DURABLE_OIDC_WORKFLOWS";
124
+ /** The most workflow files the set may name. */
125
+ export const OIDC_WORKFLOW_SET_MAX = 8;
116
126
  /** True when `t` has an adopt ticket's shape. */
117
127
  export function adoptTicketOk(t) {
118
128
  return typeof t === "string" && ADOPT_TICKET_RE.test(t);
@@ -138,6 +148,24 @@ export function oidcPinnedWorkflow(envValue) {
138
148
  const wf = envValue?.trim() ?? "";
139
149
  return OIDC_WORKFLOW_FILE_RE.test(wf) ? wf : null;
140
150
  }
151
+ /**
152
+ * The workflow files the exchange accepts, or null (then the exchange is dark).
153
+ * `setValue` is OIDC_WORKFLOW_SET_ENV's value, `pinValue` OIDC_WORKFLOW_ENV's. With the set unset
154
+ * the answer is the one pinned file, as oidcPinnedWorkflow reads it. With it set, every
155
+ * comma-separated entry, trimmed, must be a workflow file name with no "..", and there must be one
156
+ * to OIDC_WORKFLOW_SET_MAX of them; anything else is null, never a partial set. Duplicates collapse.
157
+ */
158
+ export function oidcWorkflowSet(setValue, pinValue) {
159
+ if (setValue === undefined) {
160
+ const pin = oidcPinnedWorkflow(pinValue);
161
+ return pin === null ? null : [pin];
162
+ }
163
+ const names = setValue.split(",").map((n) => n.trim());
164
+ if (names.some((n) => !OIDC_WORKFLOW_FILE_RE.test(n) || n.includes("..")))
165
+ return null;
166
+ const set = [...new Set(names)];
167
+ return set.length > OIDC_WORKFLOW_SET_MAX ? null : set;
168
+ }
141
169
  /**
142
170
  * The workflow pin: the token's job_workflow_ref must be exactly
143
171
  * `<repository>/.github/workflows/<pinned file>@refs/heads/<default branch>`, with `repository`
@@ -148,6 +176,10 @@ export function jobWorkflowRefOk(jobWorkflowRef, repository, workflowFile, defau
148
176
  return (typeof jobWorkflowRef === "string" &&
149
177
  jobWorkflowRef === `${repository}/.github/workflows/${workflowFile}@refs/heads/${defaultBranch}`);
150
178
  }
179
+ /** jobWorkflowRefOk for any one file of an oidcWorkflowSet answer (amendment 66, part 2). */
180
+ export function jobWorkflowRefInSet(jobWorkflowRef, repository, workflowFiles, defaultBranch) {
181
+ return workflowFiles.some((f) => jobWorkflowRefOk(jobWorkflowRef, repository, f, defaultBranch));
182
+ }
151
183
  // ---- Per-org live-lease cap (rulings 34.05, 34.05.1) ----------------------------------------
152
184
  /** durable_org_policy.limits keys (34.05 (1)); live_lease_cap is optional. */
153
185
  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;
@@ -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.2",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {