@runuai/host 0.9.138 → 0.9.140

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/lib/ssh.ts CHANGED
@@ -23,10 +23,11 @@ import { join, resolve } from "node:path";
23
23
 
24
24
  import { eq } from "drizzle-orm";
25
25
 
26
- import { queueStoreWrite, userParameterName } from "./credential-store";
26
+ import { configuredCredentialStore, queueStoreWrite, userParameterName } from "./credential-store";
27
27
  import { getDb, schema } from "./db";
28
28
  import { env } from "./env";
29
29
  import { sealAesGcm, openAesGcm } from "./secrets";
30
+ import { signInBoxMode } from "./signin-box";
30
31
  import { assertSafeHostTaskId } from "./task-identity";
31
32
 
32
33
  const withNewline = (s: string): string => (s.endsWith("\n") ? s : `${s}\n`);
@@ -137,14 +138,15 @@ export function sshKeyParameterName(userId: string): string {
137
138
  return userParameterName(userId, "ssh");
138
139
  }
139
140
 
140
- /** The row as it is mirrored (private key decrypted). Never logged. */
141
+ /** The row as it is mirrored (private key decrypted). Never logged. Null
142
+ * for a store-held row (sign-in box): nothing here to mirror. */
141
143
  export function sshKeyStoreValue(userId: string): StoredSshKey | null {
142
144
  const row = getDb()
143
145
  .select()
144
146
  .from(schema.sshKeys)
145
147
  .where(eq(schema.sshKeys.userId, userId))
146
148
  .get();
147
- if (!row) return null;
149
+ if (!row || isStoreHeldSshRow(row)) return null;
148
150
  return {
149
151
  v: 1,
150
152
  kind: "ssh",
@@ -175,6 +177,8 @@ export function listSshKeyStoreRows(): Array<{ userId: string; storeRef: string
175
177
  }
176
178
 
177
179
  export function mirrorSshKeyToStore(userId: string): void {
180
+ // ADR-136 sign-in box: rows hold no material; the store was written first.
181
+ if (signInBoxMode()) return;
178
182
  queueStoreWrite(`mirror ssh key ${userId}`, async (store) => {
179
183
  const value = sshKeyStoreValue(userId);
180
184
  if (!value) return;
@@ -203,14 +207,15 @@ export function importSshKeyFromStore(value: StoredSshKey, storeRef: string): bo
203
207
  return result.changes > 0;
204
208
  }
205
209
 
206
- /** Decrypt + return the user's private key, or null if they have none. */
210
+ /** Decrypt + return the user's private key, or null if they have none (or
211
+ * the row is store-held — the sign-in box never holds one). */
207
212
  export function privateKeyForUser(userId: string): string | null {
208
213
  const row = getDb()
209
214
  .select()
210
215
  .from(schema.sshKeys)
211
216
  .where(eq(schema.sshKeys.userId, userId))
212
217
  .get();
213
- if (!row) return null;
218
+ if (!row || isStoreHeldSshRow(row)) return null;
214
219
  return openAesGcm(
215
220
  Buffer.from(row.privateKeyCt as Uint8Array),
216
221
  Buffer.from(row.privateKeyNonce as Uint8Array),
@@ -226,7 +231,7 @@ export function keyPairForUser(
226
231
  .from(schema.sshKeys)
227
232
  .where(eq(schema.sshKeys.userId, userId))
228
233
  .get();
229
- if (!row) return null;
234
+ if (!row || isStoreHeldSshRow(row)) return null;
230
235
  return {
231
236
  privateKey: openAesGcm(
232
237
  Buffer.from(row.privateKeyCt as Uint8Array),
@@ -236,6 +241,104 @@ export function keyPairForUser(
236
241
  };
237
242
  }
238
243
 
244
+ // ---------------------------------------------------------------------------
245
+ // ADR-136 sign-in box: a key row here holds NO private key (`private_key_ct`
246
+ // empty — "store-held"); the keypair goes to the store first and only the
247
+ // public half stays, which is all the box ever answers (`ssh.key.get`). The
248
+ // public key also rides as a tag so a replacement box lists it without
249
+ // reading the value.
250
+ // ---------------------------------------------------------------------------
251
+
252
+ export const SSH_KEY_TAGS = { publicKey: "uai_public_key", createdAt: "uai_created_at" } as const;
253
+
254
+ export function isStoreHeldSshRow(row: { privateKeyCt: unknown }): boolean {
255
+ const ct = row.privateKeyCt;
256
+ if (ct === null || ct === undefined) return true;
257
+ if (ct instanceof Uint8Array) return ct.byteLength === 0;
258
+ return false;
259
+ }
260
+
261
+ export function sshKeyMetadataTags(value: { publicKey: string; createdAt: number }): Record<string, string> {
262
+ return {
263
+ [SSH_KEY_TAGS.publicKey]: value.publicKey,
264
+ [SSH_KEY_TAGS.createdAt]: String(value.createdAt),
265
+ };
266
+ }
267
+
268
+ /** A key row with no private half (sign-in box). Existing rows win. */
269
+ export function insertStoreHeldSshKeyRow(args: {
270
+ userId: string;
271
+ publicKey: string;
272
+ createdAt: number;
273
+ updatedAt: number;
274
+ storeRef: string;
275
+ }): boolean {
276
+ const result = getDb()
277
+ .insert(schema.sshKeys)
278
+ .values({
279
+ userId: args.userId,
280
+ publicKey: args.publicKey,
281
+ privateKeyCt: Buffer.alloc(0),
282
+ privateKeyNonce: Buffer.alloc(0),
283
+ createdAt: args.createdAt,
284
+ updatedAt: args.updatedAt,
285
+ storeRef: args.storeRef,
286
+ })
287
+ .onConflictDoNothing({ target: schema.sshKeys.userId })
288
+ .run();
289
+ return result.changes > 0;
290
+ }
291
+
292
+ /** Delete the row only (no store call). */
293
+ export function deleteSshKeyRowLocal(userId: string): void {
294
+ getDb().delete(schema.sshKeys).where(eq(schema.sshKeys.userId, userId)).run();
295
+ }
296
+
297
+ /**
298
+ * `ensureKeyForUser` for the sign-in box: generate, write the keypair to the
299
+ * store FIRST (awaited — a refused put is the operation's failure and leaves
300
+ * nothing), tag the public key, then keep a store-held row. A row whose
301
+ * public key this box could not learn (a legacy parameter it may not read)
302
+ * is refused rather than silently regenerated over the user's real key.
303
+ */
304
+ export async function ensureSshKeyStoreFirst(userId: string): Promise<string> {
305
+ const existing = getDb()
306
+ .select({ publicKey: schema.sshKeys.publicKey })
307
+ .from(schema.sshKeys)
308
+ .where(eq(schema.sshKeys.userId, userId))
309
+ .get();
310
+ if (existing) {
311
+ if (existing.publicKey) return existing.publicKey;
312
+ throw new Error(
313
+ "this user's SSH key exists in the credential store but its public half is not tagged yet; re-run the sign-in box bootstrap with the transitional read",
314
+ );
315
+ }
316
+ const store = configuredCredentialStore();
317
+ if (!store) throw new Error("the sign-in box has no credential store to hold the key");
318
+ const { publicKey, privateKey } = generateEd25519(`uai-${userId}`);
319
+ const now = Date.now();
320
+ const name = sshKeyParameterName(userId);
321
+ const value: StoredSshKey = {
322
+ v: 1,
323
+ kind: "ssh",
324
+ userId,
325
+ publicKey,
326
+ privateKey,
327
+ createdAt: now,
328
+ updatedAt: now,
329
+ };
330
+ await store.put(name, JSON.stringify(value));
331
+ try {
332
+ await store.tag(name, sshKeyMetadataTags(value));
333
+ } catch (err) {
334
+ console.warn(
335
+ `[ssh] user ${userId}: tagging ${name} failed (${err instanceof Error ? err.message : String(err)}) — a replacement box needs the transitional read to list this key`,
336
+ );
337
+ }
338
+ insertStoreHeldSshKeyRow({ userId, publicKey, createdAt: now, updatedAt: now, storeRef: name });
339
+ return getPublicKey(userId) ?? publicKey;
340
+ }
341
+
239
342
  // --- per-task on-disk identity (optional SSH fallback + signing, ADR-029) -----
240
343
  //
241
344
  // task-up.sh uses the creator's key for host Git only when GitHub is not
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.138",
3
+ "version": "0.9.140",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
package/src/cli.ts CHANGED
@@ -204,6 +204,10 @@ async function cmdRun(rest: string[] = []): Promise<void> {
204
204
  // (lib/sidecar.ts) — set before main.ts loads, since its boot reads it.
205
205
  const sidecarTask = flagValue(rest, "--task");
206
206
  if (sidecarTask) process.env.UAI_SIDECAR_TASK = sidecarTask;
207
+ // ADR-136 sign-in box: `uai-host run --signin-box` on the small always-on
208
+ // machine behind the "Uai Cloud" row. Same package, one flag
209
+ // (lib/signin-box.ts); set before main.ts loads for the same reason.
210
+ if (rest.includes("--signin-box")) process.env.UAI_SIGNIN_BOX = "1";
207
211
  // Timestamped service logs: the ADR-106 live smoke burned an hour dating
208
212
  // undated scrollback. Applies to the service process only, not one-shot CLI
209
213
  // commands.
@@ -1581,12 +1585,21 @@ async function cmdSetup(rest: string[]): Promise<void> {
1581
1585
  process.exitCode = 1;
1582
1586
  return;
1583
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;
1584
1599
  if (
1585
- !success ||
1586
- typeof success !== "object" ||
1587
- Array.isArray(success) ||
1588
- (success as Record<string, unknown>).ok !== true ||
1589
- (success as Record<string, unknown>).hostId !== pending.hostId
1600
+ !outcome ||
1601
+ outcome.ok !== true ||
1602
+ (replacedHostId === null && outcome.hostId !== pending.hostId)
1590
1603
  ) {
1591
1604
  console.error(red("enrollment outcome did not match the proposed host identity"));
1592
1605
  console.error(dim("the proposed identity is saved locally; retry this exact command."));
@@ -1610,7 +1623,27 @@ async function cmdSetup(rest: string[]): Promise<void> {
1610
1623
  process.exitCode = 1;
1611
1624
  return;
1612
1625
  }
1613
- 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
+ }
1614
1647
  printTelemetryNotice();
1615
1648
  console.log("");
1616
1649
  console.log("Start the host:");
package/src/main.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  type OutgoingHttpHeaders,
11
11
  } from "node:http";
12
12
  import { Socket } from "node:net";
13
+ import { homedir } from "node:os";
13
14
  import { join } from "node:path";
14
15
 
15
16
  import WebSocket, { type RawData } from "ws";
@@ -159,9 +160,13 @@ import {
159
160
  type HostConfigResult,
160
161
  } from "../lib/host-config";
161
162
  import {
163
+ addCapturedConfigDirAccountToStore,
164
+ addEngineAccountToStore,
162
165
  configureEngineAccountStore,
163
166
  hasStoredEngineAccounts,
164
167
  listEngineAccountsAllScopes,
168
+ purgeLocalEngineAccountSecrets,
169
+ reconcileEngineAccountMetadataFromStore,
165
170
  reconcileEngineAccountsFromStore,
166
171
  } from "../lib/engine-accounts";
167
172
  import {
@@ -253,6 +258,21 @@ import {
253
258
  import { isEnvironmentTransportProvider } from "../lib/task-environment/types";
254
259
  import { LeaseManager } from "../lib/lease";
255
260
  import { markSidecarReady, sidecarMode, sidecarTaskId } from "../lib/sidecar";
261
+ import {
262
+ purgeDefaultEngineSlots,
263
+ SIGN_IN_BOX_TASK_REFUSAL,
264
+ signInBoxAllowsCommand,
265
+ signInBoxMode,
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";
256
276
  import { resumeSidecarTask } from "../lib/sidecar-resume";
257
277
  import { declareAgentClisReady } from "../lib/standard-image";
258
278
 
@@ -438,6 +458,11 @@ interface PausableSource {
438
458
  }
439
459
 
440
460
  console.log(`[host-agent] starting host ${hostId}`);
461
+ if (signInBoxMode() && sidecarMode()) {
462
+ console.error("[host-agent] --signin-box and --task are exclusive; refusing to start");
463
+ process.exit(2);
464
+ }
465
+ if (signInBoxMode()) console.log("[host-agent] sign-in box mode (ADR-136): no tasks, no credential at rest");
441
466
  // Self-heal the recurring silent admission gate BEFORE anything can refuse
442
467
  // work: an uninstall/update helper that died at phase "checking" (live
443
468
  // 2026-08-11 + twice 2026-08-18) left every taskUp/channelEnsure bouncing
@@ -526,9 +551,83 @@ const repushBrokeredCopies = async (kind: string): Promise<void> => {
526
551
  await getOrchestrator().refreshEngineAccountCopies(kind);
527
552
  };
528
553
  configureTokenBroker({ repush: repushBrokeredCopies });
554
+ /**
555
+ * ADR-136 sign-in box start. Strip every credential a previous release or an
556
+ * operator left on this machine, then rebuild the workspace account LIST from
557
+ * parameter names and tags — never a value — before the first capability
558
+ * frame is built, so a replacement box does not advertise an empty list and
559
+ * overwrite the cloud's snapshot with it. Bounded: a store outage must not
560
+ * keep the box offline, only empty until the next start.
561
+ */
562
+ const SIGN_IN_BOX_RECONCILE_TIMEOUT_MS = 30_000;
563
+ async function startSignInBox(): Promise<void> {
564
+ const secrets = purgeLocalEngineAccountSecrets();
565
+ const slots = purgeDefaultEngineSlots({
566
+ ownerHome: process.env.UAI_OWNER_HOME?.trim() || homedir(),
567
+ envFile: join(env.uaiHome, ".env.local"),
568
+ });
569
+ const users = purgeLocalUserCredentialSecrets();
570
+ const purged =
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;
581
+ if (purged > 0 || secrets.errors > 0) {
582
+ console.warn(
583
+ `[signin-box] purged local credentials: ${secrets.stripped} account rows stripped, ${secrets.removed} removed, ` +
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` +
587
+ (secrets.errors > 0 ? `, ${secrets.errors} errors` : ""),
588
+ );
589
+ }
590
+ let timer: NodeJS.Timeout | null = null;
591
+ const deadline = new Promise<"timeout">((resolve) => {
592
+ timer = setTimeout(() => resolve("timeout"), SIGN_IN_BOX_RECONCILE_TIMEOUT_MS);
593
+ timer.unref?.();
594
+ });
595
+ try {
596
+ const outcome = await Promise.race([
597
+ Promise.all([reconcileEngineAccountMetadataFromStore(), reconcileUserCredentialMetadataFromStore()]),
598
+ deadline,
599
+ ]);
600
+ if (outcome === "timeout") {
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
+ }
615
+ }
616
+ } catch (error) {
617
+ console.warn(
618
+ `[signin-box] account metadata reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
619
+ );
620
+ } finally {
621
+ if (timer) clearTimeout(timer);
622
+ }
623
+ }
529
624
  // ADR-133: a sidecar never refreshes sign-ins (the broker Lambda does), and
530
625
  // reads the store only once it holds a lease — see leaseManager below.
531
- if (!sidecarMode()) {
626
+ // ADR-136: the sign-in box imports nothing and runs no pickup — it holds no
627
+ // copy for a refresh to update.
628
+ if (signInBoxMode()) {
629
+ await startSignInBox();
630
+ } else if (!sidecarMode()) {
532
631
  startTokenBrokerLoop();
533
632
  startCredentialStorePickup({ repush: repushBrokeredCopies });
534
633
  void reconcileStores();
@@ -621,6 +720,21 @@ const engineLoginManager = createEngineLoginManager({
621
720
  // A grok sign-in may be the first grok configuration on this host: the
622
721
  // standard image needs the CLI before its tasks can run (host 0.9.105).
623
722
  ensureStandardImage: () => ensureStandardImage(),
723
+ // ADR-136 sign-in box: workspace accounts only, written to the store first
724
+ // and never sealed here.
725
+ workspaceOnly: signInBoxMode(),
726
+ ...(signInBoxMode()
727
+ ? {
728
+ seams: {
729
+ addClaudeAccount: (label, token, scope) =>
730
+ addEngineAccountToStore("claude", label, { token }, scope),
731
+ addCodexAccount: (label, authJson, scope) =>
732
+ addCapturedConfigDirAccountToStore("codex", label, authJson, scope),
733
+ addGrokAccount: (label, authJson, scope) =>
734
+ addCapturedConfigDirAccountToStore("grok", label, authJson, scope),
735
+ },
736
+ }
737
+ : {}),
624
738
  });
625
739
  // ADR-117: one-shot connect (pasted key / OpenCode re-detect) over the bridge.
626
740
  const engineConnectManager = createEngineConnectManager({
@@ -628,6 +742,13 @@ const engineConnectManager = createEngineConnectManager({
628
742
  refreshEngineAccountAgents: (engine) =>
629
743
  getOrchestrator().refreshEngineAccountAgents(engine),
630
744
  ensureStandardImage: () => ensureStandardImage(),
745
+ workspaceOnly: signInBoxMode(),
746
+ ...(signInBoxMode()
747
+ ? {
748
+ addLabeledAccount: (kind, label, secret, scope) =>
749
+ addEngineAccountToStore(kind, label, { token: secret }, scope),
750
+ }
751
+ : {}),
631
752
  });
632
753
  if (initialRuntime.status === "ready") {
633
754
  console.log(
@@ -693,10 +814,10 @@ void connect();
693
814
  // Best-effort: a UI bind failure must not take the host service down.
694
815
  // A task machine has no operator sitting at it (ADR-133): no local UI, no
695
816
  // Mac vnode watch.
696
- if (!sidecarMode()) void startLocalUi();
817
+ if (!sidecarMode() && !signInBoxMode()) void startLocalUi();
697
818
  // ADR-118 follow-up: vnode-pressure observability (macOS/apple-container
698
819
  // hosts). Logs only on band transitions; the sensor caches its sysctl read.
699
- if (!sidecarMode()) {
820
+ if (!sidecarMode() && !signInBoxMode()) {
700
821
  const vnodePressureTimer = setInterval(() => void logVnodePressure(), 60_000);
701
822
  vnodePressureTimer.unref?.();
702
823
  void logVnodePressure();
@@ -1334,6 +1455,7 @@ async function connect(): Promise<void> {
1334
1455
  const frame = parseCloudFrame(data);
1335
1456
  if (!frame) return;
1336
1457
  if (stopping && rejectFrameDuringShutdown(socket, frame)) return;
1458
+ if (signInBoxMode() && rejectFrameOnSignInBox(socket, frame)) return;
1337
1459
 
1338
1460
  switch (frame.kind) {
1339
1461
  case "pong":
@@ -1539,6 +1661,11 @@ async function connect(): Promise<void> {
1539
1661
  });
1540
1662
  },
1541
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
+ }
1542
1669
  let result: Awaited<ReturnType<typeof onConnectSet>>;
1543
1670
  try {
1544
1671
  result = await onConnectSet(
@@ -1704,6 +1831,11 @@ async function connect(): Promise<void> {
1704
1831
  error: "stale GitHub credential generation",
1705
1832
  };
1706
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
+ }
1707
1839
  await onConnectClear(frame.userId, {
1708
1840
  invalidateCredentials: invalidateTaskGithubGitCredentials,
1709
1841
  reconcile: (taskId, userId) => {
@@ -1789,6 +1921,24 @@ async function connect(): Promise<void> {
1789
1921
  );
1790
1922
  break;
1791
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
+ }
1792
1942
  try {
1793
1943
  const publicKey =
1794
1944
  frame.kind === "ssh.key.ensure"
@@ -1831,6 +1981,30 @@ async function connect(): Promise<void> {
1831
1981
  case "env.var.list":
1832
1982
  case "env.var.set":
1833
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
+ }
1834
2008
  try {
1835
2009
  if (frame.kind === "env.var.set") {
1836
2010
  setProjectEnvVar(frame.projectId, frame.key, frame.value, frame.orgId);
@@ -3260,6 +3434,39 @@ function rejectFrameDuringShutdown(
3260
3434
  }
3261
3435
  }
3262
3436
 
3437
+ /**
3438
+ * ADR-136 sign-in box: the runner behind the "Uai Cloud" row runs no task.
3439
+ * Task commands and tunnel opens get a definite, non-retryable refusal —
3440
+ * nothing here will ever accept them — while the managed lifecycle, engine
3441
+ * sign-ins and account management pass through untouched.
3442
+ */
3443
+ function rejectFrameOnSignInBox(socket: WebSocket, frame: CloudToHost): boolean {
3444
+ switch (frame.kind) {
3445
+ case "command":
3446
+ if (signInBoxAllowsCommand(frame.command)) return false;
3447
+ send(socket, {
3448
+ kind: "result",
3449
+ commandId: frame.commandId,
3450
+ result: {
3451
+ ok: false,
3452
+ code: HostErrorCode.HostUnavailable,
3453
+ message: SIGN_IN_BOX_TASK_REFUSAL,
3454
+ retryable: false,
3455
+ },
3456
+ });
3457
+ return true;
3458
+ case "tunnel.open":
3459
+ send(socket, {
3460
+ kind: "tunnel.close",
3461
+ tunnelId: frame.tunnelId,
3462
+ reason: SIGN_IN_BOX_TASK_REFUSAL,
3463
+ });
3464
+ return true;
3465
+ default:
3466
+ return false;
3467
+ }
3468
+ }
3469
+
3263
3470
  function rawDataToBuffer(data: RawData): Buffer {
3264
3471
  if (Buffer.isBuffer(data)) return data;
3265
3472
  if (data instanceof ArrayBuffer) return Buffer.from(data);
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,