@runuai/host 0.9.139 → 0.9.141

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.
package/src/cli.ts CHANGED
@@ -1585,12 +1585,21 @@ async function cmdSetup(rest: string[]): Promise<void> {
1585
1585
  process.exitCode = 1;
1586
1586
  return;
1587
1587
  }
1588
+ // ADR-136 step 4: a replace-machine enrollment answers with the EXISTING
1589
+ // row's id — this machine adopts it instead of its own proposal, so the
1590
+ // runner keeps its identity (and every task created against it).
1591
+ const outcome =
1592
+ success && typeof success === "object" && !Array.isArray(success)
1593
+ ? (success as Record<string, unknown>)
1594
+ : null;
1595
+ const replacedHostId =
1596
+ outcome?.replaced === true && typeof outcome.hostId === "string" && outcome.hostId.length > 0
1597
+ ? outcome.hostId
1598
+ : null;
1588
1599
  if (
1589
- !success ||
1590
- typeof success !== "object" ||
1591
- Array.isArray(success) ||
1592
- (success as Record<string, unknown>).ok !== true ||
1593
- (success as Record<string, unknown>).hostId !== pending.hostId
1600
+ !outcome ||
1601
+ outcome.ok !== true ||
1602
+ (replacedHostId === null && outcome.hostId !== pending.hostId)
1594
1603
  ) {
1595
1604
  console.error(red("enrollment outcome did not match the proposed host identity"));
1596
1605
  console.error(dim("the proposed identity is saved locally; retry this exact command."));
@@ -1614,7 +1623,27 @@ async function cmdSetup(rest: string[]): Promise<void> {
1614
1623
  process.exitCode = 1;
1615
1624
  return;
1616
1625
  }
1617
- console.log(green("✓ enrolled") + dim(` — config written to ${envLocalPath()}`));
1626
+ if (replacedHostId !== null) {
1627
+ // The pending commit wrote the proposed id; the row this machine now
1628
+ // answers as is the replaced one. Same secret, so the bridge admits it.
1629
+ try {
1630
+ writeEnvValuesAtomic(envLocalPath(), { UAI_HOST_ID: replacedHostId });
1631
+ } catch (err) {
1632
+ console.error(
1633
+ red(
1634
+ `replaced, but could not record the runner id in ${envLocalPath()}: ${err instanceof Error ? err.message : String(err)}`,
1635
+ ),
1636
+ );
1637
+ console.error(dim(`set UAI_HOST_ID=${replacedHostId} there by hand before starting.`));
1638
+ process.exitCode = 1;
1639
+ return;
1640
+ }
1641
+ console.log(
1642
+ green("✓ replaced") + dim(` — this machine now answers as runner ${replacedHostId}; config written to ${envLocalPath()}`),
1643
+ );
1644
+ } else {
1645
+ console.log(green("✓ enrolled") + dim(` — config written to ${envLocalPath()}`));
1646
+ }
1618
1647
  printTelemetryNotice();
1619
1648
  console.log("");
1620
1649
  console.log("Start the host:");
package/src/main.ts CHANGED
@@ -264,6 +264,15 @@ import {
264
264
  signInBoxAllowsCommand,
265
265
  signInBoxMode,
266
266
  } from "../lib/signin-box";
267
+ import {
268
+ deleteProjectEnvStoreOnly,
269
+ listProjectEnvKeysStoreOnly,
270
+ purgeLocalUserCredentialSecrets,
271
+ reconcileUserCredentialMetadataFromStore,
272
+ setProjectEnvStoreOnly,
273
+ } from "../lib/signin-box-credentials";
274
+ import { loadGithubTokenIntoCache } from "../lib/github-tokens";
275
+ import { ensureSshKeyStoreFirst } from "../lib/ssh";
267
276
  import { resumeSidecarTask } from "../lib/sidecar-resume";
268
277
  import { declareAgentClisReady } from "../lib/standard-image";
269
278
 
@@ -557,12 +566,24 @@ async function startSignInBox(): Promise<void> {
557
566
  ownerHome: process.env.UAI_OWNER_HOME?.trim() || homedir(),
558
567
  envFile: join(env.uaiHome, ".env.local"),
559
568
  });
569
+ const users = purgeLocalUserCredentialSecrets();
560
570
  const purged =
561
- secrets.stripped + secrets.removed + slots.files.length + slots.dirs.length + slots.envKeys.length;
571
+ secrets.stripped +
572
+ secrets.removed +
573
+ slots.files.length +
574
+ slots.dirs.length +
575
+ slots.envKeys.length +
576
+ users.githubStripped +
577
+ users.sshStripped +
578
+ users.mcpStripped +
579
+ users.projectEnvRemoved +
580
+ users.unmirroredRemoved;
562
581
  if (purged > 0 || secrets.errors > 0) {
563
582
  console.warn(
564
583
  `[signin-box] purged local credentials: ${secrets.stripped} account rows stripped, ${secrets.removed} removed, ` +
565
- `${slots.files.length} default-slot files, ${slots.dirs.length} stray dirs, ${slots.envKeys.length} env keys` +
584
+ `${slots.files.length} default-slot files, ${slots.dirs.length} stray dirs, ${slots.envKeys.length} env keys, ` +
585
+ `${users.githubStripped} github, ${users.sshStripped} ssh, ${users.mcpStripped} mcp rows stripped, ` +
586
+ `${users.projectEnvRemoved} project env rows and ${users.unmirroredRemoved} unmirrored rows removed` +
566
587
  (secrets.errors > 0 ? `, ${secrets.errors} errors` : ""),
567
588
  );
568
589
  }
@@ -572,13 +593,25 @@ async function startSignInBox(): Promise<void> {
572
593
  timer.unref?.();
573
594
  });
574
595
  try {
575
- const outcome = await Promise.race([reconcileEngineAccountMetadataFromStore(), deadline]);
596
+ const outcome = await Promise.race([
597
+ Promise.all([reconcileEngineAccountMetadataFromStore(), reconcileUserCredentialMetadataFromStore()]),
598
+ deadline,
599
+ ]);
576
600
  if (outcome === "timeout") {
577
- console.warn("[signin-box] account metadata reconcile did not finish in time; the list fills on the next start");
578
- } else if (!outcome.skipped) {
579
- console.log(
580
- `[signin-box] account metadata reconciled: ${outcome.imported} imported, ${outcome.removed} removed, ${outcome.errors} errors`,
581
- );
601
+ console.warn("[signin-box] metadata reconcile did not finish in time; the lists fill on the next start");
602
+ } else {
603
+ const [accounts, users] = outcome;
604
+ if (!accounts.skipped) {
605
+ console.log(
606
+ `[signin-box] account metadata reconciled: ${accounts.imported} imported, ${accounts.removed} removed, ${accounts.errors} errors`,
607
+ );
608
+ }
609
+ if (!users.skipped) {
610
+ console.log(
611
+ `[signin-box] user credential metadata reconciled: ${users.imported} imported, ${users.removed} removed, ` +
612
+ `${users.tagged} legacy parameters tagged, ${users.unreadable} unreadable, ${users.errors} errors`,
613
+ );
614
+ }
582
615
  }
583
616
  } catch (error) {
584
617
  console.warn(
@@ -1628,6 +1661,11 @@ async function connect(): Promise<void> {
1628
1661
  });
1629
1662
  },
1630
1663
  };
1664
+ // ADR-136 sign-in box: the prior grant is not on disk; read it
1665
+ // into the cache so an account switch still revokes it remotely.
1666
+ if (signInBoxMode()) {
1667
+ await loadGithubTokenIntoCache(frame.userId).catch(() => false);
1668
+ }
1631
1669
  let result: Awaited<ReturnType<typeof onConnectSet>>;
1632
1670
  try {
1633
1671
  result = await onConnectSet(
@@ -1793,6 +1831,11 @@ async function connect(): Promise<void> {
1793
1831
  error: "stale GitHub credential generation",
1794
1832
  };
1795
1833
  }
1834
+ // ADR-136 sign-in box: the grant is not on disk; read it into
1835
+ // the cache so the disconnect still revokes it remotely.
1836
+ if (signInBoxMode()) {
1837
+ await loadGithubTokenIntoCache(frame.userId).catch(() => false);
1838
+ }
1796
1839
  await onConnectClear(frame.userId, {
1797
1840
  invalidateCredentials: invalidateTaskGithubGitCredentials,
1798
1841
  reconcile: (taskId, userId) => {
@@ -1878,6 +1921,24 @@ async function connect(): Promise<void> {
1878
1921
  );
1879
1922
  break;
1880
1923
  }
1924
+ if (frame.kind === "ssh.key.ensure" && signInBoxMode()) {
1925
+ // ADR-136 sign-in box: the keypair goes to the store first; only
1926
+ // the public half stays here.
1927
+ void managedMutationDrain.track(() =>
1928
+ ensureSshKeyStoreFirst(frame.userId).then(
1929
+ (publicKey) =>
1930
+ send(socket, { kind: "ssh.key.ack", userId: frame.userId, ok: true, publicKey }),
1931
+ (err) =>
1932
+ send(socket, {
1933
+ kind: "ssh.key.ack",
1934
+ userId: frame.userId,
1935
+ ok: false,
1936
+ error: err instanceof Error ? err.message : "ssh key op failed",
1937
+ }),
1938
+ ),
1939
+ );
1940
+ break;
1941
+ }
1881
1942
  try {
1882
1943
  const publicKey =
1883
1944
  frame.kind === "ssh.key.ensure"
@@ -1920,6 +1981,30 @@ async function connect(): Promise<void> {
1920
1981
  case "env.var.list":
1921
1982
  case "env.var.set":
1922
1983
  case "env.var.delete": {
1984
+ if (signInBoxMode()) {
1985
+ // ADR-136 sign-in box: no local rows — every op is a store call,
1986
+ // and the ack lists names from the store.
1987
+ void managedMutationDrain.track(() =>
1988
+ (frame.kind === "env.var.set"
1989
+ ? setProjectEnvStoreOnly(frame.projectId, frame.key, frame.value, frame.orgId)
1990
+ : frame.kind === "env.var.delete"
1991
+ ? deleteProjectEnvStoreOnly(frame.projectId, frame.key)
1992
+ : Promise.resolve()
1993
+ )
1994
+ .then(() => listProjectEnvKeysStoreOnly(frame.projectId))
1995
+ .then(
1996
+ (keys) => send(socket, { kind: "env.var.ack", opId: frame.opId, ok: true, keys }),
1997
+ (err) =>
1998
+ send(socket, {
1999
+ kind: "env.var.ack",
2000
+ opId: frame.opId,
2001
+ ok: false,
2002
+ error: err instanceof Error ? err.message : "env var op failed",
2003
+ }),
2004
+ ),
2005
+ );
2006
+ break;
2007
+ }
1923
2008
  try {
1924
2009
  if (frame.kind === "env.var.set") {
1925
2010
  setProjectEnvVar(frame.projectId, frame.key, frame.value, frame.orgId);
package/src/protocol.ts CHANGED
@@ -1489,27 +1489,33 @@ export interface HostCommands {
1489
1489
  * exactly like the local API; re-advertises capabilities on success. */
1490
1490
  engineAccountRemove(
1491
1491
  ctx: CommandContext,
1492
- input: { id: string; scope?: string },
1492
+ input: { id: string; scope?: string; hostId?: string },
1493
1493
  ): Promise<HostCommandResult<{ removed: boolean; message?: string }>>;
1494
1494
  /** Manage-v1: relabel an extra account (never `default:*`); a scope may
1495
1495
  * rename only its own accounts. Re-advertises on success. */
1496
1496
  engineAccountRename(
1497
1497
  ctx: CommandContext,
1498
- input: { id: string; label: string; scope?: string },
1498
+ input: { id: string; label: string; scope?: string; hostId?: string },
1499
1499
  ): Promise<HostCommandResult<{ renamed: boolean; message?: string }>>;
1500
1500
  /** Manage-v1: set the priority order of a kind's extra accounts within a
1501
1501
  * scope — `ids` must be exactly that set. Tasks pick the first healthy
1502
1502
  * account in this order. Re-advertises on success. */
1503
1503
  engineAccountReorder(
1504
1504
  ctx: CommandContext,
1505
- input: { kind: string; ids: string[]; scope?: string },
1505
+ input: { kind: string; ids: string[]; scope?: string; hostId?: string },
1506
1506
  ): Promise<HostCommandResult<{ reordered: boolean; message?: string }>>;
1507
1507
  /** Disconnect-v1: forget the host's DEFAULT credential for an engine — the
1508
1508
  * .env.local key and/or the login file the adapter detects. Extras are
1509
- * untouched (remove them by id). Re-advertises on success. */
1509
+ * untouched (remove them by id). Re-advertises on success.
1510
+ *
1511
+ * `hostId` on the four account commands above: the runner the account lives
1512
+ * on. The cloud's bridge routes by it (2026-09-14: without it these fell to
1513
+ * the any-open-host fallback and landed on whichever runner connected
1514
+ * first — the label step after a sign-in answered "That account no longer
1515
+ * exists" and Disconnect did nothing). The host ignores the field. */
1510
1516
  engineDisconnect(
1511
1517
  ctx: CommandContext,
1512
- input: { engine: EngineConnectKind },
1518
+ input: { engine: EngineConnectKind; hostId?: string },
1513
1519
  ): Promise<HostCommandResult<{ disconnected: boolean; message?: string }>>;
1514
1520
  cloneRepo(
1515
1521
  ctx: CommandContext,