@mnemom/mnemom 0.17.32-next.1 → 0.17.32-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.
@@ -367,6 +367,15 @@ export declare function prepareStatuslineFeed(o: {
367
367
  }): {
368
368
  feedFile: string;
369
369
  } | null;
370
+ /**
371
+ * Whether the launch reads or writes Record, and so needs the served agent's org: the conduct
372
+ * receipt, commit reporting and the fed status line each key on it (lib/record-org).
373
+ */
374
+ export declare function recordOrgWanted(o: {
375
+ receipt: boolean;
376
+ commitReport: boolean;
377
+ fedStatusline: boolean;
378
+ }): boolean;
370
379
  /**
371
380
  * Whether this launch feeds the status line measured model cost and μ (W1.4): prerelease
372
381
  * builds only (stable stays "mnemom", decision 14), and only when the turn proxy runs, since
@@ -31,13 +31,15 @@ import { randomUUID } from "node:crypto";
31
31
  import { tmpdir, homedir, hostname } from "node:os";
32
32
  import { basename, join, dirname } from "node:path";
33
33
  import { fileURLToPath } from "node:url";
34
- import { FEED_ENV, statuslineLaunch } from "../lib/statusline.js";
34
+ import { FEED_ENV, launchStatusLine, statuslineLaunch } from "../lib/statusline.js";
35
35
  import { startFeed } from "../lib/statusline-feed.js";
36
36
  import { isPrerelease } from "../lib/update/semver.js";
37
37
  import { attributionSettings, COMMIT_REPORT_ENV, commitReportEnabled, realGit, startCommitReporter, } from "../lib/commit-report.js";
38
38
  import { ALLOW_AUTOCOMPACT_OFF_ENV, dropAutocompactOffEnv, hasOwnSettingsFlag, mergedSettingsArgs, planAutocompactGuard, } from "../lib/autocompact-guard.js";
39
39
  import { getApiUrl, getGatewayUrl, MNEMOM_DIR } from "../lib/config.js";
40
40
  import { createApiKey } from "../lib/api.js";
41
+ import { recordOrgFor } from "../lib/record-org.js";
42
+ import { defaultSessionKeyDeps, mintSessionGatewayKey, revokeSessionGatewayKey, } from "../lib/session-key.js";
41
43
  import { MNEMOM_KEY_STORE_FIELD, resolveAgentAuth, sayRetiredConsoleNotice, readMnemomKey, resolveSubscriptionMnemomKey, subscriptionChildEnv, subscriptionShape, } from "../lib/subscription-login.js";
42
44
  import { LEDGER_FILE, appendSessionRecord, findResumeCandidate, readSessionRecords, resolveRetentionDays, resumedContextProfile, toLedgerContract, } from "../lib/agent-ledger.js";
43
45
  import { isLoggedIn } from "../lib/auth.js";
@@ -50,7 +52,7 @@ import { askInput, askSelect, askYesNo, isInteractive } from "../lib/prompt.js";
50
52
  import { authConfigured, chooseAuth, detectClaudeLogin, readClaudeLoginState, } from "../lib/login-detect.js";
51
53
  import { goalAlignmentLine, resolveSessionSetup } from "./agent-session.js";
52
54
  import { defaultAgentSlug, ensureClaimedServedAgent, ensureSessionAndBalance, isQuietPreambleExit, readComposedProtection, resolveDefaultSlug, resolvePinnedAgent, resolvePreflightedAccount, } from "./agent-setup.js";
53
- import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildHandoffMcpConfigJson, buildSessionConfig, buildSessionConfigB64, assertCompactAtCompatible, assertOffProfileCompatible, compactAtWindowWarning, describeCompactAt, launchWindow, resolveCompactAt, applyGatewayHintHeaders, resolveCompactionIntercept, resolveContextHandoff, resolveContextProfile, resolveHandoffGatewayHost, resolveUsageSavings, HANDOFF_RC_KEY_ENV_NAME, HANDOFF_RC_MNEMOM_KEY_ENV_NAME, malformedAgentIdMessage, parseAgentRef, isProdGateway, launchPreferenceExplicit, launchPreflighted, remoteControlAllowed, applyToolSearchDefault, DISABLE_1M_ENV, readCcSettings, passthroughModel, resolveWindowModel, resolveAnthropicDoor, resolveLaunchModel, resolveConversationId, resolveGatewayHost, singleCellError, resolveLaunchShape, resolveRemoteControlShape, liftPassthroughName, passthroughName, sanitizeAgentName, resumeCommand, sessionDisplayName, SESSION_NAME_ENV, SESSION_CONFIG_HEADER, } from "../lib/agent.js";
55
+ import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildHandoffMcpConfigJson, buildSessionConfig, buildSessionConfigB64, assertCompactAtCompatible, assertOffProfileCompatible, compactAtWindowWarning, describeCompactAt, launchWindow, resolveCompactAt, applyGatewayHintHeaders, resolveCompactionIntercept, resolveContextHandoff, resolveContextProfile, resolveHandoffGatewayHost, resolveUsageSavings, HANDOFF_RC_KEY_ENV_NAME, HANDOFF_RC_MNEMOM_KEY_ENV_NAME, malformedAgentIdMessage, parseAgentRef, isProdGateway, launchPreferenceExplicit, launchPreflighted, remoteControlAllowed, applyToolSearchDefault, DISABLE_1M_ENV, readCcSettings, passthroughModel, resolveWindowModel, resolveAnthropicDoor, resolveLaunchModel, resolveConversationId, resolveGatewayHost, singleCellError, resolveLaunchShape, resolveRemoteControlShape, liftPassthroughName, passthroughName, sanitizeAgentName, resumeCommand, sessionDisplayName, SESSION_NAME_ENV, SESSION_CONFIG_HEADER, stripOwnerCredentialEnv, } from "../lib/agent.js";
54
56
  import { LAUNCHER_STORE_PATH, mergeStore } from "../lib/launcher-store.js";
55
57
  import { slackChannelArgs } from "../lib/slack-launch.js";
56
58
  import { appendPluginDirsEnv, applyPluginEnv, pluginEnabled, pluginPlanLine, resolvePluginDir, } from "../lib/claude-plugin.js";
@@ -588,6 +590,33 @@ export async function agentCommand(scenarioArg, options = {}) {
588
590
  // hosts many sessions in one process, so it records none and injects nothing.
589
591
  const cc = shape === "server" ? { id: null, inject: false } : pickCcSessionId(options.passthrough ?? []);
590
592
  const ccSessionId = cc.inject && cc.id ? cc.id : undefined;
593
+ // ── MNE-8245: the LAUNCHED session must never hold the owner's own Mnemom
594
+ // key ── whenever `mnemomKey` above is set it is the owner's own credential:
595
+ // a subscription launch's identity anchor, or (since #2291's safe core) the
596
+ // stored/pinning key an api-key launch also sends. It is used ONLY for the
597
+ // claim call already made, and for nothing after this point: replace it here with a session-scoped, agent-bound,
598
+ // 24h `['gateway']`-only key minted fresh for THIS launch (MNE-8244's
599
+ // session-keys route), via the CLI PROCESS's own ambient auth — never
600
+ // anything read back out of the launched session. Minting is itself
601
+ // owner-gated server-side (a session key can never mint a broader one),
602
+ // so this closes the loop even if a future bug reintroduced a leak.
603
+ //
604
+ // FAILS CLOSED: `mintSessionGatewayKey` throws on ANY problem (network,
605
+ // non-2xx, malformed body, or scopes/kind not exactly a session-only
606
+ // `['gateway']` key); that throw is NOT caught here, so it propagates to
607
+ // index.ts's top-level handler, which prints the message and exits
608
+ // non-zero before anything is spawned. There is no fallback path back to
609
+ // `mnemomKey`'s original (owner) value.
610
+ let sessionKeyId;
611
+ if (mnemomKey) {
612
+ const minted = await mintSessionGatewayKey(defaultSessionKeyDeps(say), {
613
+ agentId: servedAgentId,
614
+ orgId: account.orgId,
615
+ sessionId: cc.id ?? undefined,
616
+ });
617
+ mnemomKey = minted.key;
618
+ sessionKeyId = minted.keyId;
619
+ }
591
620
  // ── 1M context window by default ── the model this launch runs on gets `[1m]` when it has no
592
621
  // window suffix and supports 1M (resolveWindowModel); an explicit choice — the
593
622
  // CLAUDE_CODE_DISABLE_1M_CONTEXT opt-out, a passthrough `-- --model`, a suffixed id — is kept.
@@ -706,6 +735,16 @@ export async function agentCommand(scenarioArg, options = {}) {
706
735
  }
707
736
  }
708
737
  if (shape === "terminal") {
738
+ // Record keys the lineage under the served agent's org, not the account org (lib/record-org).
739
+ const recordOrgId = recordOrgWanted({
740
+ receipt: experimental.includes("record"),
741
+ commitReport: cli.name === "claude" && commitReportEnabled(process.env[COMMIT_REPORT_ENV], CLI_VERSION),
742
+ fedStatusline: cli.name === "claude" && fedStatuslineWanted(CLI_VERSION, Boolean(goalMode), process.env),
743
+ }) && account.orgId
744
+ ? await recordOrgFor(servedAgentId, account.orgId, {
745
+ onFallback: (reason) => say(fmt.warn(`mnemom agent: record: could not read ${servedAgentId}'s org (${reason}); using ${account.orgId}, which may hold none of this session's record.`)),
746
+ })
747
+ : account.orgId;
709
748
  launchTerminal({
710
749
  cli,
711
750
  door,
@@ -723,6 +762,7 @@ export async function agentCommand(scenarioArg, options = {}) {
723
762
  yolo,
724
763
  contextHint,
725
764
  ccSessionId,
765
+ sessionKeyId,
726
766
  handoffMcpConfigPath,
727
767
  resumeHint: (id) => resumeHintLine({
728
768
  scenario,
@@ -735,9 +775,9 @@ export async function agentCommand(scenarioArg, options = {}) {
735
775
  knownCcSessionId: cc.id ?? continuedCcSessionId,
736
776
  plugin,
737
777
  receipt: experimental.includes("record")
738
- ? { orgId: account.orgId, agentId: servedAgentId }
778
+ ? { orgId: recordOrgId, agentId: servedAgentId }
739
779
  : undefined,
740
- orgId: account.orgId,
780
+ orgId: recordOrgId,
741
781
  commitReport: cli.name === "claude" &&
742
782
  Boolean(account.orgId) &&
743
783
  commitReportEnabled(process.env[COMMIT_REPORT_ENV], CLI_VERSION),
@@ -762,6 +802,7 @@ export async function agentCommand(scenarioArg, options = {}) {
762
802
  options: launchOptions,
763
803
  yolo,
764
804
  ccSessionId,
805
+ sessionKeyId,
765
806
  });
766
807
  }
767
808
  // ───────────────────────────────────────────────────────────────────────────
@@ -1260,6 +1301,12 @@ function launchTerminal(ctx) {
1260
1301
  else {
1261
1302
  childEnv = subscriptionChildEnv(process.env, ctx.door, headers);
1262
1303
  }
1304
+ // MNE-8245 defense-in-depth: the owner's own Mnemom credential must never ride
1305
+ // through to the session just because it happened to sit in the CLI's OWN
1306
+ // process env (a shell MNEMOM_API_KEY / MNEMOM_TOKEN export, or a leftover
1307
+ // MNEMOM_AGENT_MNEMOM_KEY) — the session's only Mnemom credential is the
1308
+ // minted key already spliced into `headers` above (ctx.mnemomKey).
1309
+ stripOwnerCredentialEnv(childEnv);
1263
1310
  // Both auth arms point ANTHROPIC_BASE_URL at the gateway door, which makes Claude Code turn
1264
1311
  // tool search off and inline every tool schema on every request — unless told otherwise.
1265
1312
  applyToolSearchDefault(childEnv);
@@ -1301,7 +1348,8 @@ function launchTerminal(ctx) {
1301
1348
  const ownSettings = hasOwnSettingsFlag(passthrough);
1302
1349
  const guard = applyAutocompactGuard(childEnv, ctx.options, !ownSettings);
1303
1350
  const extra = launchSettingsExtra({
1304
- statusline: sl?.settings ?? null,
1351
+ // Fed: a timed redraw, so totals that land after the last turn still reach the screen.
1352
+ statusline: launchStatusLine(sl?.settings ?? null, feed !== null),
1305
1353
  commitReport: Boolean(ctx.commitReport),
1306
1354
  ownSettings,
1307
1355
  conversationId: ctx.conversationId,
@@ -1332,24 +1380,30 @@ function launchTerminal(ctx) {
1332
1380
  process.exit(1);
1333
1381
  });
1334
1382
  child.on("exit", (code, signal) => {
1335
- if (signal)
1336
- process.kill(process.pid, signal);
1337
- else
1338
- void (async () => {
1339
- // The conduct receipt and the last commit sweep run side by side, each under its own
1340
- // hard deadline inside W1.3's exit budget; neither changes the exit code.
1341
- const swept = commits?.finish().catch(() => undefined);
1342
- if (ctx.receipt)
1343
- await sayConductReceipt({ ...ctx.receipt, statePath: recordStatePath });
1344
- await swept;
1345
- // Claude Code's own last line is "Resume this session with: claude --resume <id>",
1346
- // which reopens it OUTSIDE Mnemom (ungoverned, unbilled). Ours goes after it, last.
1347
- if (code === 0 && ctx.resumeHint && wantsResumeHint(ctx.cli, args)) {
1348
- const id = ctx.knownCcSessionId ?? newestCcSessionIn(process.cwd(), process.env);
1349
- say(ctx.resumeHint(id));
1350
- }
1351
- process.exit(code ?? 0);
1352
- })();
1383
+ void (async () => {
1384
+ // MNE-8245: best-effort revoke of the minted session-scoped key — a failure
1385
+ // here is non-fatal (the key is 24h-TTL'd regardless); never blocks exit.
1386
+ if (ctx.sessionKeyId) {
1387
+ await revokeSessionGatewayKey(defaultSessionKeyDeps(say), ctx.sessionKeyId);
1388
+ }
1389
+ if (signal) {
1390
+ process.kill(process.pid, signal);
1391
+ return;
1392
+ }
1393
+ // The conduct receipt and the last commit sweep run side by side, each under its own
1394
+ // hard deadline inside W1.3's exit budget; neither changes the exit code.
1395
+ const swept = commits?.finish().catch(() => undefined);
1396
+ if (ctx.receipt)
1397
+ await sayConductReceipt({ ...ctx.receipt, statePath: recordStatePath });
1398
+ await swept;
1399
+ // Claude Code's own last line is "Resume this session with: claude --resume <id>",
1400
+ // which reopens it OUTSIDE Mnemom (ungoverned, unbilled). Ours goes after it, last.
1401
+ if (code === 0 && ctx.resumeHint && wantsResumeHint(ctx.cli, args)) {
1402
+ const id = ctx.knownCcSessionId ?? newestCcSessionIn(process.cwd(), process.env);
1403
+ say(ctx.resumeHint(id));
1404
+ }
1405
+ process.exit(code ?? 0);
1406
+ })();
1353
1407
  });
1354
1408
  };
1355
1409
  // Implicit goal mode: put the local base-URL proxy in front of the door so each main request
@@ -1428,6 +1482,13 @@ export function prepareStatuslineFeed(o) {
1428
1482
  o.childEnv[FEED_ENV] = feed.feedFile;
1429
1483
  return feed;
1430
1484
  }
1485
+ /**
1486
+ * Whether the launch reads or writes Record, and so needs the served agent's org: the conduct
1487
+ * receipt, commit reporting and the fed status line each key on it (lib/record-org).
1488
+ */
1489
+ export function recordOrgWanted(o) {
1490
+ return o.receipt || o.commitReport || o.fedStatusline;
1491
+ }
1431
1492
  /**
1432
1493
  * Whether this launch feeds the status line measured model cost and μ (W1.4): prerelease
1433
1494
  * builds only (stable stays "mnemom", decision 14), and only when the turn proxy runs, since
@@ -1485,16 +1546,19 @@ export function startBaseUrlProxy(door, done, opts = {}) {
1485
1546
  // The receipt's record state (lineage key, record URL: non-secret) lives beside the log and is
1486
1547
  // removed with it on exit, after the receipt has read it.
1487
1548
  const recordStatePath = opts.recordState ? join(logDir, "record.json") : undefined;
1549
+ const proxyEnv = {
1550
+ ...process.env,
1551
+ MNEMOM_AGENT_BU_DOOR: door,
1552
+ MNEMOM_AGENT_BU_CWD: process.cwd(),
1553
+ MNEMOM_AGENT_BU_LOG: process.env.MNEMOM_AGENT_TURN_PROXY_LOG || join(logDir, "proxy.log"),
1554
+ MNEMOM_AGENT_BU_PARENT_PID: String(process.pid),
1555
+ ...(opts.turnHeader === false ? { MNEMOM_AGENT_TURN_HEADER: "0" } : {}),
1556
+ ...(recordStatePath ? { MNEMOM_AGENT_BU_RECORD_STATE: recordStatePath } : {}),
1557
+ };
1558
+ // MNE-8245 defense-in-depth: the proxy reads none of the owner's own Mnemom credential vars.
1559
+ stripOwnerCredentialEnv(proxyEnv);
1488
1560
  const proxy = spawn(opts.node ?? process.execPath, [script], {
1489
- env: {
1490
- ...process.env,
1491
- MNEMOM_AGENT_BU_DOOR: door,
1492
- MNEMOM_AGENT_BU_CWD: process.cwd(),
1493
- MNEMOM_AGENT_BU_LOG: process.env.MNEMOM_AGENT_TURN_PROXY_LOG || join(logDir, "proxy.log"),
1494
- MNEMOM_AGENT_BU_PARENT_PID: String(process.pid),
1495
- ...(opts.turnHeader === false ? { MNEMOM_AGENT_TURN_HEADER: "0" } : {}),
1496
- ...(recordStatePath ? { MNEMOM_AGENT_BU_RECORD_STATE: recordStatePath } : {}),
1497
- },
1561
+ env: proxyEnv,
1498
1562
  stdio: ["ignore", "pipe", "ignore"],
1499
1563
  });
1500
1564
  process.on("exit", () => {
@@ -1566,27 +1630,28 @@ function launchRemoteControl(ctx) {
1566
1630
  const rcDir = mkdtempSync(join(rcStateRoot(), "mnemom-code-rc."));
1567
1631
  writeFileSync(join(rcDir, "owner.pid"), `${process.pid}\n`, { mode: 0o600 }); // rc-state.mjs sweep
1568
1632
  const proxyLog = process.env.MNEMOM_AGENT_RC_PROXY_LOG || join(rcDir, "proxy.log");
1633
+ const proxyEnv = {
1634
+ ...process.env,
1635
+ MNEMOM_AGENT_RC_DOOR: ctx.door,
1636
+ MNEMOM_AGENT_RC_AGENT: ctx.agent,
1637
+ MNEMOM_AGENT_RC_AGENT_ID: ctx.agentId ?? "",
1638
+ MNEMOM_AGENT_RC_CONVERSATION_ID: ctx.conversationId,
1639
+ MNEMOM_AGENT_RC_CONTRACT_B64: ctx.envelope?.base64 ?? "",
1640
+ MNEMOM_AGENT_RC_GOAL_MODE: ctx.goalMode ?? "",
1641
+ MNEMOM_AGENT_RC_SESSION_CONFIG: ctx.sessionConfigB64 ?? "",
1642
+ MNEMOM_AGENT_RC_PROXY_LOG: proxyLog,
1643
+ MNEMOM_AGENT_RC_PARENT_PID: String(process.pid),
1644
+ MNEMOM_AGENT_RC_AUTH: ctx.anthropicKey ? "api-key" : "subscription",
1645
+ };
1646
+ // MNE-8245 defense-in-depth: the proxy gets the session's (minted) Mnemom key on stdin only
1647
+ // and reads none of the owner's own Mnemom credential vars, so they never ride into its env.
1648
+ stripOwnerCredentialEnv(proxyEnv);
1569
1649
  const proxy = spawn("node", [proxyScript], {
1570
- env: {
1571
- ...process.env,
1572
- MNEMOM_AGENT_RC_DOOR: ctx.door,
1573
- MNEMOM_AGENT_RC_AGENT: ctx.agent,
1574
- MNEMOM_AGENT_RC_AGENT_ID: ctx.agentId ?? "",
1575
- MNEMOM_AGENT_RC_CONVERSATION_ID: ctx.conversationId,
1576
- MNEMOM_AGENT_RC_CONTRACT_B64: ctx.envelope?.base64 ?? "",
1577
- MNEMOM_AGENT_RC_GOAL_MODE: ctx.goalMode ?? "",
1578
- MNEMOM_AGENT_RC_SESSION_CONFIG: ctx.sessionConfigB64 ?? "",
1579
- MNEMOM_AGENT_RC_PROXY_LOG: proxyLog,
1580
- MNEMOM_AGENT_RC_PARENT_PID: String(process.pid),
1581
- MNEMOM_AGENT_RC_AUTH: ctx.anthropicKey ? "api-key" : "subscription",
1582
- },
1650
+ env: proxyEnv,
1583
1651
  stdio: ["pipe", "pipe", "inherit"],
1584
1652
  });
1585
1653
  let stopped = false;
1586
- const onExit = () => {
1587
- if (stopped)
1588
- return;
1589
- stopped = true;
1654
+ const stopProxyAndTempDir = () => {
1590
1655
  if (!proxy.killed) {
1591
1656
  try {
1592
1657
  proxy.kill("SIGTERM");
@@ -1602,7 +1667,32 @@ function launchRemoteControl(ctx) {
1602
1667
  /* best effort */
1603
1668
  }
1604
1669
  };
1605
- process.on("exit", onExit);
1670
+ // MNE-8245 review-fix: this MUST be awaited by every call site below, and exit/re-signal must
1671
+ // happen only after it settles — a fire-and-forget `void revokeSessionGatewayKey(...)`
1672
+ // immediately followed by `process.exit()`/`process.kill()` never lets the revoke's fetch even
1673
+ // start before the process dies. revokeSessionGatewayKey bounds itself internally (~3s via
1674
+ // AbortSignal.timeout in lib/session-key.ts), so awaiting it here cannot hang the exit path.
1675
+ const onExit = async () => {
1676
+ if (stopped)
1677
+ return;
1678
+ stopped = true;
1679
+ stopProxyAndTempDir();
1680
+ if (ctx.sessionKeyId) {
1681
+ await revokeSessionGatewayKey(defaultSessionKeyDeps(say), ctx.sessionKeyId);
1682
+ }
1683
+ };
1684
+ // Last-resort SYNC fallback only — Node's "exit" handlers cannot await, so this branch never
1685
+ // revokes the key (the 24h TTL covers it). It exists solely to guarantee the proxy child and
1686
+ // temp dir are cleaned up on an exit path that didn't go through (and await) onExit() above,
1687
+ // e.g. an unexpected top-level throw. Every normal exit path in this function calls onExit()
1688
+ // directly and awaits it before exiting, so this is a no-op on the common paths (`stopped` is
1689
+ // already true by the time it fires).
1690
+ process.on("exit", () => {
1691
+ if (stopped)
1692
+ return;
1693
+ stopped = true;
1694
+ stopProxyAndTempDir();
1695
+ });
1606
1696
  // Secrets on stdin — three lines (anthropic, mnemom, openai). An api-key launch sends the
1607
1697
  // Anthropic key, plus this machine's Mnemom key when it has one (the key principal; always
1608
1698
  // with a pin); a subscription launch sends the Mnemom key only (the identity anchor — the
@@ -1614,21 +1704,24 @@ function launchRemoteControl(ctx) {
1614
1704
  if (err.code === "EPIPE")
1615
1705
  return;
1616
1706
  say(`mnemom agent: could not hand the secrets to the Remote Control proxy: ${err.message}`);
1617
- onExit();
1618
- process.exit(1);
1707
+ void onExit().finally(() => process.exit(1));
1619
1708
  });
1620
1709
  proxy.stdin?.end(rcProxyStdin(ctx));
1621
1710
  let stdoutBuf = "";
1622
1711
  const start = Date.now();
1623
1712
  const tryStart = () => {
1713
+ // Cleanup already ran (a refusal, or the exit fallback reaped the proxy): stop polling, so a
1714
+ // proxy killed by a signal (exitCode stays null) never reaches the 15s refusal afterwards.
1715
+ if (stopped)
1716
+ return;
1624
1717
  const m = stdoutBuf.match(/^READY (\d+) (.+)$/m);
1625
1718
  if (m) {
1626
1719
  const port = m[1];
1627
1720
  const ca = m[2].trim();
1628
1721
  if (!existsSync(ca)) {
1629
1722
  say("mnemom agent: malformed READY line from the Remote Control proxy.");
1630
- onExit();
1631
- process.exit(1);
1723
+ void onExit().finally(() => process.exit(1));
1724
+ return;
1632
1725
  }
1633
1726
  spawnRcCli({ ...ctx, port, ca, onExit });
1634
1727
  return;
@@ -1636,13 +1729,13 @@ function launchRemoteControl(ctx) {
1636
1729
  if (proxy.exitCode !== null) {
1637
1730
  say("mnemom agent: the Remote Control proxy exited before it was ready — refusing to launch un-routed.");
1638
1731
  redactedProxyLog(proxyLog);
1639
- onExit();
1640
- process.exit(1);
1732
+ void onExit().finally(() => process.exit(1));
1733
+ return;
1641
1734
  }
1642
1735
  if (Date.now() - start > 15_000) {
1643
1736
  say("mnemom agent: the Remote Control proxy did not report READY within 15s.");
1644
- onExit();
1645
- process.exit(1);
1737
+ void onExit().finally(() => process.exit(1));
1738
+ return;
1646
1739
  }
1647
1740
  setTimeout(tryStart, 200);
1648
1741
  };
@@ -1651,8 +1744,7 @@ function launchRemoteControl(ctx) {
1651
1744
  });
1652
1745
  proxy.on("error", (err) => {
1653
1746
  say(`mnemom agent: failed to launch the Remote Control proxy: ${err.message}`);
1654
- onExit();
1655
- process.exit(1);
1747
+ void onExit().finally(() => process.exit(1));
1656
1748
  });
1657
1749
  tryStart();
1658
1750
  }
@@ -1683,6 +1775,11 @@ function spawnRcCli(ctx) {
1683
1775
  if (!strip.has(k))
1684
1776
  childEnv[k] = v;
1685
1777
  }
1778
+ // MNE-8245 defense-in-depth: see the identical call in launchTerminal — the owner's own
1779
+ // Mnemom credential must never ride through just because it sits in the CLI's own process
1780
+ // env. The RC gate forwards the Mnemom identity via the proxy's own auth plane, never from
1781
+ // this child's env, so there is no legitimate use for these vars here either.
1782
+ stripOwnerCredentialEnv(childEnv);
1686
1783
  childEnv.HTTPS_PROXY = `http://127.0.0.1:${ctx.port}`;
1687
1784
  childEnv.https_proxy = `http://127.0.0.1:${ctx.port}`;
1688
1785
  childEnv.NODE_EXTRA_CA_CERTS = ctx.ca;
@@ -1764,15 +1861,18 @@ function spawnRcCli(ctx) {
1764
1861
  const child = spawn(ctx.cli.bin, args, { stdio: "inherit", env: childEnv });
1765
1862
  child.on("error", (err) => {
1766
1863
  say(`mnemom agent: failed to launch ${ctx.cli.name} under Remote Control: ${err.message}`);
1767
- ctx.onExit();
1768
- process.exit(1);
1864
+ void ctx.onExit().finally(() => process.exit(1));
1769
1865
  });
1770
1866
  child.on("exit", (code, signal) => {
1771
- ctx.onExit();
1772
- if (signal)
1773
- process.kill(process.pid, signal);
1774
- else
1775
- process.exit(code ?? 0);
1867
+ // MNE-8245 review-fix: await the (internally bounded) revoke before exiting/re-signaling —
1868
+ // the previous fire-and-forget `ctx.onExit()` immediately followed by exit/kill never let the
1869
+ // revoke's fetch start before the process died, so it silently never ran on this path.
1870
+ void ctx.onExit().finally(() => {
1871
+ if (signal)
1872
+ process.kill(process.pid, signal);
1873
+ else
1874
+ process.exit(code ?? 0);
1875
+ });
1776
1876
  });
1777
1877
  }
1778
1878
  function warnServerDrops(options, yolo) {
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The A2 calls `sessions adopt` makes (BACKBONE A2: /v1/agent/sessions/{cid} get and lock).
3
+ *
4
+ * - getSession: GET /v1/agent/sessions/{cid}?agent_id&org_id (the existing owner read, which A2
5
+ * extends with durable_mode, state, effective_state and cc_session_id).
6
+ * - lock: POST S1 ROUTES.own_lock with the closed S1 LockBody, under the caller's
7
+ * Idempotency-Key (one per handover, reused on every retry) and LOCK_HTTP_TIMEOUT_MS.
8
+ * - lockCheck (PRECHECK): C14-R25 step 1 runs the R17 sharing and R18 lock-precondition checks
9
+ * before any SIGSTOP, and the C14 cases assert no request to a path ending in /lock for a
10
+ * refused group. S1 freezes no non-mutating check route, so adopt asks
11
+ * POST /v1/agent/sessions/{cid}/lock-preconditions with the same LockBody (an A2 assumption,
12
+ * written up in the w1 gaps). A 404/405 with no durable code means the route is not served;
13
+ * adopt then fails closed: the group is refused before any signal.
14
+ *
15
+ * Every answer is an ApiAnswer; nothing here throws on HTTP status.
16
+ */
17
+ import type { ApiAnswer, AdoptApi } from "./adopt.js";
18
+ export declare const PRECHECK_SUFFIX = "/lock-preconditions";
19
+ export declare const ADOPT_HTTP_TIMEOUT_MS = 30000;
20
+ /**
21
+ * The lock request's own timeout: below the api's 30 s window after which a pending
22
+ * Idempotency-Key reservation is taken over by the next call with that key. A same-key retry sent
23
+ * after a lock timeout therefore reaches the api while the first call's reservation still holds,
24
+ * and is answered 409 idempotency_key_in_flight (still pending) or with the replayed 2xx.
25
+ */
26
+ export declare const LOCK_HTTP_TIMEOUT_MS = 20000;
27
+ export interface HttpDeps {
28
+ base: string;
29
+ /** Auth headers for the owner (JWT or the owner's API key). */
30
+ auth(): Promise<Record<string, string>>;
31
+ fetch(url: string, init: RequestInit): Promise<Response>;
32
+ idempotencyKey(): string;
33
+ timeoutMs?: number;
34
+ }
35
+ /** A Response as an ApiAnswer: 2xx ok with its JSON body; else the error envelope's code and details. */
36
+ export declare function answerOf(res: Response): Promise<ApiAnswer>;
37
+ export declare function httpAdoptApi(deps: HttpDeps): AdoptApi;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The A2 calls `sessions adopt` makes (BACKBONE A2: /v1/agent/sessions/{cid} get and lock).
3
+ *
4
+ * - getSession: GET /v1/agent/sessions/{cid}?agent_id&org_id (the existing owner read, which A2
5
+ * extends with durable_mode, state, effective_state and cc_session_id).
6
+ * - lock: POST S1 ROUTES.own_lock with the closed S1 LockBody, under the caller's
7
+ * Idempotency-Key (one per handover, reused on every retry) and LOCK_HTTP_TIMEOUT_MS.
8
+ * - lockCheck (PRECHECK): C14-R25 step 1 runs the R17 sharing and R18 lock-precondition checks
9
+ * before any SIGSTOP, and the C14 cases assert no request to a path ending in /lock for a
10
+ * refused group. S1 freezes no non-mutating check route, so adopt asks
11
+ * POST /v1/agent/sessions/{cid}/lock-preconditions with the same LockBody (an A2 assumption,
12
+ * written up in the w1 gaps). A 404/405 with no durable code means the route is not served;
13
+ * adopt then fails closed: the group is refused before any signal.
14
+ *
15
+ * Every answer is an ApiAnswer; nothing here throws on HTTP status.
16
+ */
17
+ import { routePath } from "../vendor/durable-contract/index.js";
18
+ export const PRECHECK_SUFFIX = "/lock-preconditions";
19
+ export const ADOPT_HTTP_TIMEOUT_MS = 30_000;
20
+ /**
21
+ * The lock request's own timeout: below the api's 30 s window after which a pending
22
+ * Idempotency-Key reservation is taken over by the next call with that key. A same-key retry sent
23
+ * after a lock timeout therefore reaches the api while the first call's reservation still holds,
24
+ * and is answered 409 idempotency_key_in_flight (still pending) or with the replayed 2xx.
25
+ */
26
+ export const LOCK_HTTP_TIMEOUT_MS = 20_000;
27
+ /** A Response as an ApiAnswer: 2xx ok with its JSON body; else the error envelope's code and details. */
28
+ export async function answerOf(res) {
29
+ let body;
30
+ const text = await res.text();
31
+ try {
32
+ body = text === "" ? null : JSON.parse(text);
33
+ }
34
+ catch {
35
+ body = text;
36
+ }
37
+ if (res.ok)
38
+ return { kind: "ok", status: res.status, body };
39
+ const b = (body && typeof body === "object" ? body : {});
40
+ const err = (b.error && typeof b.error === "object" ? b.error : b);
41
+ return {
42
+ kind: "error",
43
+ status: res.status,
44
+ code: typeof err.code === "string" ? err.code : null,
45
+ details: err.details ?? null,
46
+ message: typeof err.message === "string"
47
+ ? err.message
48
+ : typeof b.error === "string"
49
+ ? b.error
50
+ : `HTTP ${res.status}`,
51
+ };
52
+ }
53
+ async function call(deps, path, init, timeoutMs = ADOPT_HTTP_TIMEOUT_MS) {
54
+ const ctl = new AbortController();
55
+ const timer = setTimeout(() => ctl.abort(), deps.timeoutMs ?? timeoutMs);
56
+ try {
57
+ const res = await deps.fetch(`${deps.base}${path}`, { ...init, signal: ctl.signal });
58
+ return await answerOf(res);
59
+ }
60
+ catch (e) {
61
+ if (ctl.signal.aborted)
62
+ return { kind: "timeout" };
63
+ return { kind: "network", message: e instanceof Error ? e.message : String(e) };
64
+ }
65
+ finally {
66
+ clearTimeout(timer);
67
+ }
68
+ }
69
+ export function httpAdoptApi(deps) {
70
+ const post = async (path, body, key = deps.idempotencyKey(), timeoutMs) => call(deps, path, {
71
+ method: "POST",
72
+ headers: {
73
+ ...(await deps.auth()),
74
+ "Content-Type": "application/json",
75
+ "Idempotency-Key": key,
76
+ },
77
+ body: JSON.stringify(body),
78
+ }, timeoutMs);
79
+ return {
80
+ async getSession(cid, facts) {
81
+ const q = new URLSearchParams();
82
+ if (facts.agentId)
83
+ q.set("agent_id", facts.agentId);
84
+ if (facts.orgId)
85
+ q.set("org_id", facts.orgId);
86
+ const qs = q.toString();
87
+ return call(deps, `/v1/agent/sessions/${encodeURIComponent(cid)}${qs ? `?${qs}` : ""}`, {
88
+ method: "GET",
89
+ headers: await deps.auth(),
90
+ });
91
+ },
92
+ lockCheck: (cid, body) => post(`/v1/agent/sessions/${encodeURIComponent(cid)}${PRECHECK_SUFFIX}`, body),
93
+ // One key per handover: the caller passes the same key to every retry of one lock.
94
+ async lock(cid, body, idempotencyKey) {
95
+ let path;
96
+ try {
97
+ path = routePath("own_lock", cid);
98
+ }
99
+ catch (e) {
100
+ // Never throw after the caller's SIGSTOP: an unroutable id is a refused commit.
101
+ return { kind: "error", status: 400, code: null, details: null, message: String(e) };
102
+ }
103
+ return post(path, body, idempotencyKey ?? deps.idempotencyKey(), LOCK_HTTP_TIMEOUT_MS);
104
+ },
105
+ };
106
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The thin live driver for `sessions adopt`: real processes, files, HTTP and the terminal. The
3
+ * decisions live in adopt.ts (pure over AdoptDeps); this file only wires them to the machine.
4
+ */
5
+ import { type ExecutorState, type LauncherKey } from "../vendor/durable-contract/index.js";
6
+ import { type SessionRecord } from "../lib/agent-ledger.js";
7
+ import type { AdoptDeps, LedgerFacts } from "./adopt.js";
8
+ /** The ledger facts for a conversation: its newest record. */
9
+ export declare function ledgerFactsFrom(records: readonly SessionRecord[], cid: string): LedgerFacts | null;
10
+ /** The launcher key `durable` (MNEMOM_AGENT_DURABLE, then launcher.toml), default off. */
11
+ export declare function launcherKeyFrom(env: Readonly<Record<string, string | undefined>>, tomlText: string | null): LauncherKey;
12
+ /** The executor state file, or null when absent or not the frozen S1 shape. */
13
+ export declare function executorStateFrom(text: string | null): ExecutorState | null;
14
+ export declare function authHeaders(): Promise<Record<string, string>>;
15
+ /** A [Y/n] answer: anything not starting with n is yes. */
16
+ export declare function answerIsYes(a: string): boolean;
17
+ export declare function liveAdoptDeps(): AdoptDeps;