@mnemom/mnemom 0.17.31-next.0 → 0.17.31

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,4 +1,5 @@
1
1
  import { getAuthInfo, clearAuthTokens, loginWithBrowser, loginWithDeviceFlow, resolveAuth, } from "../lib/auth.js";
2
+ import { getMe } from "../lib/api.js";
2
3
  import { fmt } from "../lib/format.js";
3
4
  export async function loginCommand(options = {}) {
4
5
  try {
@@ -7,6 +8,7 @@ export async function loginCommand(options = {}) {
7
8
  const tokens = options.noBrowser ? await loginWithDeviceFlow() : await loginWithBrowser();
8
9
  console.log();
9
10
  console.log(fmt.success("Logged in successfully!"));
11
+ await printSignedInAs();
10
12
  printGrant(tokens);
11
13
  console.log();
12
14
  }
@@ -16,6 +18,24 @@ export async function loginCommand(options = {}) {
16
18
  process.exit(1);
17
19
  }
18
20
  }
21
+ /**
22
+ * Name the account this login signed in as, so a login finished in the wrong
23
+ * account (an approval from another browser or device that was signed in as
24
+ * someone else) is visible straight away. The OAuth token carries no identity,
25
+ * so this asks the API; if that fails the login still succeeded, so it prints
26
+ * nothing rather than an error.
27
+ */
28
+ async function printSignedInAs() {
29
+ let email;
30
+ try {
31
+ email = (await getMe()).email;
32
+ }
33
+ catch {
34
+ return;
35
+ }
36
+ if (email)
37
+ console.log(`Signed in as ${email}`);
38
+ }
19
39
  /**
20
40
  * Print what the issued token actually grants. OAuth access tokens are scoped
21
41
  * and (deliberately) carry no user identity, so we show scope + expiry rather
@@ -105,6 +105,31 @@ export async function teamShowCommand(teamId, opts) {
105
105
  }
106
106
  console.log();
107
107
  }
108
+ /**
109
+ * What a template write did to the members' SERVED cards (mnemom-platform#2779).
110
+ *
111
+ * Before #2779 this printed "Agents recompose: N" for a count of rows merely
112
+ * FLAGGED for the 15-minute recompose sweep, while `card show` kept serving
113
+ * the stale composed card. The API now recomposes active members in-request
114
+ * and reports `agents_recomposed` / `agents_recompose_pending`; an older API
115
+ * that only flags is reported as queued, never as recomposed.
116
+ */
117
+ function printRecomposeLines(result) {
118
+ if (typeof result.agents_recomposed === "number") {
119
+ const pending = result.agents_recompose_pending ?? 0;
120
+ console.log(` Agents recomposed: ${result.agents_recomposed} ` +
121
+ `(active members of this team; their served card is updated now)`);
122
+ if (pending > 0) {
123
+ console.log(` Agents pending: ${pending} ` +
124
+ `(queued for the recompose sweep, ~15 min; confirm with \`mnemom card show --agent <id>\`)`);
125
+ }
126
+ return;
127
+ }
128
+ if (typeof result.agents_flagged_for_recompose === "number") {
129
+ console.log(` Agents queued: ${result.agents_flagged_for_recompose} ` +
130
+ `(active members of this team only; served cards update on the next recompose sweep, ~15 min)`);
131
+ }
132
+ }
108
133
  export async function teamTemplateCommand(kind, teamId, opts) {
109
134
  await requireAuth();
110
135
  if (!teamId) {
@@ -149,10 +174,7 @@ export async function teamTemplateCommand(kind, teamId, opts) {
149
174
  console.log(` Team ID: ${result.team_id}`);
150
175
  console.log(` Org ID: ${result.org_id}`);
151
176
  console.log(` Enabled: ${result.enabled ? "yes" : "no"}`);
152
- if (typeof result.agents_flagged_for_recompose === "number") {
153
- console.log(` Agents recompose: ${result.agents_flagged_for_recompose} ` +
154
- `(active members of this team only — not blanket fan-out)`);
155
- }
177
+ printRecomposeLines(result);
156
178
  console.log();
157
179
  return;
158
180
  }
@@ -177,9 +199,7 @@ export async function teamTemplateCommand(kind, teamId, opts) {
177
199
  console.log(` Team ID: ${result.team_id}`);
178
200
  console.log(` Org ID: ${result.org_id}`);
179
201
  console.log(` Deleted: yes`);
180
- if (typeof result.agents_flagged_for_recompose === "number") {
181
- console.log(` Agents recompose: ${result.agents_flagged_for_recompose}`);
182
- }
202
+ printRecomposeLines(result);
183
203
  console.log();
184
204
  return;
185
205
  }
package/dist/lib/api.d.ts CHANGED
@@ -470,7 +470,15 @@ export interface TeamTemplateBody {
470
470
  name?: string;
471
471
  template: Record<string, unknown> | null;
472
472
  enabled: boolean;
473
+ /** Active members whose canonical rows were flagged for the recompose sweep. */
473
474
  agents_flagged_for_recompose?: number;
475
+ /**
476
+ * Active members recomposed in-request (mnemom-platform#2779): their served
477
+ * card already reflects this write. Absent on an API that only flags.
478
+ */
479
+ agents_recomposed?: number;
480
+ /** Active members still waiting on the recompose sweep (cap or compose failure). */
481
+ agents_recompose_pending?: number;
474
482
  deleted?: boolean;
475
483
  }
476
484
  export type TeamTemplateKind = "alignment" | "protection";
@@ -1326,10 +1334,16 @@ export interface AgentSessionGuardrails {
1326
1334
  turns: number;
1327
1335
  spend_usd: number;
1328
1336
  stall_run: number;
1337
+ /**
1338
+ * Which ceilings this session has crossed. Under `enforce` a crossing refuses the request
1339
+ * (429) without counting it as a turn — the flag is the record of that refusal (#2782) and
1340
+ * stays set even after the owner raises the ceiling and the session runs on.
1341
+ */
1329
1342
  fired: {
1330
1343
  turns?: boolean;
1331
1344
  budget?: boolean;
1332
1345
  stall?: boolean;
1346
+ time?: boolean;
1333
1347
  };
1334
1348
  } | null;
1335
1349
  }
@@ -1340,14 +1354,20 @@ export interface AgentSessionGuardrails {
1340
1354
  * `false` for a conclusive mechanical allowed/forbidden-paths check, `null` when the contract has
1341
1355
  * no fences or the verdict predates the check. This shape is also what MNE-8234 mirrors — keep
1342
1356
  * the field name and member shape stable.
1357
+ *
1358
+ * `refused` (#2782) is not a judge verdict: it records an enforce-mode ceiling refusal — the
1359
+ * request was turned away (429) and not counted, so `turn` is the turn it would have been and
1360
+ * the counter never reached it; `ceilings` names what was crossed and `fence_breach` is null.
1343
1361
  */
1344
1362
  export interface AgentSessionVerdictTrailEntry {
1345
1363
  request_id: string | null;
1346
1364
  turn: number;
1347
- verdict: "aligned" | "drifting" | "violation";
1365
+ verdict: "aligned" | "drifting" | "violation" | "refused";
1348
1366
  reason: string;
1349
1367
  fence_breach: boolean | null;
1350
1368
  at: string;
1369
+ /** Only on a `refused` entry: the ceilings the refusal named. */
1370
+ ceilings?: Array<"turns" | "budget" | "time">;
1351
1371
  }
1352
1372
  /**
1353
1373
  * The seal's lifecycle (MNE-8149 R2, MNE-8199 D9/R9, MNE-8234/mnemom-api#3541). `live`: a sealed
@@ -89,10 +89,35 @@ export interface LoopbackResult {
89
89
  * scoped tokens plus the client_id that was used (so the caller can persist it
90
90
  * for refresh). `openUrl` is injectable so tests can drive the flow without
91
91
  * spawning a real browser.
92
+ *
93
+ * Cross-device completion: when the AS advertises a device authorization
94
+ * endpoint, this also starts an RFC 8628 device authorization for the same
95
+ * client and prints its code with the verification URI. The login then
96
+ * finishes on whichever comes first: the loopback redirect (same machine) or an
97
+ * approval of the device code from any browser (e.g. the phone that opened the
98
+ * sign-up confirmation email), where the person types the code from this
99
+ * terminal. The code is never put in the authorize URL or any other link, so
100
+ * nothing that opens in a browser can approve this login without the person
101
+ * typing it. Tokens only ever come back to this process: the loopback code
102
+ * needs our PKCE verifier and the device grant needs our device_code. If the
103
+ * device authorization cannot be started, the login is exactly the loopback
104
+ * flow it always was.
92
105
  */
93
106
  export declare function loginWithLoopback(openUrl?: (url: string) => void, opts?: {
94
107
  inviteToken?: string;
108
+ sleep?: (ms: number) => Promise<void>;
95
109
  }): Promise<LoopbackResult>;
110
+ /** How long the loopback listener waits while a device code is live. */
111
+ export declare function crossDeviceWaitMs(expiresInSec: number): number;
112
+ /**
113
+ * Settle with whichever login path finishes first.
114
+ * - The loopback path settles the login either way, exactly as before: a
115
+ * denial, a state mismatch or the timeout in the browser ends it.
116
+ * - The device path ends it on success, or when the person explicitly denies
117
+ * the device code. Any other device failure (expiry, a transport error)
118
+ * only drops that path and the loopback wait continues.
119
+ */
120
+ export declare function firstLogin(viaLoopback: Promise<OAuthTokens>, viaDevice: Promise<OAuthTokens> | null): Promise<OAuthTokens>;
96
121
  export interface DeviceResult {
97
122
  tokens: OAuthTokens;
98
123
  clientId: string;
@@ -169,6 +194,11 @@ export declare function browserOpenerFor(platform: NodeJS.Platform, href: string
169
194
  * Returns whether a launch was attempted.
170
195
  */
171
196
  export declare function openBrowser(url: string): boolean;
197
+ /**
198
+ * setTimeout-based sleep that resolves early when `signal` aborts, so an
199
+ * abandoned device poll does not hold the process open after login finishes.
200
+ */
201
+ export declare function abortableSleep(ms: number, signal: AbortSignal): Promise<void>;
172
202
  /**
173
203
  * Await `promise` while emitting a heartbeat tick every `intervalMs`, so a long
174
204
  * silent wait (a browser sign-in round-trip) doesn't look hung. `onTick` is
package/dist/lib/oauth.js CHANGED
@@ -35,6 +35,12 @@ const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
35
35
  // Loopback login waits at most this long for the browser round-trip before
36
36
  // giving up and freeing the port.
37
37
  const LOOPBACK_TIMEOUT_MS = 5 * 60 * 1000;
38
+ // Browser login also starts a device authorization (RFC 8628) when the AS offers
39
+ // one, so the login can be approved from ANOTHER device: someone who signs up in
40
+ // the browser and confirms the email on their phone. While that is live the
41
+ // loopback listener waits as long as the device code is valid, capped here, so
42
+ // the port is never left open indefinitely.
43
+ const CROSS_DEVICE_MAX_WAIT_MS = 15 * 60 * 1000;
38
44
  // Cadence for the "…still waiting for you to finish signing in" heartbeat that
39
45
  // both interactive login poll loops emit, so a multi-minute browser sign-in
40
46
  // doesn't read as a hung CLI (matches the card-write retry tick in try-me).
@@ -208,46 +214,120 @@ function tokensFromResponse(data) {
208
214
  * scoped tokens plus the client_id that was used (so the caller can persist it
209
215
  * for refresh). `openUrl` is injectable so tests can drive the flow without
210
216
  * spawning a real browser.
217
+ *
218
+ * Cross-device completion: when the AS advertises a device authorization
219
+ * endpoint, this also starts an RFC 8628 device authorization for the same
220
+ * client and prints its code with the verification URI. The login then
221
+ * finishes on whichever comes first: the loopback redirect (same machine) or an
222
+ * approval of the device code from any browser (e.g. the phone that opened the
223
+ * sign-up confirmation email), where the person types the code from this
224
+ * terminal. The code is never put in the authorize URL or any other link, so
225
+ * nothing that opens in a browser can approve this login without the person
226
+ * typing it. Tokens only ever come back to this process: the loopback code
227
+ * needs our PKCE verifier and the device grant needs our device_code. If the
228
+ * device authorization cannot be started, the login is exactly the loopback
229
+ * flow it always was.
211
230
  */
212
231
  export async function loginWithLoopback(openUrl = openBrowser, opts = {}) {
213
232
  const meta = await discover();
214
233
  const pkce = generatePkce();
215
234
  const state = crypto.randomBytes(16).toString("hex");
216
- const { port, codePromise, close } = await startLoopbackServer(state);
235
+ const { port, codePromise, close, setTimeoutMs } = await startLoopbackServer(state);
217
236
  const redirectUri = `http://127.0.0.1:${port}/callback`;
218
- // Register a client bound to the exact loopback redirect URI. OAuth 2.1
219
- // requires exact redirect_uri matching, and the loopback port is ephemeral,
220
- // so we register per-login (cheap, public client, no secret).
221
- const clientId = await registerClient([redirectUri]);
222
- const authUrl = new URL(meta.authorization_endpoint);
223
- authUrl.searchParams.set("response_type", "code");
224
- authUrl.searchParams.set("client_id", clientId);
225
- authUrl.searchParams.set("redirect_uri", redirectUri);
226
- authUrl.searchParams.set("scope", negotiateScopes(meta));
227
- authUrl.searchParams.set("state", state);
228
- authUrl.searchParams.set("code_challenge", pkce.challenge);
229
- authUrl.searchParams.set("code_challenge_method", pkce.method);
230
- // A Mnemom Agent invitation (`mnemom agent --invite`) rides along so a NEW
231
- // invitee who signs up in this browser round-trip skips the confirmation
232
- // email: the AS ignores the param but bounces a signed-out browser to /login
233
- // with this exact URL as return_to, and sign-up forwards the token to the api.
234
- if (opts.inviteToken)
235
- authUrl.searchParams.set("invite_token", opts.inviteToken);
236
- console.log("Opening browser to authenticate...");
237
- console.log(`If the browser doesn't open, visit:\n ${authUrl.toString()}\n`);
238
- openUrl(authUrl.toString());
239
- console.log("Waiting for authentication...");
237
+ const stopPolling = new AbortController();
240
238
  try {
241
- // Heartbeat while we await the loopback redirect — otherwise the sign-in
242
- // round-trip is dead silent and reads as a hung CLI.
243
- const code = await withHeartbeat(codePromise, LOGIN_HEARTBEAT_MS, (s) => console.log(`…still waiting for you to finish signing in (${s}s)`));
244
- const tokens = await exchangeCode(meta, clientId, code, pkce.verifier, redirectUri);
239
+ // Register a client bound to the exact loopback redirect URI. OAuth 2.1
240
+ // requires exact redirect_uri matching, and the loopback port is ephemeral,
241
+ // so we register per-login (cheap, public client, no secret). The same
242
+ // client also runs the device grant, so either path refreshes the same way.
243
+ const clientId = await registerClient([redirectUri]);
244
+ const device = await startCrossDeviceAuthorization(meta, clientId);
245
+ const authUrl = new URL(meta.authorization_endpoint);
246
+ authUrl.searchParams.set("response_type", "code");
247
+ authUrl.searchParams.set("client_id", clientId);
248
+ authUrl.searchParams.set("redirect_uri", redirectUri);
249
+ authUrl.searchParams.set("scope", negotiateScopes(meta));
250
+ authUrl.searchParams.set("state", state);
251
+ authUrl.searchParams.set("code_challenge", pkce.challenge);
252
+ authUrl.searchParams.set("code_challenge_method", pkce.method);
253
+ // A Mnemom Agent invitation (`mnemom agent --invite`) rides along so a NEW
254
+ // invitee who signs up in this browser round-trip skips the confirmation
255
+ // email: the AS ignores the param but bounces a signed-out browser to /login
256
+ // with this exact URL as return_to, and sign-up forwards the token to the api.
257
+ if (opts.inviteToken)
258
+ authUrl.searchParams.set("invite_token", opts.inviteToken);
259
+ console.log("Opening browser to authenticate...");
260
+ console.log(`If the browser doesn't open, visit:\n ${authUrl.toString()}\n`);
261
+ if (device) {
262
+ setTimeoutMs(crossDeviceWaitMs(device.expires_in));
263
+ // The plain verification_uri, never verification_uri_complete: the person
264
+ // types the code there, so a link alone can never approve this login.
265
+ console.log(`Confirming on another device? Open ${device.verification_uri} and enter: ${device.user_code}\n`);
266
+ }
267
+ openUrl(authUrl.toString());
268
+ console.log("Waiting for authentication...");
269
+ const viaLoopback = codePromise.then((code) => exchangeCode(meta, clientId, code, pkce.verifier, redirectUri));
270
+ const viaDevice = device
271
+ ? pollDeviceToken(meta, clientId, device, opts.sleep ?? ((ms) => abortableSleep(ms, stopPolling.signal)),
272
+ // The loopback wait below already prints the heartbeat.
273
+ () => { }, stopPolling.signal)
274
+ : null;
275
+ // Heartbeat while we wait — otherwise the sign-in round-trip is dead
276
+ // silent and reads as a hung CLI.
277
+ const tokens = await withHeartbeat(firstLogin(viaLoopback, viaDevice), LOGIN_HEARTBEAT_MS, (s) => console.log(`…still waiting for you to finish signing in (${s}s)`));
245
278
  return { tokens, clientId };
246
279
  }
247
280
  finally {
281
+ stopPolling.abort();
248
282
  close();
249
283
  }
250
284
  }
285
+ /**
286
+ * Start the device authorization that lets a browser login be approved from
287
+ * another device. Best-effort: an AS without the endpoint, or any failure,
288
+ * returns null and the login carries on as loopback only.
289
+ */
290
+ async function startCrossDeviceAuthorization(meta, clientId) {
291
+ if (!meta.device_authorization_endpoint)
292
+ return null;
293
+ try {
294
+ const authz = await requestDeviceAuthorization(meta.device_authorization_endpoint, meta, clientId);
295
+ if (typeof authz.device_code !== "string" ||
296
+ typeof authz.user_code !== "string" ||
297
+ typeof authz.verification_uri !== "string" ||
298
+ typeof authz.expires_in !== "number") {
299
+ return null;
300
+ }
301
+ return authz;
302
+ }
303
+ catch {
304
+ return null;
305
+ }
306
+ }
307
+ /** How long the loopback listener waits while a device code is live. */
308
+ export function crossDeviceWaitMs(expiresInSec) {
309
+ const ms = Number.isFinite(expiresInSec) ? expiresInSec * 1000 : 0;
310
+ return Math.min(Math.max(ms, LOOPBACK_TIMEOUT_MS), CROSS_DEVICE_MAX_WAIT_MS);
311
+ }
312
+ /**
313
+ * Settle with whichever login path finishes first.
314
+ * - The loopback path settles the login either way, exactly as before: a
315
+ * denial, a state mismatch or the timeout in the browser ends it.
316
+ * - The device path ends it on success, or when the person explicitly denies
317
+ * the device code. Any other device failure (expiry, a transport error)
318
+ * only drops that path and the loopback wait continues.
319
+ */
320
+ export function firstLogin(viaLoopback, viaDevice) {
321
+ if (!viaDevice)
322
+ return viaLoopback;
323
+ return new Promise((resolve, reject) => {
324
+ viaLoopback.then(resolve, reject);
325
+ viaDevice.then(resolve, (err) => {
326
+ if (err instanceof Error && err.message === DEVICE_ACCESS_DENIED)
327
+ reject(err);
328
+ });
329
+ });
330
+ }
251
331
  /**
252
332
  * Start a loopback HTTP listener that captures the OAuth redirect
253
333
  * (GET /callback?code=...&state=...). Resolves with the authorization code once
@@ -293,13 +373,18 @@ function startLoopbackServer(expectedState) {
293
373
  return new Promise((resolve) => {
294
374
  server.listen(0, "127.0.0.1", () => {
295
375
  const port = server.address().port;
296
- const timeout = setTimeout(() => {
376
+ const giveUp = () => {
297
377
  rejectCode(new Error("Login timed out. Please try again."));
298
378
  server.close();
299
- }, LOOPBACK_TIMEOUT_MS);
379
+ };
380
+ let timeout = setTimeout(giveUp, LOOPBACK_TIMEOUT_MS);
300
381
  resolve({
301
382
  port,
302
383
  codePromise,
384
+ setTimeoutMs: (ms) => {
385
+ clearTimeout(timeout);
386
+ timeout = setTimeout(giveUp, ms);
387
+ },
303
388
  close: () => {
304
389
  clearTimeout(timeout);
305
390
  server.close();
@@ -358,18 +443,7 @@ export async function loginWithDevice(opts) {
358
443
  // clients"). Register a loopback placeholder — it satisfies validation and is
359
444
  // never used, since the device grant never redirects.
360
445
  const clientId = await registerClient(["http://127.0.0.1/callback"]);
361
- const authzRes = await oauthFetch("Device authorization at", meta.device_authorization_endpoint, {
362
- method: "POST",
363
- headers: {
364
- "Content-Type": "application/x-www-form-urlencoded",
365
- Accept: "application/json",
366
- },
367
- body: new URLSearchParams({ client_id: clientId, scope: negotiateScopes(meta) }),
368
- });
369
- if (!authzRes.ok) {
370
- throw new Error(`Device authorization failed: ${await oauthError(authzRes)}`);
371
- }
372
- const authz = (await authzRes.json());
446
+ const authz = await requestDeviceAuthorization(meta.device_authorization_endpoint, meta, clientId);
373
447
  display("");
374
448
  display("To authenticate, visit:");
375
449
  display(` ${authz.verification_uri}`);
@@ -385,7 +459,26 @@ export async function loginWithDevice(opts) {
385
459
  const tokens = await pollDeviceToken(meta, clientId, authz, sleep, display);
386
460
  return { tokens, clientId };
387
461
  }
388
- async function pollDeviceToken(meta, clientId, authz, sleep, display) {
462
+ /** POST the device authorization request (RFC 8628 §3.1) for `clientId`. */
463
+ async function requestDeviceAuthorization(endpoint, meta, clientId) {
464
+ const authzRes = await oauthFetch("Device authorization at", endpoint, {
465
+ method: "POST",
466
+ headers: {
467
+ "Content-Type": "application/x-www-form-urlencoded",
468
+ Accept: "application/json",
469
+ },
470
+ body: new URLSearchParams({ client_id: clientId, scope: negotiateScopes(meta) }),
471
+ });
472
+ if (!authzRes.ok) {
473
+ throw new Error(`Device authorization failed: ${await oauthError(authzRes)}`);
474
+ }
475
+ return (await authzRes.json());
476
+ }
477
+ /** The message a device-code denial rejects with (matched by firstLogin). */
478
+ const DEVICE_ACCESS_DENIED = "Authorization was denied.";
479
+ /** Thrown into an abandoned device poll once the other login path has won. */
480
+ const POLL_STOPPED = "Device polling stopped.";
481
+ async function pollDeviceToken(meta, clientId, authz, sleep, display, signal) {
389
482
  // RFC 8628 §3.5: default interval is 5s if the server omits it; on slow_down
390
483
  // we increase the interval by 5s and keep that as the new minimum.
391
484
  let intervalMs = (authz.interval ?? 5) * 1000;
@@ -399,6 +492,8 @@ async function pollDeviceToken(meta, clientId, authz, sleep, display) {
399
492
  throw new Error("Device authorization expired before approval. Please try again.");
400
493
  }
401
494
  await sleep(intervalMs);
495
+ if (signal?.aborted)
496
+ throw new Error(POLL_STOPPED);
402
497
  waitedMs += intervalMs;
403
498
  let res;
404
499
  try {
@@ -437,7 +532,7 @@ async function pollDeviceToken(meta, clientId, authz, sleep, display) {
437
532
  case "expired_token":
438
533
  throw new Error("Device authorization expired before approval. Please try again.");
439
534
  case "access_denied":
440
- throw new Error("Authorization was denied.");
535
+ throw new Error(DEVICE_ACCESS_DENIED);
441
536
  default:
442
537
  throw new Error(`Device authorization failed: ${body.error ?? `HTTP ${res.status}`}` +
443
538
  (body.error_description ? ` — ${body.error_description}` : ""));
@@ -623,6 +718,23 @@ export function openBrowser(url) {
623
718
  function defaultSleep(ms) {
624
719
  return new Promise((resolve) => setTimeout(resolve, ms));
625
720
  }
721
+ /**
722
+ * setTimeout-based sleep that resolves early when `signal` aborts, so an
723
+ * abandoned device poll does not hold the process open after login finishes.
724
+ */
725
+ export function abortableSleep(ms, signal) {
726
+ return new Promise((resolve) => {
727
+ if (signal.aborted)
728
+ return resolve();
729
+ const done = () => {
730
+ clearTimeout(timer);
731
+ signal.removeEventListener("abort", done);
732
+ resolve();
733
+ };
734
+ const timer = setTimeout(done, ms);
735
+ signal.addEventListener("abort", done, { once: true });
736
+ });
737
+ }
626
738
  /**
627
739
  * Await `promise` while emitting a heartbeat tick every `intervalMs`, so a long
628
740
  * silent wait (a browser sign-in round-trip) doesn't look hung. `onTick` is
@@ -0,0 +1,15 @@
1
+ /**
2
+ * lib/slack-fs.ts — the only way the CLI writes a file under ~/.mnemom/slack.
3
+ *
4
+ * A sandboxed agent session is allowed to write a few of these files itself (its own
5
+ * offsets/<session>.off). If it swaps one for a symlink, a plain writeFileSync from the
6
+ * router or channel server — which run outside the sandbox, as the operator — follows
7
+ * the link and truncates whatever it points at. So every write here opens the path with
8
+ * O_NOFOLLOW (a symlink fails with ELOOP instead of being followed), and refuses anything
9
+ * that is not a regular file with a single link (a hard link to another file is the same
10
+ * trick) before a single byte is written or truncated.
11
+ */
12
+ /** Replace `path`'s contents with `data`; never follows a symlink at `path`. */
13
+ export declare function writeFileNoFollow(path: string, data: string, mode?: number): void;
14
+ /** Append `data` to `path`; never follows a symlink at `path`. */
15
+ export declare function appendFileNoFollow(path: string, data: string, mode?: number): void;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * lib/slack-fs.ts — the only way the CLI writes a file under ~/.mnemom/slack.
3
+ *
4
+ * A sandboxed agent session is allowed to write a few of these files itself (its own
5
+ * offsets/<session>.off). If it swaps one for a symlink, a plain writeFileSync from the
6
+ * router or channel server — which run outside the sandbox, as the operator — follows
7
+ * the link and truncates whatever it points at. So every write here opens the path with
8
+ * O_NOFOLLOW (a symlink fails with ELOOP instead of being followed), and refuses anything
9
+ * that is not a regular file with a single link (a hard link to another file is the same
10
+ * trick) before a single byte is written or truncated.
11
+ */
12
+ import { closeSync, constants, fstatSync, ftruncateSync, openSync, writeSync } from "node:fs";
13
+ // O_NOFOLLOW is undefined on Windows, where creating symlinks needs elevation anyway.
14
+ const NOFOLLOW = constants.O_NOFOLLOW ?? 0;
15
+ function writeNoFollow(path, data, flags, truncate, mode) {
16
+ const fd = openSync(path, flags | NOFOLLOW, mode);
17
+ try {
18
+ const st = fstatSync(fd);
19
+ if (!st.isFile() || st.nlink !== 1)
20
+ throw new Error(`refusing to write ${path}: not a regular file with a single link`);
21
+ if (truncate)
22
+ ftruncateSync(fd, 0);
23
+ writeSync(fd, data);
24
+ }
25
+ finally {
26
+ closeSync(fd);
27
+ }
28
+ }
29
+ /** Replace `path`'s contents with `data`; never follows a symlink at `path`. */
30
+ export function writeFileNoFollow(path, data, mode = 0o600) {
31
+ writeNoFollow(path, data, constants.O_WRONLY | constants.O_CREAT, true, mode);
32
+ }
33
+ /** Append `data` to `path`; never follows a symlink at `path`. */
34
+ export function appendFileNoFollow(path, data, mode = 0o600) {
35
+ writeNoFollow(path, data, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND, false, mode);
36
+ }
@@ -8,12 +8,13 @@
8
8
  * `--dangerously-load-development-channels`, which shows a confirmation dialog;
9
9
  * orgs on Team/Enterprise must also enable `channelsEnabled` in managed settings.
10
10
  */
11
- import { mkdirSync, writeFileSync } from "node:fs";
11
+ import { mkdirSync } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import { SESSION_NAME_ENV } from "./agent.js";
14
14
  import { ensureRouter } from "./slack-router.js";
15
15
  import { slackDir } from "./slack-state.js";
16
16
  import { normSession } from "./slack-route.js";
17
+ import { writeFileNoFollow } from "./slack-fs.js";
17
18
  export const SLACK_CHANNEL_SERVER = "mnemom-slack";
18
19
  /** The MCP config document for `session` (pure; unit-tested). */
19
20
  export function slackMcpConfig(session, cli) {
@@ -31,7 +32,7 @@ export function slackMcpConfig(session, cli) {
31
32
  export function slackChannelArgs(session, cli = [process.execPath, process.argv[1] ?? "mnemom"]) {
32
33
  mkdirSync(slackDir(), { recursive: true, mode: 0o700 });
33
34
  const path = join(slackDir(), `mcp-${normSession(session)}.json`);
34
- writeFileSync(path, JSON.stringify(slackMcpConfig(session, cli), null, 2), { mode: 0o600 });
35
+ writeFileNoFollow(path, JSON.stringify(slackMcpConfig(session, cli), null, 2));
35
36
  ensureRouter(cli);
36
37
  return [
37
38
  "--mcp-config",
@@ -12,11 +12,12 @@
12
12
  * in-memory user-name cache.
13
13
  */
14
14
  import { spawn } from "node:child_process";
15
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
15
+ import { existsSync, readFileSync, mkdirSync } from "node:fs";
16
16
  import { join } from "node:path";
17
17
  import { fetchThread, formatThread, messageBody, slackCall, } from "./slack.js";
18
18
  import { buildThreadIndex, normSession, routeHits, stripAddressing, } from "./slack-route.js";
19
19
  import { appendInbox, inboxSize, listAliases, liveSessions, PENDING_TTL_MS, readAliases, readPosts, readThreadRecords, recordPending, recordRoute, settlePending, slackDir, } from "./slack-state.js";
20
+ import { writeFileNoFollow } from "./slack-fs.js";
20
21
  /** How often the router settles held messages (delivered / expired). */
21
22
  export const PENDING_SWEEP_MS = 30_000;
22
23
  const ttlText = () => `${Math.round(PENDING_TTL_MS / 60_000)} min`;
@@ -92,7 +93,7 @@ export async function runRouter(opts) {
92
93
  if (other && other !== process.pid)
93
94
  throw new Error(`a router is already running (pid ${other}).`);
94
95
  mkdirSync(slackDir(), { recursive: true, mode: 0o700 });
95
- writeFileSync(routerPidFile(), String(process.pid), { mode: 0o600 });
96
+ writeFileNoFollow(routerPidFile(), String(process.pid));
96
97
  log(`aliases ${aliasesOn ? "on" : "off"}, sticky threads ${stickyOn ? "on" : "off"}`);
97
98
  if (aliasesOn) {
98
99
  const sweep = setInterval(() => {
@@ -15,9 +15,10 @@
15
15
  * each session's listener is the only reader of its own inbox. No secret is
16
16
  * stored here.
17
17
  */
18
- import { appendFileSync, closeSync, existsSync, fstatSync, openSync, readSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
18
+ import { closeSync, existsSync, fstatSync, openSync, readSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, } from "node:fs";
19
19
  import { join } from "node:path";
20
20
  import { MNEMOM_DIR } from "./config.js";
21
+ import { appendFileNoFollow, writeFileNoFollow } from "./slack-fs.js";
21
22
  import { normHandle, normSession, reresolveRoutes, } from "./slack-route.js";
22
23
  export function slackDir() {
23
24
  return join(MNEMOM_DIR, "slack");
@@ -59,14 +60,14 @@ function readJsonl(path) {
59
60
  }
60
61
  export function recordPost(p) {
61
62
  ensure(slackDir());
62
- appendFileSync(postsFile(), JSON.stringify(p) + "\n", { mode: 0o600 });
63
+ appendFileNoFollow(postsFile(), JSON.stringify(p) + "\n");
63
64
  }
64
65
  export function readPosts() {
65
66
  return readJsonl(postsFile()).slice(-MAX_POSTS);
66
67
  }
67
68
  export function recordRoute(r) {
68
69
  ensure(slackDir());
69
- appendFileSync(routesFile(), JSON.stringify(r) + "\n", { mode: 0o600 });
70
+ appendFileNoFollow(routesFile(), JSON.stringify(r) + "\n");
70
71
  }
71
72
  /** Sticky routes still within their TTL at `now`, oldest first (capped like posts). */
72
73
  export function readRoutes(now = Date.now()) {
@@ -112,9 +113,7 @@ export function freshInbox(messages, now = Date.now()) {
112
113
  const fileFor = (session) => `${normSession(session)}`;
113
114
  export function appendInbox(session, msg) {
114
115
  ensure(inboxDir());
115
- appendFileSync(join(inboxDir(), `${fileFor(session)}.jsonl`), JSON.stringify(msg) + "\n", {
116
- mode: 0o600,
117
- });
116
+ appendFileNoFollow(join(inboxDir(), `${fileFor(session)}.jsonl`), JSON.stringify(msg) + "\n");
118
117
  }
119
118
  export function inboxPath(session) {
120
119
  return join(inboxDir(), `${fileFor(session)}.jsonl`);
@@ -142,7 +141,7 @@ export function readOffset(session) {
142
141
  /** `session` has read its inbox up to byte `offset`. */
143
142
  export function writeOffset(session, offset) {
144
143
  ensure(offsetDir());
145
- writeFileSync(offsetFile(session), String(offset), { mode: 0o600 });
144
+ writeFileNoFollow(offsetFile(session), String(offset));
146
145
  }
147
146
  /** True when `session` has saved an offset at all (it has read its inbox before). */
148
147
  export function hasOffset(session) {
@@ -150,7 +149,7 @@ export function hasOffset(session) {
150
149
  }
151
150
  export function recordPending(p) {
152
151
  ensure(slackDir());
153
- appendFileSync(pendingFile(), JSON.stringify(p) + "\n", { mode: 0o600 });
152
+ appendFileNoFollow(pendingFile(), JSON.stringify(p) + "\n");
154
153
  }
155
154
  export function readPending() {
156
155
  return readJsonl(pendingFile());
@@ -198,9 +197,7 @@ export function settlePending(now = Date.now()) {
198
197
  else
199
198
  held.push(p);
200
199
  }
201
- writeFileSync(pendingFile(), held.map((p) => JSON.stringify(p) + "\n").join(""), {
202
- mode: 0o600,
203
- });
200
+ writeFileNoFollow(pendingFile(), held.map((p) => JSON.stringify(p) + "\n").join(""));
204
201
  return { delivered, expired, held };
205
202
  }
206
203
  /** Messages appended after byte `offset`, and the new offset. */
@@ -246,7 +243,7 @@ export function readInboxFrom(session, offset) {
246
243
  }
247
244
  export function registerLive(session, pid = process.pid) {
248
245
  ensure(liveDir());
249
- writeFileSync(join(liveDir(), `${fileFor(session)}.json`), JSON.stringify({ session, pid, since: new Date().toISOString() }), { mode: 0o600 });
246
+ writeFileNoFollow(join(liveDir(), `${fileFor(session)}.json`), JSON.stringify({ session, pid, since: new Date().toISOString() }));
250
247
  }
251
248
  export function unregisterLive(session) {
252
249
  rmSync(join(liveDir(), `${fileFor(session)}.json`), { force: true });
@@ -280,7 +277,7 @@ export function setAlias(handle, session) {
280
277
  session: s,
281
278
  since: prev && prev.session === s ? prev.since : new Date().toISOString(),
282
279
  };
283
- writeFileSync(path, JSON.stringify(rec), { mode: 0o600 });
280
+ writeFileNoFollow(path, JSON.stringify(rec));
284
281
  return rec;
285
282
  }
286
283
  /** Remove `handle`; true when something was removed. */
@@ -70,7 +70,13 @@ export function isPromptEntry(entry) {
70
70
  * The origin Claude Code recorded for a prompt entry, or null when it recorded none we know
71
71
  * (then the entry is not stated at all, and the gateway reads that text as before).
72
72
  * Precedence: summary / transcript-only → meta; channel; scheduled; peer; task; isMeta → meta;
73
- * a human origin or a typed / queued prompt → human.
73
+ * a human origin or a typed / queued prompt → human; an SDK prompt → human.
74
+ *
75
+ * SDK prompts: under the Agent SDK (and `claude -p`) Claude Code records the caller's own
76
+ * prompts as promptSource / turnOrigin "sdk". The caller is the operator driving the session,
77
+ * so its prompt is the user's turn. Every non-user kind (wakeup, task notification, peer,
78
+ * hook or skill text, compaction summary) is matched above it first, so it keeps its own
79
+ * origin in SDK mode too; `source=sdk` still tells the gateway which surface the turn came from.
74
80
  */
75
81
  export function entryOrigin(entry) {
76
82
  if (!entry) return null;
@@ -85,6 +91,7 @@ export function entryOrigin(entry) {
85
91
  if (entry.isMeta === true || turn === "system") return "meta";
86
92
  if (turn === "human" || kind === "human") return "human";
87
93
  if (entry.promptSource === "typed" || entry.promptSource === "queued") return "human";
94
+ if (entry.promptSource === "sdk" || turn === "sdk") return "human";
88
95
  return null;
89
96
  }
90
97
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.31-next.0",
3
+ "version": "0.17.31",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,10 +26,10 @@
26
26
  "devDependencies": {
27
27
  "@types/js-yaml": "^4.0.9",
28
28
  "@types/node": "^20.10.0",
29
- "@vitest/coverage-v8": "^4.1.11",
29
+ "@vitest/coverage-v8": "^4.1.12",
30
30
  "tsx": "^4.23.1",
31
31
  "typescript": "^6.0.3",
32
- "vitest": "^4.1.11"
32
+ "vitest": "^4.1.12"
33
33
  },
34
34
  "files": [
35
35
  "dist",
@@ -71,7 +71,7 @@
71
71
  "node": ">=18.0.0"
72
72
  },
73
73
  "overrides": {
74
- "@vitest/mocker": "^4.1.11",
74
+ "@vitest/mocker": "^4.1.12",
75
75
  "nanoid": "^3.3.18"
76
76
  }
77
77
  }
@@ -26,8 +26,9 @@ Mnemom gateway:
26
26
 
27
27
  This plugin is the visible part: the band above the prompt, the status pane, the ◆ sign on the
28
28
  spinner and the turn line, Mnemom trailers in commit attribution, a governance section in the
29
- system prompt, and a guard that denies Bash commands which would point `ANTHROPIC_BASE_URL`,
30
- `ANTHROPIC_AUTH_TOKEN` or `ANTHROPIC_CUSTOM_HEADERS` away from the gateway.
29
+ system prompt, and a guard that denies Bash commands which plainly set or unset
30
+ `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN` or `ANTHROPIC_CUSTOM_HEADERS` (a text match on the
31
+ command, not a sandbox).
31
32
 
32
33
  A session that was NOT launched by `mnemom agent` shows an amber "not governed" band. Nothing is
33
34
  recorded for it. Tell the person to launch with `mnemom agent` (install: `npm i -g @mnemom/mnemom`,
@@ -79,9 +80,10 @@ server at `https://docs.mnemom.ai/mcp` answers questions from the published docs
79
80
 
80
81
  - **"Why is there a ◆ on the spinner?"** The session is governed; every model call is being
81
82
  checkpointed through the Mnemom gateway. `/mnemom status` shows by whom.
82
- - **"Can I turn the gateway off for one command?"** Not inside this session: the plugin denies
83
- it, by design. Use another terminal, or `mnemom agent --no-plugin` for a session without the
84
- guard (the routing itself still comes from the launcher).
83
+ - **"Can I turn the gateway off for one command?"** Not from this session: the plugin denies
84
+ commands that set or unset those variables, by design, and you must not work around it. Use
85
+ another terminal, or `mnemom agent --no-plugin` for a session without the guard (the routing
86
+ itself still comes from the launcher).
85
87
  - **"Is my score good?"** Grades A and up are strong; NR means not enough history yet, not a
86
88
  bad score. Point them at the public page for the breakdown.
87
89
  - **"Who sees what?"** The public rating and its chain attestation are public. Alignment and