@runuai/host 0.9.137 → 0.9.139

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.
@@ -699,6 +699,10 @@ register({
699
699
  existsSync(
700
700
  join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".codex", "auth.json"),
701
701
  ),
702
- create: async ({ taskId, agent, environment, systemPreamble, agentEnv }) =>
703
- new CodexSession({ taskId, agent, environment, systemPreamble, agentEnv }),
702
+ // Forward `cliConfigOverrides` too: the Uai Managed Engine's gateway
703
+ // provider block rides it, and this destructuring silently dropped it on
704
+ // the first live gpt-5-mini smoke (2026-09-12, host 0.9.136 — codex still
705
+ // dialed api.openai.com).
706
+ create: async ({ taskId, agent, environment, systemPreamble, agentEnv, cliConfigOverrides }) =>
707
+ new CodexSession({ taskId, agent, environment, systemPreamble, agentEnv, cliConfigOverrides }),
704
708
  });
@@ -61,6 +61,13 @@ export interface CredentialStore {
61
61
  * with Overwrite cannot carry tags). Throws on failure — including when
62
62
  * the identity lacks `ssm:AddTagsToResource`, which older host policies do. */
63
63
  tag(name: string, tags: Record<string, string>): Promise<void>;
64
+ /** The tags on one parameter. Metadata only: `ssm:ListTagsForResource`
65
+ * reveals no value and needs no KMS grant — the sign-in box (ADR-136)
66
+ * rebuilds an account's label from here without ever reading the secret. */
67
+ listTags(name: string): Promise<Record<string, string>>;
68
+ /** Every parameter NAME under `path` (recursive), no values — the
69
+ * `describe-parameters` listing that is allowed on `*` and never decrypts. */
70
+ describeNames(path: string): Promise<string[]>;
64
71
  /** Names and versions under `path` (recursive), NO decryption — the cheap
65
72
  * poll a Uai-operated host runs to notice a broker refresh. */
66
73
  listVersions(path: string): Promise<Array<{ name: string; version: number }>>;
@@ -284,7 +291,7 @@ export class AwsSsmCredentialStore implements CredentialStore {
284
291
  return out;
285
292
  }
286
293
 
287
- async listByName(path: string): Promise<StoredParameter[]> {
294
+ async describeNames(path: string): Promise<string[]> {
288
295
  if (!isParameterName(path)) throw new Error(`invalid parameter path: ${path}`);
289
296
  const described = await this.run([
290
297
  "ssm",
@@ -296,9 +303,34 @@ export class AwsSsmCredentialStore implements CredentialStore {
296
303
  ]);
297
304
  if (described.status !== 0) throw failure("describe-parameters", described);
298
305
  const parsed: unknown = JSON.parse(described.stdout || "[]");
299
- const names = (Array.isArray(parsed) ? parsed : []).filter(
306
+ return (Array.isArray(parsed) ? parsed : []).filter(
300
307
  (name): name is string => typeof name === "string" && name.startsWith(`${path}/`),
301
308
  );
309
+ }
310
+
311
+ async listTags(name: string): Promise<Record<string, string>> {
312
+ if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
313
+ const result = await this.run([
314
+ "ssm",
315
+ "list-tags-for-resource",
316
+ "--resource-type",
317
+ "Parameter",
318
+ "--resource-id",
319
+ name,
320
+ ]);
321
+ if (result.status !== 0) throw failure("list-tags-for-resource", result);
322
+ const parsed = JSON.parse(result.stdout || "{}") as {
323
+ TagList?: Array<{ Key?: unknown; Value?: unknown }> | null;
324
+ };
325
+ const out: Record<string, string> = {};
326
+ for (const tag of parsed.TagList ?? []) {
327
+ if (typeof tag.Key === "string" && typeof tag.Value === "string") out[tag.Key] = tag.Value;
328
+ }
329
+ return out;
330
+ }
331
+
332
+ async listByName(path: string): Promise<StoredParameter[]> {
333
+ const names = await this.describeNames(path);
302
334
  const out: StoredParameter[] = [];
303
335
  for (const name of names) {
304
336
  const result = await this.run(["ssm", "get-parameter", "--name", name, "--with-decryption"]);
@@ -352,6 +352,19 @@ function resolveExtras(
352
352
  execEnv: { [d.selectVar]: containerDir },
353
353
  configDir: { hostDir: row.configDir, containerDir },
354
354
  });
355
+ } else if (row.authKind === "config-dir" && isStoreHeldRow(row)) {
356
+ // ADR-136 sign-in box: the material lives in the store, not here. The
357
+ // row is listed (label, scope — what the capabilities frame carries)
358
+ // and never provisioned: the box runs no task.
359
+ out.push({
360
+ id: row.id,
361
+ kind,
362
+ label: row.label,
363
+ isDefault: false,
364
+ authKind: "config-dir",
365
+ scope,
366
+ execEnv: {},
367
+ });
355
368
  }
356
369
  }
357
370
  return out;
@@ -822,14 +835,22 @@ export interface AddAccountResult {
822
835
  * Claude accepts a `setup-token` token or an `sk-ant-…` key; Codex accepts an
823
836
  * OpenAI API key (written into an isolated `~/.codex-acct-<id>/auth.json`).
824
837
  */
825
- export function addEngineAccount(
838
+ /** What a labeled account will hold once persisted — the validated shape
839
+ * shared by the local writer (owned and shared hosts) and the store-only
840
+ * writer (the ADR-136 sign-in box), so both refuse the same inputs with the
841
+ * same words. Never logged. */
842
+ type LabeledAccountPlan =
843
+ | { authKind: "env"; label: string; env: Record<string, string>; message: string }
844
+ | { authKind: "config-dir"; label: string; files: Record<string, string>; message: string };
845
+
846
+ type PlannedAccount = { ok: false; message: string } | { ok: true; plan: LabeledAccountPlan };
847
+
848
+ function planLabeledAccount(
826
849
  kind: string,
827
850
  label: string,
828
851
  creds: { apiKey?: string; token?: string },
829
- seams: Partial<AccountSeams> = {},
830
- scope: string = HOST_ACCOUNT_SCOPE,
831
- ): AddAccountResult {
832
- const s = withDefaults(seams);
852
+ scope: string,
853
+ ): PlannedAccount {
833
854
  if (!isEngineAccountScope(scope)) {
834
855
  return { ok: false, message: "The account scope is invalid." };
835
856
  }
@@ -840,72 +861,106 @@ export function addEngineAccount(
840
861
  if (/\s/.test(secret) || secret.length < 12) {
841
862
  return { ok: false, message: "That doesn't look like a token. Paste just the value." };
842
863
  }
843
-
844
- const id = newId();
845
- const now = Date.now();
846
-
847
864
  if (kind === "claude") {
848
865
  const envVar = secret.startsWith("sk-ant-api")
849
866
  ? "ANTHROPIC_API_KEY"
850
867
  : "CLAUDE_CODE_OAUTH_TOKEN";
851
- getDb()
852
- .insert(schema.engineAccounts)
853
- .values({
854
- id,
855
- kind,
856
- label: trimmedLabel,
868
+ return {
869
+ ok: true,
870
+ plan: {
857
871
  authKind: "env",
858
- secretEnc: packSecret(JSON.stringify({ [envVar]: secret })),
859
- configDir: null,
860
- createdAt: now,
861
- scope,
862
- position: nextPosition(kind, scope),
863
- })
864
- .run();
865
- mirrorEngineAccount(id);
866
- return { ok: true, message: `Added Claude account "${trimmedLabel}".`, id };
872
+ label: trimmedLabel,
873
+ env: { [envVar]: secret },
874
+ message: `Added Claude account "${trimmedLabel}".`,
875
+ },
876
+ };
867
877
  }
868
-
869
878
  const envKeyVar = API_KEY_ENV_VARS[kind];
870
879
  if (envKeyVar) {
871
- getDb()
872
- .insert(schema.engineAccounts)
873
- .values({
874
- id,
875
- kind,
876
- label: trimmedLabel,
880
+ return {
881
+ ok: true,
882
+ plan: {
877
883
  authKind: "env",
878
- secretEnc: packSecret(JSON.stringify({ [envKeyVar]: secret })),
879
- configDir: null,
880
- createdAt: now,
881
- scope,
882
- position: nextPosition(kind, scope),
883
- })
884
- .run();
885
- mirrorEngineAccount(id);
884
+ label: trimmedLabel,
885
+ env: { [envKeyVar]: secret },
886
+ message: `Added ${API_KEY_ENGINE_LABELS[kind] ?? kind} account "${trimmedLabel}".`,
887
+ },
888
+ };
889
+ }
890
+ if (kind === "codex") {
886
891
  return {
887
892
  ok: true,
888
- message: `Added ${API_KEY_ENGINE_LABELS[kind] ?? kind} account "${trimmedLabel}".`,
889
- id,
893
+ plan: {
894
+ authKind: "config-dir",
895
+ label: trimmedLabel,
896
+ files: { "auth.json": `${JSON.stringify({ OPENAI_API_KEY: secret }, null, 2)}\n` },
897
+ message: `Added Codex account "${trimmedLabel}".`,
898
+ },
890
899
  };
891
900
  }
901
+ return {
902
+ ok: false,
903
+ message: `Adding a second ${kind} account isn't supported yet — connect the primary account from the Engines panel.`,
904
+ };
905
+ }
892
906
 
893
- if (kind === "codex") {
894
- const d = KINDS.codex as KindDescriptor;
895
- const hostDir = (d.extraHostDir as (o: string, i: string) => string)(
896
- s.ownerHome(),
897
- id,
898
- );
907
+ function planCapturedConfigDirAccount(
908
+ kind: "codex" | "grok",
909
+ label: string,
910
+ authJson: string,
911
+ scope: string,
912
+ ): PlannedAccount {
913
+ const engineLabel = kind === "codex" ? "Codex" : "Grok";
914
+ if (!isEngineAccountScope(scope)) {
915
+ return { ok: false, message: "The account scope is invalid." };
916
+ }
917
+ const trimmedLabel = label.trim();
918
+ if (!trimmedLabel) return { ok: false, message: "Give the account a label." };
919
+ try {
920
+ const parsed: unknown = JSON.parse(authJson);
921
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
922
+ throw new Error("not an object");
923
+ }
924
+ } catch {
925
+ return { ok: false, message: `The captured ${engineLabel} credential is not valid.` };
926
+ }
927
+ return {
928
+ ok: true,
929
+ plan: {
930
+ authKind: "config-dir",
931
+ label: trimmedLabel,
932
+ files: { "auth.json": authJson.endsWith("\n") ? authJson : `${authJson}\n` },
933
+ message: `Added ${engineLabel} account "${trimmedLabel}".`,
934
+ },
935
+ };
936
+ }
937
+
938
+ /** Persist a plan on THIS host — a sealed env row or an isolated config dir
939
+ * under the owner's home — and mirror it to the store when one is configured. */
940
+ function persistPlannedAccountLocally(
941
+ kind: string,
942
+ scope: string,
943
+ plan: LabeledAccountPlan,
944
+ s: AccountSeams,
945
+ ): AddAccountResult {
946
+ const id = newId();
947
+ let configDir: string | null = null;
948
+ if (plan.authKind === "config-dir") {
949
+ const d = KINDS[kind];
950
+ if (!d?.extraHostDir) {
951
+ return { ok: false, message: `${kind} has no config-dir layout.` };
952
+ }
953
+ const hostDir = d.extraHostDir(s.ownerHome(), id);
899
954
  try {
900
- const file = join(hostDir, "auth.json");
901
955
  mkdirSync(hostDir, { recursive: true });
902
- writeFileSync(file, `${JSON.stringify({ OPENAI_API_KEY: secret }, null, 2)}\n`, {
903
- mode: 0o600,
904
- });
905
- try {
906
- chmodSync(file, 0o600);
907
- } catch {
908
- /* best effort */
956
+ for (const [basename, content] of Object.entries(plan.files)) {
957
+ const file = join(hostDir, basename);
958
+ writeFileSync(file, content, { mode: 0o600 });
959
+ try {
960
+ chmodSync(file, 0o600);
961
+ } catch {
962
+ /* best effort */
963
+ }
909
964
  }
910
965
  } catch (err) {
911
966
  return {
@@ -915,28 +970,36 @@ export function addEngineAccount(
915
970
  }`,
916
971
  };
917
972
  }
918
- getDb()
919
- .insert(schema.engineAccounts)
920
- .values({
921
- id,
922
- kind,
923
- label: trimmedLabel,
924
- authKind: "config-dir",
925
- secretEnc: null,
926
- configDir: hostDir,
927
- createdAt: now,
928
- scope,
929
- position: nextPosition(kind, scope),
930
- })
931
- .run();
932
- mirrorEngineAccount(id);
933
- return { ok: true, message: `Added Codex account "${trimmedLabel}".`, id };
973
+ configDir = hostDir;
934
974
  }
975
+ getDb()
976
+ .insert(schema.engineAccounts)
977
+ .values({
978
+ id,
979
+ kind,
980
+ label: plan.label,
981
+ authKind: plan.authKind,
982
+ secretEnc: plan.authKind === "env" ? packSecret(JSON.stringify(plan.env)) : null,
983
+ configDir,
984
+ createdAt: Date.now(),
985
+ scope,
986
+ position: nextPosition(kind, scope),
987
+ })
988
+ .run();
989
+ mirrorEngineAccount(id);
990
+ return { ok: true, message: plan.message, id };
991
+ }
935
992
 
936
- return {
937
- ok: false,
938
- message: `Adding a second ${kind} account isn't supported yet — connect the primary account from the Engines panel.`,
939
- };
993
+ export function addEngineAccount(
994
+ kind: string,
995
+ label: string,
996
+ creds: { apiKey?: string; token?: string },
997
+ seams: Partial<AccountSeams> = {},
998
+ scope: string = HOST_ACCOUNT_SCOPE,
999
+ ): AddAccountResult {
1000
+ const planned = planLabeledAccount(kind, label, creds, scope);
1001
+ if (!planned.ok) return planned;
1002
+ return persistPlannedAccountLocally(kind, scope, planned.plan, withDefaults(seams));
940
1003
  }
941
1004
 
942
1005
  /**
@@ -972,63 +1035,9 @@ function addConfigDirAccountFromAuthJson(
972
1035
  seams: Partial<AccountSeams>,
973
1036
  scope: string,
974
1037
  ): AddAccountResult {
975
- const engineLabel = kind === "codex" ? "Codex" : "Grok";
976
- if (!isEngineAccountScope(scope)) {
977
- return { ok: false, message: "The account scope is invalid." };
978
- }
979
- const s = withDefaults(seams);
980
- const trimmedLabel = label.trim();
981
- if (!trimmedLabel) return { ok: false, message: "Give the account a label." };
982
- try {
983
- const parsed: unknown = JSON.parse(authJson);
984
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
985
- throw new Error("not an object");
986
- }
987
- } catch {
988
- return { ok: false, message: `The captured ${engineLabel} credential is not valid.` };
989
- }
990
-
991
- const id = newId();
992
- const d = KINDS[kind] as KindDescriptor;
993
- const hostDir = (d.extraHostDir as (o: string, i: string) => string)(
994
- s.ownerHome(),
995
- id,
996
- );
997
- try {
998
- const file = join(hostDir, "auth.json");
999
- mkdirSync(hostDir, { recursive: true });
1000
- writeFileSync(file, authJson.endsWith("\n") ? authJson : `${authJson}\n`, {
1001
- mode: 0o600,
1002
- });
1003
- try {
1004
- chmodSync(file, 0o600);
1005
- } catch {
1006
- /* best effort */
1007
- }
1008
- } catch (err) {
1009
- return {
1010
- ok: false,
1011
- message: `Could not write the account config: ${
1012
- err instanceof Error ? err.message : String(err)
1013
- }`,
1014
- };
1015
- }
1016
- getDb()
1017
- .insert(schema.engineAccounts)
1018
- .values({
1019
- id,
1020
- kind,
1021
- label: trimmedLabel,
1022
- authKind: "config-dir",
1023
- secretEnc: null,
1024
- configDir: hostDir,
1025
- createdAt: Date.now(),
1026
- scope,
1027
- position: nextPosition(kind, scope),
1028
- })
1029
- .run();
1030
- mirrorEngineAccount(id);
1031
- return { ok: true, message: `Added ${engineLabel} account "${trimmedLabel}".`, id };
1038
+ const planned = planCapturedConfigDirAccount(kind, label, authJson, scope);
1039
+ if (!planned.ok) return planned;
1040
+ return persistPlannedAccountLocally(kind, scope, planned.plan, withDefaults(seams));
1032
1041
  }
1033
1042
 
1034
1043
  /** Remove an EXTRA account (idempotent). The synthesized default can't be
@@ -1346,6 +1355,11 @@ function setStoreRef(id: string, storeRef: string): void {
1346
1355
  }
1347
1356
 
1348
1357
  async function putRow(store: CredentialStore, row: StoreRow, hostId: string | null): Promise<boolean> {
1358
+ if (isStoreHeldRow(row)) {
1359
+ // ADR-136: nothing here to mirror — an empty env put would overwrite the
1360
+ // only copy of the credential.
1361
+ throw new Error(`account ${row.id} is store-held; this runner holds no material to mirror`);
1362
+ }
1349
1363
  const name = engineAccountParameterName(row, hostId);
1350
1364
  if (!name) {
1351
1365
  if (!warnedNoHostId) {
@@ -1368,6 +1382,13 @@ export function mirrorEngineAccount(id: string): void {
1368
1382
  queueStoreWrite(`mirror ${id}`, async (store) => {
1369
1383
  const row = selectRow(id);
1370
1384
  if (!row) return;
1385
+ if (isStoreHeldRow(row)) {
1386
+ // ADR-136 sign-in box: a rename or reorder rides as metadata tags —
1387
+ // the secret is not here to re-put, and tags are what a replacement
1388
+ // box reads back.
1389
+ await store.tag(row.storeRef!, engineAccountMetadataTags(row));
1390
+ return;
1391
+ }
1371
1392
  await putRow(store, row, storeHostId);
1372
1393
  });
1373
1394
  }
@@ -1565,4 +1586,288 @@ export function applyStoredEngineAccount(
1565
1586
  throw new Error("stored auth kind does not match the local row");
1566
1587
  }
1567
1588
  return { kind: stored.kind, id: stored.id, imported: false };
1568
- }
1589
+ }
1590
+
1591
+ // ---------------------------------------------------------------------------
1592
+ // Store-only accounts (ADR-136 sign-in box, docs/shared-host-retirement.md).
1593
+ //
1594
+ // The small always-on machine behind the "Uai Cloud" row writes every capture
1595
+ // STRAIGHT into the credential store and keeps only a metadata row — id, kind,
1596
+ // label, scope, position, store_ref — with no sealed secret and no config dir.
1597
+ // Its store identity is write-only (put, tag, delete, describe names; never
1598
+ // get, never decrypt), so a compromised box can break sign-ins but cannot read
1599
+ // one. Labels therefore also ride as parameter TAGS, which
1600
+ // `ssm:ListTagsForResource` returns without a KMS grant: a replacement box
1601
+ // rebuilds its list from names + tags alone (`reconcileEngineAccountMetadataFromStore`).
1602
+ // ---------------------------------------------------------------------------
1603
+
1604
+ export const ACCOUNT_LABEL_TAG = "uai_label_b64";
1605
+ export const ACCOUNT_POSITION_TAG = "uai_position";
1606
+ export const ACCOUNT_CREATED_AT_TAG = "uai_created_at";
1607
+ export const ACCOUNT_AUTH_KIND_TAG = "uai_auth_kind";
1608
+ /** SSM tag values: 256 chars of `[A-Za-z0-9_.:/=+@ -]`; base64url fits. */
1609
+ const MAX_TAG_VALUE_CHARS = 256;
1610
+
1611
+ /** A row whose material lives ONLY in the store: a store_ref and no secret. */
1612
+ export function isStoreHeldRow(row: {
1613
+ authKind: string;
1614
+ secretEnc: string | null;
1615
+ configDir: string | null;
1616
+ storeRef?: string | null;
1617
+ }): boolean {
1618
+ if (!row.storeRef) return false;
1619
+ return row.authKind === "env" ? row.secretEnc === null : row.configDir === null;
1620
+ }
1621
+
1622
+ /** The secret-free metadata a parameter carries as tags. The label is
1623
+ * base64url so any label character survives SSM's tag charset; a label too
1624
+ * long to fit is shortened (the store value keeps the full one). */
1625
+ export function engineAccountMetadataTags(row: {
1626
+ label: string;
1627
+ authKind: string;
1628
+ position?: number | null;
1629
+ createdAt: number;
1630
+ }): Record<string, string> {
1631
+ let label = row.label;
1632
+ let encoded = Buffer.from(label, "utf8").toString("base64url");
1633
+ while (encoded.length > MAX_TAG_VALUE_CHARS && label.length > 1) {
1634
+ label = label.slice(0, -1);
1635
+ encoded = Buffer.from(label, "utf8").toString("base64url");
1636
+ }
1637
+ return {
1638
+ [ACCOUNT_LABEL_TAG]: encoded,
1639
+ [ACCOUNT_POSITION_TAG]: String(row.position ?? 0),
1640
+ [ACCOUNT_CREATED_AT_TAG]: String(row.createdAt),
1641
+ [ACCOUNT_AUTH_KIND_TAG]: row.authKind,
1642
+ };
1643
+ }
1644
+
1645
+ function derivedAccountLabel(kind: string, id: string): string {
1646
+ const engine = kind.charAt(0).toUpperCase() + kind.slice(1);
1647
+ return `${engine} ${id.slice(-6)}`;
1648
+ }
1649
+
1650
+ function labelFromTags(tags: Record<string, string>, kind: string, id: string): string {
1651
+ const encoded = tags[ACCOUNT_LABEL_TAG];
1652
+ if (encoded) {
1653
+ try {
1654
+ const label = Buffer.from(encoded, "base64url").toString("utf8").trim();
1655
+ if (isEngineAccountLabel(label)) return label;
1656
+ } catch {
1657
+ /* fall through */
1658
+ }
1659
+ }
1660
+ return derivedAccountLabel(kind, id);
1661
+ }
1662
+
1663
+ async function persistPlannedAccountToStore(
1664
+ kind: string,
1665
+ scope: string,
1666
+ plan: LabeledAccountPlan,
1667
+ ): Promise<AddAccountResult> {
1668
+ const store = configuredCredentialStore();
1669
+ if (!store) {
1670
+ return { ok: false, message: "This runner has no credential store to hold the account." };
1671
+ }
1672
+ if (scope === HOST_ACCOUNT_SCOPE) {
1673
+ return { ok: false, message: "Store-only accounts belong to a workspace, not the runner." };
1674
+ }
1675
+ const id = newId();
1676
+ const now = Date.now();
1677
+ const position = nextPosition(kind, scope);
1678
+ const row = { id, kind, label: plan.label, authKind: plan.authKind, scope, position, createdAt: now };
1679
+ const name = engineAccountParameterName(row);
1680
+ if (!name) {
1681
+ return { ok: false, message: "Store-only accounts belong to a workspace, not the runner." };
1682
+ }
1683
+ const stored: StoredEngineAccount = {
1684
+ v: 1,
1685
+ ...row,
1686
+ ...(plan.authKind === "env" ? { env: plan.env } : { files: plan.files }),
1687
+ };
1688
+ try {
1689
+ await store.put(name, JSON.stringify(stored));
1690
+ } catch (err) {
1691
+ console.warn(
1692
+ `[engine-accounts] store-only ${kind}/${id}: put failed (${err instanceof Error ? err.message : String(err)})`,
1693
+ );
1694
+ return {
1695
+ ok: false,
1696
+ message: "The credential store refused the write; nothing was kept on this runner.",
1697
+ };
1698
+ }
1699
+ try {
1700
+ await store.tag(name, engineAccountMetadataTags(row));
1701
+ } catch (err) {
1702
+ console.warn(
1703
+ `[engine-accounts] store-only ${kind}/${id}: tagging failed (${err instanceof Error ? err.message : String(err)}) — a replacement sign-in box lists it as "${derivedAccountLabel(kind, id)}"`,
1704
+ );
1705
+ }
1706
+ getDb()
1707
+ .insert(schema.engineAccounts)
1708
+ .values({
1709
+ id,
1710
+ kind,
1711
+ label: plan.label,
1712
+ authKind: plan.authKind,
1713
+ secretEnc: null,
1714
+ configDir: null,
1715
+ createdAt: now,
1716
+ scope,
1717
+ position,
1718
+ storeRef: name,
1719
+ })
1720
+ .run();
1721
+ return { ok: true, message: plan.message, id };
1722
+ }
1723
+
1724
+ /** `addEngineAccount` for the sign-in box: the same validation, the store
1725
+ * written FIRST and awaited, then a metadata-only row. A store failure is the
1726
+ * caller's failure and leaves nothing behind. */
1727
+ export async function addEngineAccountToStore(
1728
+ kind: string,
1729
+ label: string,
1730
+ creds: { apiKey?: string; token?: string },
1731
+ scope: string,
1732
+ ): Promise<AddAccountResult> {
1733
+ const planned = planLabeledAccount(kind, label, creds, scope);
1734
+ if (!planned.ok) return planned;
1735
+ return persistPlannedAccountToStore(kind, scope, planned.plan);
1736
+ }
1737
+
1738
+ /** `addCodexAccountFromAuthJson` / `addGrokAccountFromAuthJson` for the
1739
+ * sign-in box: the captured auth.json goes to the store, never to a dir. */
1740
+ export async function addCapturedConfigDirAccountToStore(
1741
+ kind: "codex" | "grok",
1742
+ label: string,
1743
+ authJson: string,
1744
+ scope: string,
1745
+ ): Promise<AddAccountResult> {
1746
+ const planned = planCapturedConfigDirAccount(kind, label, authJson, scope);
1747
+ if (!planned.ok) return planned;
1748
+ return persistPlannedAccountToStore(kind, scope, planned.plan);
1749
+ }
1750
+
1751
+ export interface PurgeLocalEngineAccountSecretsResult {
1752
+ /** Workspace rows whose sealed secret or config dir was removed. */
1753
+ stripped: number;
1754
+ /** Rows deleted outright: host-scope accounts and rows never mirrored. */
1755
+ removed: number;
1756
+ errors: number;
1757
+ }
1758
+
1759
+ /**
1760
+ * Sign-in box start: nothing the previous release (or an operator) left on
1761
+ * this machine may hold a credential. Workspace rows keep their metadata and
1762
+ * lose their material; host-scope rows and rows the store never received are
1763
+ * deleted — the box holds workspace accounts only, and a secret with no copy
1764
+ * in the store still may not live here.
1765
+ */
1766
+ export function purgeLocalEngineAccountSecrets(): PurgeLocalEngineAccountSecretsResult {
1767
+ const result: PurgeLocalEngineAccountSecretsResult = { stripped: 0, removed: 0, errors: 0 };
1768
+ const rows = getDb().select().from(schema.engineAccounts).all() as StoreRow[];
1769
+ for (const row of rows) {
1770
+ const scope = row.scope ?? HOST_ACCOUNT_SCOPE;
1771
+ if (scope === HOST_ACCOUNT_SCOPE || !row.storeRef) {
1772
+ removeLocalAccountRow(row);
1773
+ result.removed++;
1774
+ continue;
1775
+ }
1776
+ if (isStoreHeldRow(row)) continue;
1777
+ if (row.configDir) {
1778
+ try {
1779
+ rmSync(row.configDir, { recursive: true, force: true });
1780
+ } catch {
1781
+ result.errors++;
1782
+ }
1783
+ }
1784
+ getDb()
1785
+ .update(schema.engineAccounts)
1786
+ .set({ secretEnc: null, configDir: null })
1787
+ .where(eq(schema.engineAccounts.id, row.id))
1788
+ .run();
1789
+ result.stripped++;
1790
+ }
1791
+ return result;
1792
+ }
1793
+
1794
+ export interface ReconcileEngineAccountMetadataResult {
1795
+ skipped: boolean;
1796
+ imported: number;
1797
+ removed: number;
1798
+ errors: number;
1799
+ }
1800
+
1801
+ /**
1802
+ * Sign-in box start: make the metadata rows agree with the store WITHOUT
1803
+ * reading a value. Names come from `describe-parameters` (allowed on `*`),
1804
+ * labels and order from each new parameter's tags. A workspace parameter this
1805
+ * box lacks becomes a metadata row; a row whose parameter is gone is dropped;
1806
+ * host-scope rows are dropped (the box has none). A failed listing touches
1807
+ * nothing.
1808
+ */
1809
+ export async function reconcileEngineAccountMetadataFromStore(): Promise<ReconcileEngineAccountMetadataResult> {
1810
+ const result: ReconcileEngineAccountMetadataResult = { skipped: false, imported: 0, removed: 0, errors: 0 };
1811
+ const store = configuredCredentialStore();
1812
+ if (!store) return { ...result, skipped: true };
1813
+ const prefix = credentialStorePrefix();
1814
+ const warn = (line: string): void => console.warn(`[engine-accounts] metadata reconcile: ${line}`);
1815
+ let names: string[];
1816
+ try {
1817
+ names = await store.describeNames(`${prefix}/orgs`);
1818
+ } catch (err) {
1819
+ warn(`listing ${prefix}/orgs failed: ${err instanceof Error ? err.message : String(err)}`);
1820
+ return { ...result, errors: 1 };
1821
+ }
1822
+ const rows = getDb().select().from(schema.engineAccounts).all() as StoreRow[];
1823
+ const byRef = new Set(rows.map((r) => r.storeRef).filter((ref): ref is string => Boolean(ref)));
1824
+ const seen = new Set<string>();
1825
+ for (const name of names) {
1826
+ const at = parseEngineAccountParameterName(name, prefix);
1827
+ if (!at || at.scope === HOST_ACCOUNT_SCOPE) continue;
1828
+ seen.add(name);
1829
+ if (byRef.has(name)) continue;
1830
+ let tags: Record<string, string> = {};
1831
+ try {
1832
+ tags = await store.listTags(name);
1833
+ } catch (err) {
1834
+ result.errors++;
1835
+ warn(`tags of ${name} unreadable: ${err instanceof Error ? err.message : String(err)}`);
1836
+ }
1837
+ const taggedAuthKind = tags[ACCOUNT_AUTH_KIND_TAG];
1838
+ const authKind: AccountAuthKind =
1839
+ taggedAuthKind === "env" || taggedAuthKind === "config-dir"
1840
+ ? taggedAuthKind
1841
+ : (KINDS[at.kind]?.authKind ?? "env");
1842
+ const position = Number.parseInt(tags[ACCOUNT_POSITION_TAG] ?? "", 10);
1843
+ const createdAt = Number.parseInt(tags[ACCOUNT_CREATED_AT_TAG] ?? "", 10);
1844
+ try {
1845
+ getDb()
1846
+ .insert(schema.engineAccounts)
1847
+ .values({
1848
+ id: at.id,
1849
+ kind: at.kind,
1850
+ label: labelFromTags(tags, at.kind, at.id),
1851
+ authKind,
1852
+ secretEnc: null,
1853
+ configDir: null,
1854
+ scope: at.scope,
1855
+ position: Number.isFinite(position) ? position : 0,
1856
+ storeRef: name,
1857
+ createdAt: Number.isFinite(createdAt) ? createdAt : Date.now(),
1858
+ })
1859
+ .run();
1860
+ result.imported++;
1861
+ } catch (err) {
1862
+ result.errors++;
1863
+ warn(`importing ${name} failed: ${err instanceof Error ? err.message : String(err)}`);
1864
+ }
1865
+ }
1866
+ for (const row of rows) {
1867
+ const scope = row.scope ?? HOST_ACCOUNT_SCOPE;
1868
+ if (scope !== HOST_ACCOUNT_SCOPE && row.storeRef && seen.has(row.storeRef)) continue;
1869
+ removeLocalAccountRow(row);
1870
+ result.removed++;
1871
+ }
1872
+ return result;
1873
+ }
@@ -27,6 +27,12 @@ const MAX_CONCURRENT_CONNECTS = 1;
27
27
 
28
28
  export type EmitEngineConnectEvent = (frame: EngineConnectEventFrame) => void;
29
29
 
30
+ export interface AddAccountOutcome {
31
+ ok: boolean;
32
+ message: string;
33
+ id?: string;
34
+ }
35
+
30
36
  export interface EngineConnectManagerOptions {
31
37
  /** Re-advertise capabilities after a successful mutation (main.ts). */
32
38
  readvertise(): void;
@@ -34,6 +40,17 @@ export interface EngineConnectManagerOptions {
34
40
  refreshEngineAccountAgents(kind: string): Promise<void>;
35
41
  /** Best-effort post-connect image maintenance (optional engines bake in). */
36
42
  ensureStandardImage(): Promise<unknown>;
43
+ /** ADR-136 sign-in box: only labeled, workspace-scoped connects are
44
+ * admitted — nothing here may write the host's default slot. */
45
+ workspaceOnly?: boolean;
46
+ /** The labeled-account sink. Defaults to the local `addEngineAccount`; the
47
+ * sign-in box supplies the store-first one and awaits its write. */
48
+ addLabeledAccount?: (
49
+ kind: EngineConnectKind,
50
+ label: string,
51
+ secret: string,
52
+ scope: string,
53
+ ) => AddAccountOutcome | Promise<AddAccountOutcome>;
37
54
  timers?: {
38
55
  setTimeout(handler: () => void, ms: number): NodeJS.Timeout;
39
56
  clearTimeout(timer: NodeJS.Timeout): void;
@@ -156,15 +173,26 @@ export class EngineConnectManager {
156
173
  return;
157
174
  }
158
175
 
176
+ // ADR-136 sign-in box: a workspace account or nothing.
177
+ if (
178
+ this.options.workspaceOnly &&
179
+ (label === undefined || scope === undefined || scope === HOST_ACCOUNT_SCOPE)
180
+ ) {
181
+ this.finish(
182
+ operation,
183
+ false,
184
+ "The Uai Cloud sign-in box connects workspace accounts only.",
185
+ );
186
+ return;
187
+ }
159
188
  // ADR-116 labeled extra account — the sink validates kind support.
160
189
  if (label !== undefined && secret !== undefined) {
161
- const added = addEngineAccount(
162
- engine,
163
- label,
164
- { token: secret },
165
- {},
166
- scope ?? HOST_ACCOUNT_SCOPE,
167
- );
190
+ const sink =
191
+ this.options.addLabeledAccount ??
192
+ ((kind, accountLabel, value, accountScope) =>
193
+ addEngineAccount(kind, accountLabel, { token: value }, {}, accountScope));
194
+ const added = await sink(engine, label, secret, scope ?? HOST_ACCOUNT_SCOPE);
195
+ if (operation.done) return;
168
196
  if (added.ok) this.afterMutation(engine, false);
169
197
  this.finish(operation, added.ok, added.message, added.id);
170
198
  return;
@@ -136,6 +136,12 @@ export interface EngineLoginTimers {
136
136
  clearTimeout(timer: unknown): void;
137
137
  }
138
138
 
139
+ export interface AccountAddOutcome {
140
+ ok: boolean;
141
+ message: string;
142
+ id?: string;
143
+ }
144
+
139
145
  export interface EngineLoginSeams {
140
146
  createTempDir(): Promise<string>;
141
147
  removeTempDir(path: string): Promise<void>;
@@ -158,22 +164,24 @@ export interface EngineLoginSeams {
158
164
  readTranscript(tempDir: string): Promise<string | null>;
159
165
  persistClaudeToken(token: string): Promise<void>;
160
166
  persistCodexAuth(sourceFile: string): Promise<void>;
161
- /** ADR-116: labeled extra-account captures (never the default slots). */
167
+ /** ADR-116: labeled extra-account captures (never the default slots).
168
+ * Sync on a host that seals the capture locally; a promise on the ADR-136
169
+ * sign-in box, whose sink awaits the credential store's write. */
162
170
  addClaudeAccount(
163
171
  label: string,
164
172
  token: string,
165
173
  scope: string,
166
- ): { ok: boolean; message: string; id?: string };
174
+ ): AccountAddOutcome | Promise<AccountAddOutcome>;
167
175
  addCodexAccount(
168
176
  label: string,
169
177
  authJson: string,
170
178
  scope: string,
171
- ): { ok: boolean; message: string; id?: string };
179
+ ): AccountAddOutcome | Promise<AccountAddOutcome>;
172
180
  addGrokAccount(
173
181
  label: string,
174
182
  authJson: string,
175
183
  scope: string,
176
- ): { ok: boolean; message: string; id?: string };
184
+ ): AccountAddOutcome | Promise<AccountAddOutcome>;
177
185
  persistGrokAuth(sourceFile: string): Promise<void>;
178
186
  /** Optional engines ride the standard image only when configured; a fresh
179
187
  * account may be the first configuration → rebuild (queue-deduped). */
@@ -197,6 +205,9 @@ export interface EngineLoginManagerOptions {
197
205
  tempRoot?: string;
198
206
  /** Defer Docker inventory until the first start when no runtime is selected. */
199
207
  reconcileOnCreate?: boolean;
208
+ /** ADR-136 sign-in box: only labeled, workspace-scoped sign-ins are
209
+ * admitted — the default slots are the host owner's and the box has none. */
210
+ workspaceOnly?: boolean;
200
211
  seams?: Partial<EngineLoginSeams>;
201
212
  /** Rebuild the standard image after a successful capture (optional engines). */
202
213
  ensureStandardImage?: () => Promise<unknown>;
@@ -389,12 +400,14 @@ function withDefaults(options: EngineLoginManagerOptions): EngineLoginSeams {
389
400
  */
390
401
  export class EngineLoginManager {
391
402
  private readonly seams: EngineLoginSeams;
403
+ private readonly workspaceOnly: boolean;
392
404
  private readonly operations = new Map<string, LoginOperation>();
393
405
  private readonly engineOperations = new Map<EngineLoginKind, LoginOperation>();
394
406
  private reconciliation: Promise<boolean>;
395
407
 
396
408
  constructor(options: EngineLoginManagerOptions) {
397
409
  this.seams = withDefaults(options);
410
+ this.workspaceOnly = options.workspaceOnly === true;
398
411
  this.reconciliation =
399
412
  options.reconcileOnCreate === false
400
413
  ? Promise.resolve(false)
@@ -449,6 +462,23 @@ export class EngineLoginManager {
449
462
  );
450
463
  return false;
451
464
  }
465
+ // ADR-136 sign-in box: nothing here may write a default slot, and a
466
+ // host-scope account would belong to a runner that owns no credential.
467
+ if (
468
+ this.workspaceOnly &&
469
+ (scope === undefined || scope === HOST_ACCOUNT_SCOPE || label === undefined)
470
+ ) {
471
+ safeEmit(
472
+ emit,
473
+ failedEvent(
474
+ opId,
475
+ engine,
476
+ "start_failed",
477
+ "The Uai Cloud sign-in box signs in workspace accounts only.",
478
+ ),
479
+ );
480
+ return false;
481
+ }
452
482
  // A workspace's sign-in is always a labeled account: the default slot is
453
483
  // the host owner's and must never be written on a tenant's behalf.
454
484
  if (scope !== undefined && scope !== HOST_ACCOUNT_SCOPE && label === undefined) {
@@ -1043,7 +1073,7 @@ export class EngineLoginManager {
1043
1073
  // ADR-116: labeled capture — an extra account, not the default slot.
1044
1074
  // Extras are picked up at the next agent spawn / rotation, so no
1045
1075
  // running-agent refresh is needed (mirrors the local accounts API).
1046
- const added = this.seams.addClaudeAccount(
1076
+ const added = await this.seams.addClaudeAccount(
1047
1077
  operation.accountLabel,
1048
1078
  token,
1049
1079
  operation.accountScope,
@@ -1118,10 +1148,10 @@ export class EngineLoginManager {
1118
1148
  if (operation.accountLabel !== null) {
1119
1149
  // ADR-116: labeled capture — the auth.json this login produced becomes
1120
1150
  // an isolated extra account instead of overwriting ~/.codex.
1121
- let added: { ok: boolean; message: string; id?: string };
1151
+ let added: AccountAddOutcome;
1122
1152
  try {
1123
1153
  const contents = await readFile(authFile, "utf8");
1124
- added = grok
1154
+ added = await (grok
1125
1155
  ? this.seams.addGrokAccount(
1126
1156
  operation.accountLabel,
1127
1157
  contents,
@@ -1131,7 +1161,7 @@ export class EngineLoginManager {
1131
1161
  operation.accountLabel,
1132
1162
  contents,
1133
1163
  operation.accountScope,
1134
- );
1164
+ ));
1135
1165
  } catch {
1136
1166
  added = {
1137
1167
  ok: false,
package/lib/env-file.ts CHANGED
@@ -79,6 +79,36 @@ export function writeEnvValuesAtomic(
79
79
  replaceEnvFileAtomic(file, `${kept.join("\n")}\n`);
80
80
  }
81
81
 
82
+ /**
83
+ * Atomically drop `keys` from a dotenv file (a missing file or key is fine).
84
+ * The sign-in box (ADR-136) strips engine credentials from `.env.local` at
85
+ * start so no default-slot secret survives on that machine.
86
+ */
87
+ export function removeEnvValuesAtomic(
88
+ file: string,
89
+ keys: readonly string[],
90
+ ): string[] {
91
+ if (keys.length === 0 || !existsSync(file)) return [];
92
+ for (const key of keys) {
93
+ if (!ENV_KEY.test(key)) throw new Error(`invalid environment key: ${key}`);
94
+ }
95
+ const drop = new Set(keys);
96
+ const removed: string[] = [];
97
+ const current = readFileSync(file, "utf8");
98
+ const kept = current.split(/\r?\n/).filter((line, index, lines) => {
99
+ if (index === lines.length - 1 && line === "") return false;
100
+ const equals = line.indexOf("=");
101
+ if (equals <= 0) return true;
102
+ const key = line.slice(0, equals);
103
+ if (!drop.has(key)) return true;
104
+ removed.push(key);
105
+ return false;
106
+ });
107
+ if (removed.length === 0) return [];
108
+ replaceEnvFileAtomic(file, kept.length === 0 ? "" : `${kept.join("\n")}\n`);
109
+ return [...new Set(removed)];
110
+ }
111
+
82
112
  /** Capture the exact bytes needed to roll back a multi-store config write. */
83
113
  export function readEnvFileSnapshot(file: string): EnvFileSnapshot {
84
114
  return existsSync(file)
@@ -0,0 +1,114 @@
1
+ /**
2
+ * ADR-136 sign-in box: the small always-on machine behind the "Uai Cloud"
3
+ * runner row (docs/shared-host-retirement.md). Selected by
4
+ * `uai-host run --signin-box` (env `UAI_SIGNIN_BOX=1`), which cli.ts sets
5
+ * before main.ts loads. Same package as every runner, one flag.
6
+ *
7
+ * The box has exactly one job — run engine sign-ins and connects for
8
+ * workspaces and write the result into the credential store — and holds no
9
+ * credential at rest: captures go to the store first and only a metadata row
10
+ * stays (lib/engine-accounts.ts, store-only accounts), nothing is imported
11
+ * from the store at start, the default engine slots are scrubbed on every
12
+ * start, and its store identity is write-only. Everything else a runner does
13
+ * is refused here: task commands and tunnels get a definite non-retryable
14
+ * answer, the local UI and the pickup/broker loops never start.
15
+ */
16
+
17
+ import { existsSync, readdirSync, rmSync, type Dirent } from "node:fs";
18
+ import { join } from "node:path";
19
+
20
+ import { removeEnvValuesAtomic } from "./env-file";
21
+
22
+ export function signInBoxMode(): boolean {
23
+ return process.env.UAI_SIGNIN_BOX === "1";
24
+ }
25
+
26
+ export const SIGN_IN_BOX_TASK_REFUSAL =
27
+ "This runner is the Uai Cloud sign-in box; tasks run on their own machines.";
28
+
29
+ /** Host commands the box still serves: its own managed lifecycle and the
30
+ * account management the engines pane drives. Every task-shaped command
31
+ * (taskUp, channels, files, previews, attachments) is refused. */
32
+ const ALLOWED_COMMANDS: ReadonlySet<string> = new Set([
33
+ "hostUpdate",
34
+ "hostRestart",
35
+ "engineAccountRemove",
36
+ "engineAccountRename",
37
+ "engineAccountReorder",
38
+ "engineDisconnect",
39
+ ]);
40
+
41
+ export function signInBoxAllowsCommand(command: string): boolean {
42
+ return ALLOWED_COMMANDS.has(command);
43
+ }
44
+
45
+ /** The env keys the adapters read as the host's OWN engine credential
46
+ * (lib/engines.ts `detect`). None may survive on the box. */
47
+ export const ENGINE_CREDENTIAL_ENV_KEYS = [
48
+ "CLAUDE_CODE_OAUTH_TOKEN",
49
+ "ANTHROPIC_API_KEY",
50
+ "ANTHROPIC_AUTH_TOKEN",
51
+ "OPENAI_API_KEY",
52
+ "XAI_API_KEY",
53
+ "CURSOR_API_KEY",
54
+ ] as const;
55
+
56
+ /** Default-slot credential files under the owner's home (engine-accounts
57
+ * KINDS `defaultHostDir`), and the isolated per-account dirs
58
+ * (`extraHostDir`) — swept as strays in case no row references them. */
59
+ const DEFAULT_SLOT_FILES: readonly (readonly string[])[] = [
60
+ [".codex", "auth.json"],
61
+ [".grok", "auth.json"],
62
+ [".local", "share", "opencode", "auth.json"],
63
+ ];
64
+ const ACCOUNT_DIR_PREFIXES: readonly { parent: readonly string[]; prefix: string }[] = [
65
+ { parent: [], prefix: ".codex-acct-" },
66
+ { parent: [], prefix: ".grok-acct-" },
67
+ { parent: [".local", "share"], prefix: "opencode-acct-" },
68
+ ];
69
+
70
+ export interface PurgeDefaultEngineSlotsResult {
71
+ files: string[];
72
+ dirs: string[];
73
+ envKeys: string[];
74
+ }
75
+
76
+ /**
77
+ * Remove every host-level engine credential from this machine: the default
78
+ * slot files, stray isolated account dirs, and the credential keys in
79
+ * `.env.local` plus the live process env. Idempotent; a fresh box removes
80
+ * nothing.
81
+ */
82
+ export function purgeDefaultEngineSlots(opts: {
83
+ ownerHome: string;
84
+ envFile: string;
85
+ procEnv?: NodeJS.ProcessEnv;
86
+ }): PurgeDefaultEngineSlotsResult {
87
+ const files: string[] = [];
88
+ const dirs: string[] = [];
89
+ for (const parts of DEFAULT_SLOT_FILES) {
90
+ const file = join(opts.ownerHome, ...parts);
91
+ if (!existsSync(file)) continue;
92
+ rmSync(file, { force: true });
93
+ files.push(file);
94
+ }
95
+ for (const { parent, prefix } of ACCOUNT_DIR_PREFIXES) {
96
+ const dir = join(opts.ownerHome, ...parent);
97
+ let entries: Dirent[];
98
+ try {
99
+ entries = readdirSync(dir, { withFileTypes: true });
100
+ } catch {
101
+ continue;
102
+ }
103
+ for (const entry of entries) {
104
+ if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue;
105
+ const path = join(dir, entry.name);
106
+ rmSync(path, { recursive: true, force: true });
107
+ dirs.push(path);
108
+ }
109
+ }
110
+ const envKeys = removeEnvValuesAtomic(opts.envFile, ENGINE_CREDENTIAL_ENV_KEYS);
111
+ const procEnv = opts.procEnv ?? process.env;
112
+ for (const key of ENGINE_CREDENTIAL_ENV_KEYS) delete procEnv[key];
113
+ return { files, dirs, envKeys };
114
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.137",
3
+ "version": "0.9.139",
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.
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,12 @@ 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";
256
267
  import { resumeSidecarTask } from "../lib/sidecar-resume";
257
268
  import { declareAgentClisReady } from "../lib/standard-image";
258
269
 
@@ -438,6 +449,11 @@ interface PausableSource {
438
449
  }
439
450
 
440
451
  console.log(`[host-agent] starting host ${hostId}`);
452
+ if (signInBoxMode() && sidecarMode()) {
453
+ console.error("[host-agent] --signin-box and --task are exclusive; refusing to start");
454
+ process.exit(2);
455
+ }
456
+ if (signInBoxMode()) console.log("[host-agent] sign-in box mode (ADR-136): no tasks, no credential at rest");
441
457
  // Self-heal the recurring silent admission gate BEFORE anything can refuse
442
458
  // work: an uninstall/update helper that died at phase "checking" (live
443
459
  // 2026-08-11 + twice 2026-08-18) left every taskUp/channelEnsure bouncing
@@ -526,9 +542,59 @@ const repushBrokeredCopies = async (kind: string): Promise<void> => {
526
542
  await getOrchestrator().refreshEngineAccountCopies(kind);
527
543
  };
528
544
  configureTokenBroker({ repush: repushBrokeredCopies });
545
+ /**
546
+ * ADR-136 sign-in box start. Strip every credential a previous release or an
547
+ * operator left on this machine, then rebuild the workspace account LIST from
548
+ * parameter names and tags — never a value — before the first capability
549
+ * frame is built, so a replacement box does not advertise an empty list and
550
+ * overwrite the cloud's snapshot with it. Bounded: a store outage must not
551
+ * keep the box offline, only empty until the next start.
552
+ */
553
+ const SIGN_IN_BOX_RECONCILE_TIMEOUT_MS = 30_000;
554
+ async function startSignInBox(): Promise<void> {
555
+ const secrets = purgeLocalEngineAccountSecrets();
556
+ const slots = purgeDefaultEngineSlots({
557
+ ownerHome: process.env.UAI_OWNER_HOME?.trim() || homedir(),
558
+ envFile: join(env.uaiHome, ".env.local"),
559
+ });
560
+ const purged =
561
+ secrets.stripped + secrets.removed + slots.files.length + slots.dirs.length + slots.envKeys.length;
562
+ if (purged > 0 || secrets.errors > 0) {
563
+ console.warn(
564
+ `[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` +
566
+ (secrets.errors > 0 ? `, ${secrets.errors} errors` : ""),
567
+ );
568
+ }
569
+ let timer: NodeJS.Timeout | null = null;
570
+ const deadline = new Promise<"timeout">((resolve) => {
571
+ timer = setTimeout(() => resolve("timeout"), SIGN_IN_BOX_RECONCILE_TIMEOUT_MS);
572
+ timer.unref?.();
573
+ });
574
+ try {
575
+ const outcome = await Promise.race([reconcileEngineAccountMetadataFromStore(), deadline]);
576
+ 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
+ );
582
+ }
583
+ } catch (error) {
584
+ console.warn(
585
+ `[signin-box] account metadata reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
586
+ );
587
+ } finally {
588
+ if (timer) clearTimeout(timer);
589
+ }
590
+ }
529
591
  // ADR-133: a sidecar never refreshes sign-ins (the broker Lambda does), and
530
592
  // reads the store only once it holds a lease — see leaseManager below.
531
- if (!sidecarMode()) {
593
+ // ADR-136: the sign-in box imports nothing and runs no pickup — it holds no
594
+ // copy for a refresh to update.
595
+ if (signInBoxMode()) {
596
+ await startSignInBox();
597
+ } else if (!sidecarMode()) {
532
598
  startTokenBrokerLoop();
533
599
  startCredentialStorePickup({ repush: repushBrokeredCopies });
534
600
  void reconcileStores();
@@ -621,6 +687,21 @@ const engineLoginManager = createEngineLoginManager({
621
687
  // A grok sign-in may be the first grok configuration on this host: the
622
688
  // standard image needs the CLI before its tasks can run (host 0.9.105).
623
689
  ensureStandardImage: () => ensureStandardImage(),
690
+ // ADR-136 sign-in box: workspace accounts only, written to the store first
691
+ // and never sealed here.
692
+ workspaceOnly: signInBoxMode(),
693
+ ...(signInBoxMode()
694
+ ? {
695
+ seams: {
696
+ addClaudeAccount: (label, token, scope) =>
697
+ addEngineAccountToStore("claude", label, { token }, scope),
698
+ addCodexAccount: (label, authJson, scope) =>
699
+ addCapturedConfigDirAccountToStore("codex", label, authJson, scope),
700
+ addGrokAccount: (label, authJson, scope) =>
701
+ addCapturedConfigDirAccountToStore("grok", label, authJson, scope),
702
+ },
703
+ }
704
+ : {}),
624
705
  });
625
706
  // ADR-117: one-shot connect (pasted key / OpenCode re-detect) over the bridge.
626
707
  const engineConnectManager = createEngineConnectManager({
@@ -628,6 +709,13 @@ const engineConnectManager = createEngineConnectManager({
628
709
  refreshEngineAccountAgents: (engine) =>
629
710
  getOrchestrator().refreshEngineAccountAgents(engine),
630
711
  ensureStandardImage: () => ensureStandardImage(),
712
+ workspaceOnly: signInBoxMode(),
713
+ ...(signInBoxMode()
714
+ ? {
715
+ addLabeledAccount: (kind, label, secret, scope) =>
716
+ addEngineAccountToStore(kind, label, { token: secret }, scope),
717
+ }
718
+ : {}),
631
719
  });
632
720
  if (initialRuntime.status === "ready") {
633
721
  console.log(
@@ -693,10 +781,10 @@ void connect();
693
781
  // Best-effort: a UI bind failure must not take the host service down.
694
782
  // A task machine has no operator sitting at it (ADR-133): no local UI, no
695
783
  // Mac vnode watch.
696
- if (!sidecarMode()) void startLocalUi();
784
+ if (!sidecarMode() && !signInBoxMode()) void startLocalUi();
697
785
  // ADR-118 follow-up: vnode-pressure observability (macOS/apple-container
698
786
  // hosts). Logs only on band transitions; the sensor caches its sysctl read.
699
- if (!sidecarMode()) {
787
+ if (!sidecarMode() && !signInBoxMode()) {
700
788
  const vnodePressureTimer = setInterval(() => void logVnodePressure(), 60_000);
701
789
  vnodePressureTimer.unref?.();
702
790
  void logVnodePressure();
@@ -1334,6 +1422,7 @@ async function connect(): Promise<void> {
1334
1422
  const frame = parseCloudFrame(data);
1335
1423
  if (!frame) return;
1336
1424
  if (stopping && rejectFrameDuringShutdown(socket, frame)) return;
1425
+ if (signInBoxMode() && rejectFrameOnSignInBox(socket, frame)) return;
1337
1426
 
1338
1427
  switch (frame.kind) {
1339
1428
  case "pong":
@@ -3260,6 +3349,39 @@ function rejectFrameDuringShutdown(
3260
3349
  }
3261
3350
  }
3262
3351
 
3352
+ /**
3353
+ * ADR-136 sign-in box: the runner behind the "Uai Cloud" row runs no task.
3354
+ * Task commands and tunnel opens get a definite, non-retryable refusal —
3355
+ * nothing here will ever accept them — while the managed lifecycle, engine
3356
+ * sign-ins and account management pass through untouched.
3357
+ */
3358
+ function rejectFrameOnSignInBox(socket: WebSocket, frame: CloudToHost): boolean {
3359
+ switch (frame.kind) {
3360
+ case "command":
3361
+ if (signInBoxAllowsCommand(frame.command)) return false;
3362
+ send(socket, {
3363
+ kind: "result",
3364
+ commandId: frame.commandId,
3365
+ result: {
3366
+ ok: false,
3367
+ code: HostErrorCode.HostUnavailable,
3368
+ message: SIGN_IN_BOX_TASK_REFUSAL,
3369
+ retryable: false,
3370
+ },
3371
+ });
3372
+ return true;
3373
+ case "tunnel.open":
3374
+ send(socket, {
3375
+ kind: "tunnel.close",
3376
+ tunnelId: frame.tunnelId,
3377
+ reason: SIGN_IN_BOX_TASK_REFUSAL,
3378
+ });
3379
+ return true;
3380
+ default:
3381
+ return false;
3382
+ }
3383
+ }
3384
+
3263
3385
  function rawDataToBuffer(data: RawData): Buffer {
3264
3386
  if (Buffer.isBuffer(data)) return data;
3265
3387
  if (data instanceof ArrayBuffer) return Buffer.from(data);