@mnemom/mnemom 0.17.32-next.2 → 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.
@@ -31,7 +31,7 @@ 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";
@@ -39,6 +39,7 @@ import { ALLOW_AUTOCOMPACT_OFF_ENV, dropAutocompactOffEnv, hasOwnSettingsFlag, m
39
39
  import { getApiUrl, getGatewayUrl, MNEMOM_DIR } from "../lib/config.js";
40
40
  import { createApiKey } from "../lib/api.js";
41
41
  import { recordOrgFor } from "../lib/record-org.js";
42
+ import { defaultSessionKeyDeps, mintSessionGatewayKey, revokeSessionGatewayKey, } from "../lib/session-key.js";
42
43
  import { MNEMOM_KEY_STORE_FIELD, resolveAgentAuth, sayRetiredConsoleNotice, readMnemomKey, resolveSubscriptionMnemomKey, subscriptionChildEnv, subscriptionShape, } from "../lib/subscription-login.js";
43
44
  import { LEDGER_FILE, appendSessionRecord, findResumeCandidate, readSessionRecords, resolveRetentionDays, resumedContextProfile, toLedgerContract, } from "../lib/agent-ledger.js";
44
45
  import { isLoggedIn } from "../lib/auth.js";
@@ -51,7 +52,7 @@ import { askInput, askSelect, askYesNo, isInteractive } from "../lib/prompt.js";
51
52
  import { authConfigured, chooseAuth, detectClaudeLogin, readClaudeLoginState, } from "../lib/login-detect.js";
52
53
  import { goalAlignmentLine, resolveSessionSetup } from "./agent-session.js";
53
54
  import { defaultAgentSlug, ensureClaimedServedAgent, ensureSessionAndBalance, isQuietPreambleExit, readComposedProtection, resolveDefaultSlug, resolvePinnedAgent, resolvePreflightedAccount, } from "./agent-setup.js";
54
- 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";
55
56
  import { LAUNCHER_STORE_PATH, mergeStore } from "../lib/launcher-store.js";
56
57
  import { slackChannelArgs } from "../lib/slack-launch.js";
57
58
  import { appendPluginDirsEnv, applyPluginEnv, pluginEnabled, pluginPlanLine, resolvePluginDir, } from "../lib/claude-plugin.js";
@@ -589,6 +590,33 @@ export async function agentCommand(scenarioArg, options = {}) {
589
590
  // hosts many sessions in one process, so it records none and injects nothing.
590
591
  const cc = shape === "server" ? { id: null, inject: false } : pickCcSessionId(options.passthrough ?? []);
591
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
+ }
592
620
  // ── 1M context window by default ── the model this launch runs on gets `[1m]` when it has no
593
621
  // window suffix and supports 1M (resolveWindowModel); an explicit choice — the
594
622
  // CLAUDE_CODE_DISABLE_1M_CONTEXT opt-out, a passthrough `-- --model`, a suffixed id — is kept.
@@ -734,6 +762,7 @@ export async function agentCommand(scenarioArg, options = {}) {
734
762
  yolo,
735
763
  contextHint,
736
764
  ccSessionId,
765
+ sessionKeyId,
737
766
  handoffMcpConfigPath,
738
767
  resumeHint: (id) => resumeHintLine({
739
768
  scenario,
@@ -773,6 +802,7 @@ export async function agentCommand(scenarioArg, options = {}) {
773
802
  options: launchOptions,
774
803
  yolo,
775
804
  ccSessionId,
805
+ sessionKeyId,
776
806
  });
777
807
  }
778
808
  // ───────────────────────────────────────────────────────────────────────────
@@ -1271,6 +1301,12 @@ function launchTerminal(ctx) {
1271
1301
  else {
1272
1302
  childEnv = subscriptionChildEnv(process.env, ctx.door, headers);
1273
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);
1274
1310
  // Both auth arms point ANTHROPIC_BASE_URL at the gateway door, which makes Claude Code turn
1275
1311
  // tool search off and inline every tool schema on every request — unless told otherwise.
1276
1312
  applyToolSearchDefault(childEnv);
@@ -1312,7 +1348,8 @@ function launchTerminal(ctx) {
1312
1348
  const ownSettings = hasOwnSettingsFlag(passthrough);
1313
1349
  const guard = applyAutocompactGuard(childEnv, ctx.options, !ownSettings);
1314
1350
  const extra = launchSettingsExtra({
1315
- 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),
1316
1353
  commitReport: Boolean(ctx.commitReport),
1317
1354
  ownSettings,
1318
1355
  conversationId: ctx.conversationId,
@@ -1343,24 +1380,30 @@ function launchTerminal(ctx) {
1343
1380
  process.exit(1);
1344
1381
  });
1345
1382
  child.on("exit", (code, signal) => {
1346
- if (signal)
1347
- process.kill(process.pid, signal);
1348
- else
1349
- void (async () => {
1350
- // The conduct receipt and the last commit sweep run side by side, each under its own
1351
- // hard deadline inside W1.3's exit budget; neither changes the exit code.
1352
- const swept = commits?.finish().catch(() => undefined);
1353
- if (ctx.receipt)
1354
- await sayConductReceipt({ ...ctx.receipt, statePath: recordStatePath });
1355
- await swept;
1356
- // Claude Code's own last line is "Resume this session with: claude --resume <id>",
1357
- // which reopens it OUTSIDE Mnemom (ungoverned, unbilled). Ours goes after it, last.
1358
- if (code === 0 && ctx.resumeHint && wantsResumeHint(ctx.cli, args)) {
1359
- const id = ctx.knownCcSessionId ?? newestCcSessionIn(process.cwd(), process.env);
1360
- say(ctx.resumeHint(id));
1361
- }
1362
- process.exit(code ?? 0);
1363
- })();
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
+ })();
1364
1407
  });
1365
1408
  };
1366
1409
  // Implicit goal mode: put the local base-URL proxy in front of the door so each main request
@@ -1503,16 +1546,19 @@ export function startBaseUrlProxy(door, done, opts = {}) {
1503
1546
  // The receipt's record state (lineage key, record URL: non-secret) lives beside the log and is
1504
1547
  // removed with it on exit, after the receipt has read it.
1505
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);
1506
1560
  const proxy = spawn(opts.node ?? process.execPath, [script], {
1507
- env: {
1508
- ...process.env,
1509
- MNEMOM_AGENT_BU_DOOR: door,
1510
- MNEMOM_AGENT_BU_CWD: process.cwd(),
1511
- MNEMOM_AGENT_BU_LOG: process.env.MNEMOM_AGENT_TURN_PROXY_LOG || join(logDir, "proxy.log"),
1512
- MNEMOM_AGENT_BU_PARENT_PID: String(process.pid),
1513
- ...(opts.turnHeader === false ? { MNEMOM_AGENT_TURN_HEADER: "0" } : {}),
1514
- ...(recordStatePath ? { MNEMOM_AGENT_BU_RECORD_STATE: recordStatePath } : {}),
1515
- },
1561
+ env: proxyEnv,
1516
1562
  stdio: ["ignore", "pipe", "ignore"],
1517
1563
  });
1518
1564
  process.on("exit", () => {
@@ -1584,27 +1630,28 @@ function launchRemoteControl(ctx) {
1584
1630
  const rcDir = mkdtempSync(join(rcStateRoot(), "mnemom-code-rc."));
1585
1631
  writeFileSync(join(rcDir, "owner.pid"), `${process.pid}\n`, { mode: 0o600 }); // rc-state.mjs sweep
1586
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);
1587
1649
  const proxy = spawn("node", [proxyScript], {
1588
- env: {
1589
- ...process.env,
1590
- MNEMOM_AGENT_RC_DOOR: ctx.door,
1591
- MNEMOM_AGENT_RC_AGENT: ctx.agent,
1592
- MNEMOM_AGENT_RC_AGENT_ID: ctx.agentId ?? "",
1593
- MNEMOM_AGENT_RC_CONVERSATION_ID: ctx.conversationId,
1594
- MNEMOM_AGENT_RC_CONTRACT_B64: ctx.envelope?.base64 ?? "",
1595
- MNEMOM_AGENT_RC_GOAL_MODE: ctx.goalMode ?? "",
1596
- MNEMOM_AGENT_RC_SESSION_CONFIG: ctx.sessionConfigB64 ?? "",
1597
- MNEMOM_AGENT_RC_PROXY_LOG: proxyLog,
1598
- MNEMOM_AGENT_RC_PARENT_PID: String(process.pid),
1599
- MNEMOM_AGENT_RC_AUTH: ctx.anthropicKey ? "api-key" : "subscription",
1600
- },
1650
+ env: proxyEnv,
1601
1651
  stdio: ["pipe", "pipe", "inherit"],
1602
1652
  });
1603
1653
  let stopped = false;
1604
- const onExit = () => {
1605
- if (stopped)
1606
- return;
1607
- stopped = true;
1654
+ const stopProxyAndTempDir = () => {
1608
1655
  if (!proxy.killed) {
1609
1656
  try {
1610
1657
  proxy.kill("SIGTERM");
@@ -1620,7 +1667,32 @@ function launchRemoteControl(ctx) {
1620
1667
  /* best effort */
1621
1668
  }
1622
1669
  };
1623
- 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
+ });
1624
1696
  // Secrets on stdin — three lines (anthropic, mnemom, openai). An api-key launch sends the
1625
1697
  // Anthropic key, plus this machine's Mnemom key when it has one (the key principal; always
1626
1698
  // with a pin); a subscription launch sends the Mnemom key only (the identity anchor — the
@@ -1632,21 +1704,24 @@ function launchRemoteControl(ctx) {
1632
1704
  if (err.code === "EPIPE")
1633
1705
  return;
1634
1706
  say(`mnemom agent: could not hand the secrets to the Remote Control proxy: ${err.message}`);
1635
- onExit();
1636
- process.exit(1);
1707
+ void onExit().finally(() => process.exit(1));
1637
1708
  });
1638
1709
  proxy.stdin?.end(rcProxyStdin(ctx));
1639
1710
  let stdoutBuf = "";
1640
1711
  const start = Date.now();
1641
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;
1642
1717
  const m = stdoutBuf.match(/^READY (\d+) (.+)$/m);
1643
1718
  if (m) {
1644
1719
  const port = m[1];
1645
1720
  const ca = m[2].trim();
1646
1721
  if (!existsSync(ca)) {
1647
1722
  say("mnemom agent: malformed READY line from the Remote Control proxy.");
1648
- onExit();
1649
- process.exit(1);
1723
+ void onExit().finally(() => process.exit(1));
1724
+ return;
1650
1725
  }
1651
1726
  spawnRcCli({ ...ctx, port, ca, onExit });
1652
1727
  return;
@@ -1654,13 +1729,13 @@ function launchRemoteControl(ctx) {
1654
1729
  if (proxy.exitCode !== null) {
1655
1730
  say("mnemom agent: the Remote Control proxy exited before it was ready — refusing to launch un-routed.");
1656
1731
  redactedProxyLog(proxyLog);
1657
- onExit();
1658
- process.exit(1);
1732
+ void onExit().finally(() => process.exit(1));
1733
+ return;
1659
1734
  }
1660
1735
  if (Date.now() - start > 15_000) {
1661
1736
  say("mnemom agent: the Remote Control proxy did not report READY within 15s.");
1662
- onExit();
1663
- process.exit(1);
1737
+ void onExit().finally(() => process.exit(1));
1738
+ return;
1664
1739
  }
1665
1740
  setTimeout(tryStart, 200);
1666
1741
  };
@@ -1669,8 +1744,7 @@ function launchRemoteControl(ctx) {
1669
1744
  });
1670
1745
  proxy.on("error", (err) => {
1671
1746
  say(`mnemom agent: failed to launch the Remote Control proxy: ${err.message}`);
1672
- onExit();
1673
- process.exit(1);
1747
+ void onExit().finally(() => process.exit(1));
1674
1748
  });
1675
1749
  tryStart();
1676
1750
  }
@@ -1701,6 +1775,11 @@ function spawnRcCli(ctx) {
1701
1775
  if (!strip.has(k))
1702
1776
  childEnv[k] = v;
1703
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);
1704
1783
  childEnv.HTTPS_PROXY = `http://127.0.0.1:${ctx.port}`;
1705
1784
  childEnv.https_proxy = `http://127.0.0.1:${ctx.port}`;
1706
1785
  childEnv.NODE_EXTRA_CA_CERTS = ctx.ca;
@@ -1782,15 +1861,18 @@ function spawnRcCli(ctx) {
1782
1861
  const child = spawn(ctx.cli.bin, args, { stdio: "inherit", env: childEnv });
1783
1862
  child.on("error", (err) => {
1784
1863
  say(`mnemom agent: failed to launch ${ctx.cli.name} under Remote Control: ${err.message}`);
1785
- ctx.onExit();
1786
- process.exit(1);
1864
+ void ctx.onExit().finally(() => process.exit(1));
1787
1865
  });
1788
1866
  child.on("exit", (code, signal) => {
1789
- ctx.onExit();
1790
- if (signal)
1791
- process.kill(process.pid, signal);
1792
- else
1793
- 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
+ });
1794
1876
  });
1795
1877
  }
1796
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;