@runuai/host 0.9.103 → 0.9.105

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.
@@ -0,0 +1,3 @@
1
+ -- Manage-v1 (host 0.9.104): explicit priority order for a kind's extra
2
+ -- accounts within a scope. Existing rows keep 0 and fall back to created_at.
3
+ ALTER TABLE `host_engine_accounts` ADD `position` integer NOT NULL DEFAULT 0;
@@ -127,6 +127,13 @@
127
127
  "when": 1788820000000,
128
128
  "tag": "0017_engine_account_scope",
129
129
  "breakpoints": true
130
+ },
131
+ {
132
+ "idx": 18,
133
+ "version": "6",
134
+ "when": 1788900000000,
135
+ "tag": "0018_engine_account_position",
136
+ "breakpoints": true
130
137
  }
131
138
  ]
132
139
  }
package/db/schema.ts CHANGED
@@ -278,6 +278,9 @@ export const engineAccounts = sqliteTable("host_engine_accounts", {
278
278
  // ADR-131 D3: "host" (owner-level, default) or "org:<orgId>" (a workspace's
279
279
  // account on a shared host). Resolution is by exact scope.
280
280
  scope: text("scope").notNull().default("host"),
281
+ // Manage-v1: priority within (scope, kind) — tasks pick the first healthy
282
+ // account in ascending position; ties break on created_at.
283
+ position: integer("position", { mode: "number" }).notNull().default(0),
281
284
  createdAt: integer("created_at", { mode: "number" }).notNull(),
282
285
  });
283
286
 
@@ -25,6 +25,7 @@
25
25
  import {
26
26
  HOST_ACCOUNT_SCOPE,
27
27
  isEngineAccountScope,
28
+ isEngineAccountLabel,
28
29
  } from "../src/protocol";
29
30
  import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
30
31
  import { homedir } from "node:os";
@@ -67,7 +68,17 @@ const KINDS: Record<string, KindDescriptor> = {
67
68
  // API-key engines: an extra account is one env var. The default slot stays
68
69
  // the host's own `.env.local` key (the adapters read process.env), so these
69
70
  // never synthesize a default — `defaultAvailable` is false for them.
70
- grok: { authKind: "env" },
71
+ // Grok: a subscription sign-in is a config dir (`grok login --device-auth`
72
+ // writes auth.json; GROK_HOME selects it in-container). Pasted xAI keys
73
+ // remain `env` rows (XAI_API_KEY) — rows carry their own authKind.
74
+ grok: {
75
+ authKind: "config-dir",
76
+ defaultHostDir: (owner) => join(owner, ".grok"),
77
+ defaultContainerDir: "/home/node/.grok",
78
+ selectVar: "GROK_HOME",
79
+ containerDirPrefix: "/home/node/.grok-acct-",
80
+ extraHostDir: (owner, id) => join(owner, `.grok-acct-${id}`),
81
+ },
71
82
  cursor: { authKind: "env" },
72
83
  codex: {
73
84
  authKind: "config-dir",
@@ -280,7 +291,8 @@ function resolveExtras(
280
291
  (r) =>
281
292
  r.kind === kind &&
282
293
  accountInScope((r as { scope?: string | null }).scope, scope),
283
- );
294
+ )
295
+ .sort(compareAccountRows);
284
296
  const out: ResolvedEngineAccount[] = [];
285
297
  for (const row of rows) {
286
298
  if (row.authKind === "env") {
@@ -440,8 +452,16 @@ export function selectAccount(
440
452
  if (candidates.length === 0) return null;
441
453
  const lru = (a: ResolvedEngineAccount): number => state(a.id).lastUsedAt;
442
454
  const healthy = candidates.filter((a) => state(a.id).cooldownUntil <= now);
443
- const pool = healthy.length > 0 ? healthy : candidates;
444
- return pool.reduce((best, a) => (lru(a) < lru(best) ? a : best), pool[0]!);
455
+ // Manage-v1: the list arrives in PRIORITY order (default slot, then extras
456
+ // by position) — the first healthy account wins, so the operator's
457
+ // ordering decides which account a task uses and which are fallbacks.
458
+ // Only when every account is cooling do we fall back to the least recently
459
+ // used one (the stalest cooldown is the likeliest to have lifted).
460
+ if (healthy.length > 0) return healthy[0]!;
461
+ return candidates.reduce(
462
+ (best, a) => (lru(a) < lru(best) ? a : best),
463
+ candidates[0]!,
464
+ );
445
465
  }
446
466
 
447
467
  /** Pick an account for a kind (default + extras), applying rotation state. */
@@ -663,6 +683,30 @@ export async function provisionEngineAccounts(
663
683
  return { copied, failed, mayHaveMutated };
664
684
  }
665
685
 
686
+ /** Priority order within (scope, kind): position, then age. Rows from before
687
+ * manage-v1 all carry 0 and therefore keep their creation order. */
688
+ function compareAccountRows(
689
+ a: { position?: number | null; createdAt: number; id: string },
690
+ b: { position?: number | null; createdAt: number; id: string },
691
+ ): number {
692
+ return (
693
+ (a.position ?? 0) - (b.position ?? 0) ||
694
+ a.createdAt - b.createdAt ||
695
+ a.id.localeCompare(b.id)
696
+ );
697
+ }
698
+
699
+ /** New accounts join the END of their (scope, kind) order. */
700
+ function nextPosition(kind: string, scope: string): number {
701
+ let max = -1;
702
+ for (const row of getDb().select().from(schema.engineAccounts).all()) {
703
+ if (row.kind !== kind) continue;
704
+ if (((row as { scope?: string | null }).scope ?? HOST_ACCOUNT_SCOPE) !== scope) continue;
705
+ max = Math.max(max, (row as { position?: number | null }).position ?? 0);
706
+ }
707
+ return max + 1;
708
+ }
709
+
666
710
  // ---------------------------------------------------------------------------
667
711
  // Mutations (host UI).
668
712
  // ---------------------------------------------------------------------------
@@ -715,6 +759,7 @@ export function addEngineAccount(
715
759
  configDir: null,
716
760
  createdAt: now,
717
761
  scope,
762
+ position: nextPosition(kind, scope),
718
763
  })
719
764
  .run();
720
765
  return { ok: true, message: `Added Claude account "${trimmedLabel}".`, id };
@@ -733,6 +778,7 @@ export function addEngineAccount(
733
778
  configDir: null,
734
779
  createdAt: now,
735
780
  scope,
781
+ position: nextPosition(kind, scope),
736
782
  })
737
783
  .run();
738
784
  return {
@@ -778,6 +824,7 @@ export function addEngineAccount(
778
824
  configDir: hostDir,
779
825
  createdAt: now,
780
826
  scope,
827
+ position: nextPosition(kind, scope),
781
828
  })
782
829
  .run();
783
830
  return { ok: true, message: `Added Codex account "${trimmedLabel}".`, id };
@@ -801,6 +848,28 @@ export function addCodexAccountFromAuthJson(
801
848
  seams: Partial<AccountSeams> = {},
802
849
  scope: string = HOST_ACCOUNT_SCOPE,
803
850
  ): AddAccountResult {
851
+ return addConfigDirAccountFromAuthJson("codex", label, authJson, seams, scope);
852
+ }
853
+
854
+ /** Grok (host 0.9.105): the `auth.json` a `grok login --device-auth` capture
855
+ * produced, stored as an isolated `~/.grok-acct-<id>` selected by GROK_HOME. */
856
+ export function addGrokAccountFromAuthJson(
857
+ label: string,
858
+ authJson: string,
859
+ seams: Partial<AccountSeams> = {},
860
+ scope: string = HOST_ACCOUNT_SCOPE,
861
+ ): AddAccountResult {
862
+ return addConfigDirAccountFromAuthJson("grok", label, authJson, seams, scope);
863
+ }
864
+
865
+ function addConfigDirAccountFromAuthJson(
866
+ kind: "codex" | "grok",
867
+ label: string,
868
+ authJson: string,
869
+ seams: Partial<AccountSeams>,
870
+ scope: string,
871
+ ): AddAccountResult {
872
+ const engineLabel = kind === "codex" ? "Codex" : "Grok";
804
873
  if (!isEngineAccountScope(scope)) {
805
874
  return { ok: false, message: "The account scope is invalid." };
806
875
  }
@@ -813,11 +882,11 @@ export function addCodexAccountFromAuthJson(
813
882
  throw new Error("not an object");
814
883
  }
815
884
  } catch {
816
- return { ok: false, message: "The captured Codex credential is not valid." };
885
+ return { ok: false, message: `The captured ${engineLabel} credential is not valid.` };
817
886
  }
818
887
 
819
888
  const id = newId();
820
- const d = KINDS.codex as KindDescriptor;
889
+ const d = KINDS[kind] as KindDescriptor;
821
890
  const hostDir = (d.extraHostDir as (o: string, i: string) => string)(
822
891
  s.ownerHome(),
823
892
  id,
@@ -845,16 +914,17 @@ export function addCodexAccountFromAuthJson(
845
914
  .insert(schema.engineAccounts)
846
915
  .values({
847
916
  id,
848
- kind: "codex",
917
+ kind,
849
918
  label: trimmedLabel,
850
919
  authKind: "config-dir",
851
920
  secretEnc: null,
852
921
  configDir: hostDir,
853
922
  createdAt: Date.now(),
854
923
  scope,
924
+ position: nextPosition(kind, scope),
855
925
  })
856
926
  .run();
857
- return { ok: true, message: `Added Codex account "${trimmedLabel}".`, id };
927
+ return { ok: true, message: `Added ${engineLabel} account "${trimmedLabel}".`, id };
858
928
  }
859
929
 
860
930
  /** Remove an EXTRA account (idempotent). The synthesized default can't be
@@ -900,3 +970,78 @@ export function removeEngineAccount(
900
970
  }
901
971
  return { ok: true, message: `Removed "${row.label}".` };
902
972
  }
973
+
974
+ /** Manage-v1: relabel an extra account. Same scope rule as removal. */
975
+ export function renameEngineAccount(
976
+ id: string,
977
+ label: string,
978
+ scope?: string,
979
+ ): { ok: boolean; message: string } {
980
+ if (id.startsWith("default:")) {
981
+ return { ok: false, message: "The default account cannot be renamed." };
982
+ }
983
+ const trimmed = label.trim();
984
+ if (!isEngineAccountLabel(trimmed)) {
985
+ return { ok: false, message: "Give the account a label (up to 64 characters)." };
986
+ }
987
+ const row = getDb()
988
+ .select()
989
+ .from(schema.engineAccounts)
990
+ .where(eq(schema.engineAccounts.id, id))
991
+ .get();
992
+ if (!row) return { ok: false, message: "That account no longer exists." };
993
+ if (!accountRemovableInScope(row, scope)) {
994
+ return { ok: false, message: "That account belongs to a different scope." };
995
+ }
996
+ getDb()
997
+ .update(schema.engineAccounts)
998
+ .set({ label: trimmed })
999
+ .where(eq(schema.engineAccounts.id, id))
1000
+ .run();
1001
+ return { ok: true, message: `Renamed to "${trimmed}".` };
1002
+ }
1003
+
1004
+ /**
1005
+ * Manage-v1: set the priority order of `kind`'s extra accounts in `scope`.
1006
+ * `ids` must be exactly the set of that kind's rows in the scope — a stale
1007
+ * list (an account added or removed meanwhile) is refused rather than
1008
+ * silently partially applied.
1009
+ */
1010
+ export function reorderEngineAccounts(
1011
+ kind: string,
1012
+ ids: readonly string[],
1013
+ scope: string = HOST_ACCOUNT_SCOPE,
1014
+ ): { ok: boolean; message: string } {
1015
+ if (!isEngineAccountScope(scope)) {
1016
+ return { ok: false, message: "The account scope is invalid." };
1017
+ }
1018
+ const rows = getDb()
1019
+ .select()
1020
+ .from(schema.engineAccounts)
1021
+ .all()
1022
+ .filter(
1023
+ (r) =>
1024
+ r.kind === kind &&
1025
+ ((r as { scope?: string | null }).scope ?? HOST_ACCOUNT_SCOPE) === scope,
1026
+ );
1027
+ const have = new Set(rows.map((r) => r.id));
1028
+ const want = new Set(ids);
1029
+ if (
1030
+ have.size !== want.size ||
1031
+ ids.length !== want.size ||
1032
+ [...have].some((id) => !want.has(id))
1033
+ ) {
1034
+ return {
1035
+ ok: false,
1036
+ message: "The account list changed — refresh and try again.",
1037
+ };
1038
+ }
1039
+ ids.forEach((id, index) => {
1040
+ getDb()
1041
+ .update(schema.engineAccounts)
1042
+ .set({ position: index })
1043
+ .where(eq(schema.engineAccounts.id, id))
1044
+ .run();
1045
+ });
1046
+ return { ok: true, message: "Order saved." };
1047
+ }
@@ -166,7 +166,7 @@ export class EngineConnectManager {
166
166
  scope ?? HOST_ACCOUNT_SCOPE,
167
167
  );
168
168
  if (added.ok) this.afterMutation(engine, false);
169
- this.finish(operation, added.ok, added.message);
169
+ this.finish(operation, added.ok, added.message, added.id);
170
170
  return;
171
171
  }
172
172
  // ADR-131 D3: everything below writes the host's DEFAULT slot or re-detects
@@ -207,6 +207,7 @@ export class EngineConnectManager {
207
207
  operation: ConnectOperation,
208
208
  ok: boolean,
209
209
  message: string,
210
+ accountId?: string,
210
211
  ): void {
211
212
  if (operation.done) return;
212
213
  operation.done = true;
@@ -221,6 +222,7 @@ export class EngineConnectManager {
221
222
  phase: "done",
222
223
  ok,
223
224
  message,
225
+ ...(ok && accountId !== undefined ? { accountId } : {}),
224
226
  });
225
227
  }
226
228
  }
@@ -42,6 +42,7 @@ import {
42
42
  import {
43
43
  addCodexAccountFromAuthJson,
44
44
  addEngineAccount,
45
+ addGrokAccountFromAuthJson,
45
46
  } from "./engine-accounts";
46
47
  import { reinjectCodexRunningTasks } from "./codex-auth";
47
48
  import {
@@ -67,6 +68,16 @@ export const MAX_CONCURRENT_ENGINE_LOGINS = 1;
67
68
  const LOGIN_MOUNT = "/run/uai-engine-login";
68
69
  const LOGIN_HOME = `${LOGIN_MOUNT}/home`;
69
70
  const CODEX_HOME = `${LOGIN_MOUNT}/codex`;
71
+ /** grok writes its auth.json under GROK_HOME (host 0.9.105 remote sign-in). */
72
+ const GROK_HOME = `${LOGIN_MOUNT}/grok`;
73
+ /** The login image only carries grok when the host had it configured at its
74
+ * last image build; a shared host never did. Install into the op's private
75
+ * HOME on the fly (x.ai's installer, ~seconds) when the image lacks it. */
76
+ const GROK_LOGIN_SHELL =
77
+ 'g="$(command -v grok 2>/dev/null || true)"; ' +
78
+ '[ -n "$g" ] || { curl -fsSL https://x.ai/cli/install.sh | bash >/dev/null 2>&1; g="$HOME/.grok/bin/grok"; }; ' +
79
+ '[ -x "$g" ] || { echo "grok CLI is not available in the login image" >&2; exit 127; }; ' +
80
+ 'exec "$g" login --device-auth';
70
81
  const MAX_OUTPUT_TAIL_BYTES = 64 * 1_024;
71
82
  /** script(1) transcripts of a login run a few KB; 1MB is a hostile-file cap. */
72
83
  const MAX_TRANSCRIPT_BYTES = 1_024 * 1_024;
@@ -151,12 +162,21 @@ export interface EngineLoginSeams {
151
162
  label: string,
152
163
  token: string,
153
164
  scope: string,
154
- ): { ok: boolean; message: string };
165
+ ): { ok: boolean; message: string; id?: string };
155
166
  addCodexAccount(
156
167
  label: string,
157
168
  authJson: string,
158
169
  scope: string,
159
- ): { ok: boolean; message: string };
170
+ ): { ok: boolean; message: string; id?: string };
171
+ addGrokAccount(
172
+ label: string,
173
+ authJson: string,
174
+ scope: string,
175
+ ): { ok: boolean; message: string; id?: string };
176
+ persistGrokAuth(sourceFile: string): Promise<void>;
177
+ /** Optional engines ride the standard image only when configured; a fresh
178
+ * account may be the first configuration → rebuild (queue-deduped). */
179
+ ensureStandardImage(): Promise<void>;
160
180
  refreshEngineAccountAgents(engine: "claude"): Promise<void>;
161
181
  reinjectCodexRunningTasks(): Promise<void>;
162
182
  ownerHome(): string;
@@ -177,6 +197,8 @@ export interface EngineLoginManagerOptions {
177
197
  /** Defer Docker inventory until the first start when no runtime is selected. */
178
198
  reconcileOnCreate?: boolean;
179
199
  seams?: Partial<EngineLoginSeams>;
200
+ /** Rebuild the standard image after a successful capture (optional engines). */
201
+ ensureStandardImage?: () => Promise<unknown>;
180
202
  }
181
203
 
182
204
  type OperationStage =
@@ -289,6 +311,26 @@ export async function persistCodexAuthAtomic(
289
311
  );
290
312
  }
291
313
 
314
+ export async function persistGrokAuthAtomic(
315
+ sourceFile: string,
316
+ options: { ownerHome?: string } = {},
317
+ ): Promise<void> {
318
+ const body = await readBoundedRegularFile(sourceFile, MAX_CODEX_AUTH_BYTES);
319
+ let parsed: unknown;
320
+ try {
321
+ parsed = JSON.parse(body.toString("utf8"));
322
+ } catch {
323
+ throw new Error("Grok produced invalid auth data");
324
+ }
325
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
326
+ throw new Error("Grok produced invalid auth data");
327
+ }
328
+ await writeSensitiveFileAtomic(
329
+ join(options.ownerHome ?? ownerHome(), ".grok", "auth.json"),
330
+ body,
331
+ );
332
+ }
333
+
292
334
  function defaultSeams(options: EngineLoginManagerOptions): EngineLoginSeams {
293
335
  const tempRoot = options.tempRoot;
294
336
  return {
@@ -315,6 +357,12 @@ function defaultSeams(options: EngineLoginManagerOptions): EngineLoginSeams {
315
357
  addEngineAccount("claude", label, { token }, {}, scope),
316
358
  addCodexAccount: (label, authJson, scope) =>
317
359
  addCodexAccountFromAuthJson(label, authJson, {}, scope),
360
+ addGrokAccount: (label, authJson, scope) =>
361
+ addGrokAccountFromAuthJson(label, authJson, {}, scope),
362
+ persistGrokAuth: (sourceFile) => persistGrokAuthAtomic(sourceFile),
363
+ ensureStandardImage: async () => {
364
+ await options.ensureStandardImage?.();
365
+ },
318
366
  refreshEngineAccountAgents: options.refreshEngineAccountAgents,
319
367
  reinjectCodexRunningTasks: () => reinjectCodexRunningTasks(),
320
368
  ownerHome,
@@ -362,6 +410,9 @@ export class EngineLoginManager {
362
410
  const codexConfigured = this.seams.fileExists(
363
411
  join(this.seams.ownerHome(), ".codex", "auth.json"),
364
412
  );
413
+ const grokConfigured =
414
+ this.seams.fileExists(join(this.seams.ownerHome(), ".grok", "auth.json")) ||
415
+ Boolean(this.seams.procEnv.XAI_API_KEY);
365
416
  return [
366
417
  {
367
418
  engine: "claude",
@@ -373,6 +424,11 @@ export class EngineLoginManager {
373
424
  status: codexConfigured ? "configured" : "missing",
374
425
  loginSupported: true,
375
426
  },
427
+ {
428
+ engine: "grok",
429
+ status: grokConfigured ? "configured" : "missing",
430
+ loginSupported: true,
431
+ },
376
432
  ];
377
433
  }
378
434
 
@@ -853,10 +909,13 @@ export class EngineLoginManager {
853
909
  }
854
910
 
855
911
  if (operation.stage === "starting") {
856
- const device = findCodexDeviceAuthorization(operation.outputTail);
912
+ const device =
913
+ operation.engine === "grok"
914
+ ? findGrokDeviceAuthorization(operation.outputTail)
915
+ : findCodexDeviceAuthorization(operation.outputTail);
857
916
  if (device) {
858
917
  console.log(
859
- `[engine-login] codex device authorization found for ${operation.opId}; awaiting operator confirmation`,
918
+ `[engine-login] ${operation.engine} device authorization found for ${operation.opId}; awaiting operator confirmation`,
860
919
  );
861
920
  operation.stage = "awaiting_confirmation";
862
921
  safeEmit(
@@ -868,10 +927,12 @@ export class EngineLoginManager {
868
927
  opId: operation.opId,
869
928
  engine: operation.engine,
870
929
  phase: "awaiting_confirmation",
930
+ code: device.userCode,
871
931
  message: `Enter code ${device.userCode} on the verification page. Nothing to paste here — this completes on its own.`,
872
932
  });
873
933
  return;
874
934
  }
935
+ if (operation.engine !== "codex") return;
875
936
  const authorization = findCodexAuthorization(operation.outputTail);
876
937
  if (authorization) {
877
938
  // Symmetric with the claude branch: this breadcrumb's ABSENCE was
@@ -902,7 +963,10 @@ export class EngineLoginManager {
902
963
  result: EngineLoginProcessResult,
903
964
  ): Promise<void> {
904
965
  if (!this.isCurrent(operation) || operation.finishing) return;
905
- if (operation.engine === "codex" && result.code === 0) {
966
+ if (
967
+ (operation.engine === "codex" || operation.engine === "grok") &&
968
+ result.code === 0
969
+ ) {
906
970
  this.beginCodexFinish(operation);
907
971
  if (operation.finishPromise) await operation.finishPromise;
908
972
  return;
@@ -983,6 +1047,7 @@ export class EngineLoginManager {
983
1047
  engine: operation.engine,
984
1048
  phase: "succeeded",
985
1049
  message: added.message,
1050
+ ...(added.id === undefined ? {} : { accountId: added.id }),
986
1051
  }
987
1052
  : failedEvent(
988
1053
  operation.opId,
@@ -1035,25 +1100,34 @@ export class EngineLoginManager {
1035
1100
  private async commitCodex(operation: LoginOperation): Promise<void> {
1036
1101
  const tempDir = operation.tempDir;
1037
1102
  if (!tempDir) return;
1103
+ // codex and grok share the shape: a device-code CLI that exits 0 after
1104
+ // writing auth.json under its HOME var (CODEX_HOME / GROK_HOME), which
1105
+ // the container mounts from the op's private leaf.
1106
+ const grok = operation.engine === "grok";
1107
+ const authFile = join(tempDir, grok ? "grok" : "codex", "auth.json");
1108
+ const label = grok ? "Grok" : "Codex";
1038
1109
  let terminal: EngineLoginEventFrame;
1039
1110
  if (operation.accountLabel !== null) {
1040
1111
  // ADR-116: labeled capture — the auth.json this login produced becomes
1041
1112
  // an isolated extra account instead of overwriting ~/.codex.
1042
- let added: { ok: boolean; message: string };
1113
+ let added: { ok: boolean; message: string; id?: string };
1043
1114
  try {
1044
- const contents = await readFile(
1045
- join(tempDir, "codex", "auth.json"),
1046
- "utf8",
1047
- );
1048
- added = this.seams.addCodexAccount(
1049
- operation.accountLabel,
1050
- contents,
1051
- operation.accountScope,
1052
- );
1115
+ const contents = await readFile(authFile, "utf8");
1116
+ added = grok
1117
+ ? this.seams.addGrokAccount(
1118
+ operation.accountLabel,
1119
+ contents,
1120
+ operation.accountScope,
1121
+ )
1122
+ : this.seams.addCodexAccount(
1123
+ operation.accountLabel,
1124
+ contents,
1125
+ operation.accountScope,
1126
+ );
1053
1127
  } catch {
1054
1128
  added = {
1055
1129
  ok: false,
1056
- message: "Codex authorized, but its credential could not be read.",
1130
+ message: `${label} authorized, but its credential could not be read.`,
1057
1131
  };
1058
1132
  }
1059
1133
  terminal = added.ok
@@ -1063,6 +1137,7 @@ export class EngineLoginManager {
1063
1137
  engine: operation.engine,
1064
1138
  phase: "succeeded",
1065
1139
  message: added.message,
1140
+ ...(added.id === undefined ? {} : { accountId: added.id }),
1066
1141
  }
1067
1142
  : failedEvent(
1068
1143
  operation.opId,
@@ -1071,31 +1146,32 @@ export class EngineLoginManager {
1071
1146
  added.message,
1072
1147
  );
1073
1148
  await this.finishCommit(operation, terminal);
1149
+ if (added.ok) runBestEffort(() => this.seams.ensureStandardImage());
1074
1150
  return;
1075
1151
  }
1076
1152
  try {
1077
- await this.seams.persistCodexAuth(
1078
- join(tempDir, "codex", "auth.json"),
1079
- );
1153
+ if (grok) await this.seams.persistGrokAuth(authFile);
1154
+ else await this.seams.persistCodexAuth(authFile);
1080
1155
  } catch {
1081
1156
  terminal = failedEvent(
1082
1157
  operation.opId,
1083
1158
  operation.engine,
1084
1159
  "persist_failed",
1085
- "Codex authorized, but its credential could not be saved.",
1160
+ `${label} authorized, but its credential could not be saved.`,
1086
1161
  );
1087
1162
  await this.finishCommit(operation, terminal);
1088
1163
  return;
1089
1164
  }
1090
- runBestEffort(() => this.seams.reinjectCodexRunningTasks());
1165
+ if (!grok) runBestEffort(() => this.seams.reinjectCodexRunningTasks());
1091
1166
  terminal = {
1092
1167
  kind: "engine.login.event",
1093
1168
  opId: operation.opId,
1094
1169
  engine: operation.engine,
1095
1170
  phase: "succeeded",
1096
- message: "Codex connected.",
1171
+ message: `${label} connected.`,
1097
1172
  };
1098
1173
  await this.finishCommit(operation, terminal);
1174
+ runBestEffort(() => this.seams.ensureStandardImage());
1099
1175
  }
1100
1176
 
1101
1177
  private async finishCommit(
@@ -1316,8 +1392,8 @@ function safeEmit(
1316
1392
  }
1317
1393
  }
1318
1394
 
1319
- function engineLabel(engine: EngineLoginKind): "Claude" | "Codex" {
1320
- return engine === "claude" ? "Claude" : "Codex";
1395
+ function engineLabel(engine: EngineLoginKind): "Claude" | "Codex" | "Grok" {
1396
+ return engine === "claude" ? "Claude" : engine === "codex" ? "Codex" : "Grok";
1321
1397
  }
1322
1398
 
1323
1399
  function failedEvent(
@@ -1568,6 +1644,26 @@ export function findCodexDeviceAuthorization(
1568
1644
  return { verificationUrl, userCode: code[1]! };
1569
1645
  }
1570
1646
 
1647
+ /**
1648
+ * Grok `login --device-auth` banner (host 0.9.105): "To sign in, open this
1649
+ * URL in your browser: https://accounts.x.ai/oauth2/device?user_code=XXXX-XXXX
1650
+ * … Confirm this code in your browser: XXXX-XXXX". Phrase gate + code shape,
1651
+ * same posture as the codex parser; grok prints to stderr.
1652
+ */
1653
+ export function findGrokDeviceAuthorization(
1654
+ value: string,
1655
+ ): { verificationUrl: string; userCode: string } | null {
1656
+ const plain = stripTerminalControl(value);
1657
+ if (!/confirm this code|open this url/i.test(plain)) return null;
1658
+ const verificationUrl = findSafeHttpsUrl(plain);
1659
+ if (!verificationUrl) return null;
1660
+ const code =
1661
+ /user_code=([A-Z0-9]{4,8}-[A-Z0-9]{4,10})\b/.exec(plain) ??
1662
+ /(?:^|\s)([A-Z0-9]{4,8}-[A-Z0-9]{4,10})(?:\s|$)/m.exec(plain);
1663
+ if (!code) return null;
1664
+ return { verificationUrl, userCode: code[1]! };
1665
+ }
1666
+
1571
1667
  export function extractClaudeOAuthToken(value: string): string | null {
1572
1668
  const plain = stripTerminalControl(value);
1573
1669
  // The FULL `sk-ant-oat01-` prefix and a realistic minimum length are both
@@ -2000,7 +2096,9 @@ export function engineLoginContainerArgs(
2000
2096
  // because the typescript went to /dev/null.
2001
2097
  `${LOGIN_MOUNT}/typescript`,
2002
2098
  ]
2003
- : // Device-code flow: no localhost callback, no paste — the CLI prints
2099
+ : request.engine === "grok"
2100
+ ? ["sh", "-c", GROK_LOGIN_SHELL]
2101
+ : // Device-code flow: no localhost callback, no paste — the CLI prints
2004
2102
  // a verification URL + one-time code, the operator confirms on the
2005
2103
  // provider's page, and the CLI polls to completion (ADR-116 v2,
2006
2104
  // 2026-08-24). The callback machinery below stays as the fallback
@@ -2047,6 +2145,8 @@ export function engineLoginContainerArgs(
2047
2145
  "--env",
2048
2146
  `CODEX_HOME=${CODEX_HOME}`,
2049
2147
  "--env",
2148
+ `GROK_HOME=${GROK_HOME}`,
2149
+ "--env",
2050
2150
  "ASDF_CONFIG_FILE=/dev/null",
2051
2151
  "--env",
2052
2152
  "ASDF_DEFAULT_TOOL_VERSIONS_FILENAME=.tool-versions",
@@ -2092,6 +2192,8 @@ export function engineLoginContainerArgs(
2092
2192
  "--env",
2093
2193
  `CODEX_HOME=${CODEX_HOME}`,
2094
2194
  "--env",
2195
+ `GROK_HOME=${GROK_HOME}`,
2196
+ "--env",
2095
2197
  "ASDF_CONFIG_FILE=/dev/null",
2096
2198
  "--env",
2097
2199
  "ASDF_DEFAULT_TOOL_VERSIONS_FILENAME=.tool-versions",
@@ -2423,6 +2525,7 @@ export async function createEngineLoginTempDir(
2423
2525
  // self-creates its HOME). Create both up front.
2424
2526
  await mkdir(join(path, "home"), { mode: 0o700 });
2425
2527
  await mkdir(join(path, "codex"), { mode: 0o700 });
2528
+ await mkdir(join(path, "grok"), { mode: 0o700 });
2426
2529
  await syncDirectory(path);
2427
2530
  return path;
2428
2531
  } catch (error) {
@@ -27,6 +27,7 @@ import {
27
27
  recycleAppleRuntimeServices,
28
28
  } from "./apple-runtime-recycle";
29
29
  import { detectEngine } from "./engines";
30
+ import { hasStoredEngineAccounts } from "./engine-accounts";
30
31
  import {
31
32
  appleContainerRuntimeBinding,
32
33
  claimContainerRuntimeForMaintenance,
@@ -112,11 +113,17 @@ export function configuredOptionalEngines(): {
112
113
  cursor: boolean;
113
114
  opencode: boolean;
114
115
  } {
116
+ // A stored account (a workspace's, on a shared host; a labeled extra on an
117
+ // owned one) needs the CLI in the image just like the host's own sign-in
118
+ // does — without this a grok/cursor account existed but its tasks exited
119
+ // 127 for a missing binary.
120
+ const on = (kind: "kimi" | "grok" | "cursor" | "opencode"): boolean =>
121
+ detectEngine(kind) || hasStoredEngineAccounts(kind);
115
122
  return {
116
- kimi: detectEngine("kimi"),
117
- grok: detectEngine("grok"),
118
- cursor: detectEngine("cursor"),
119
- opencode: detectEngine("opencode"),
123
+ kimi: on("kimi"),
124
+ grok: on("grok"),
125
+ cursor: on("cursor"),
126
+ opencode: on("opencode"),
120
127
  };
121
128
  }
122
129
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.103",
3
+ "version": "0.9.105",
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/index.ts CHANGED
@@ -118,7 +118,11 @@ export type {
118
118
  TaskUpResult,
119
119
  } from "../lib/agent";
120
120
 
121
- import { removeEngineAccount } from "../lib/engine-accounts";
121
+ import {
122
+ removeEngineAccount,
123
+ renameEngineAccount,
124
+ reorderEngineAccounts,
125
+ } from "../lib/engine-accounts";
122
126
 
123
127
  export {
124
128
  parseMentions,
@@ -1239,6 +1243,34 @@ export const hostCommands: HostCommands = {
1239
1243
  }
1240
1244
  },
1241
1245
 
1246
+ async engineAccountRename(ctx, input) {
1247
+ logCommand(ctx, "engineAccountRename", input.id);
1248
+ try {
1249
+ const result = renameEngineAccount(input.id, input.label, input.scope);
1250
+ return ok(
1251
+ result.ok
1252
+ ? { renamed: true }
1253
+ : { renamed: false, message: result.message },
1254
+ );
1255
+ } catch (err) {
1256
+ return failFromUnknown(err);
1257
+ }
1258
+ },
1259
+
1260
+ async engineAccountReorder(ctx, input) {
1261
+ logCommand(ctx, "engineAccountReorder", input.kind);
1262
+ try {
1263
+ const result = reorderEngineAccounts(input.kind, input.ids, input.scope);
1264
+ return ok(
1265
+ result.ok
1266
+ ? { reordered: true }
1267
+ : { reordered: false, message: result.message },
1268
+ );
1269
+ } catch (err) {
1270
+ return failFromUnknown(err);
1271
+ }
1272
+ },
1273
+
1242
1274
  async channelResolvePermission(ctx, taskId, agentId, requestId, decision) {
1243
1275
  logCommand(ctx, "channelResolvePermission", taskId, agentId, requestId);
1244
1276
  try {
package/src/main.ts CHANGED
@@ -186,6 +186,7 @@ import {
186
186
  HOST_TASK_STOP_PROTOCOL_FEATURE,
187
187
  HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE,
188
188
  HOST_ENGINE_CONNECT_PROTOCOL_FEATURE,
189
+ HOST_ENGINE_ACCOUNT_MANAGE_PROTOCOL_FEATURE,
189
190
  HOST_ENGINE_ACCOUNT_SCOPES_PROTOCOL_FEATURE,
190
191
  HOST_ENGINE_LOGIN_PROTOCOL_FEATURE,
191
192
  isEngineAccountScope,
@@ -230,6 +231,7 @@ import {
230
231
  type TaskLaunchInput,
231
232
  type FilesOpInput,
232
233
  engineAccountScopeFromWire,
234
+ isEngineAccountLabel,
233
235
  } from "./protocol";
234
236
 
235
237
  setContainerRuntimeOperationalCheck(() => containerRuntimeProblem() === null);
@@ -460,6 +462,9 @@ const engineLoginManager = createEngineLoginManager({
460
462
  getOrchestrator().refreshEngineAccountAgents(engine),
461
463
  tempRoot: join(env.uaiHome, "engine-login-bind"),
462
464
  reconcileOnCreate: false,
465
+ // A grok sign-in may be the first grok configuration on this host: the
466
+ // standard image needs the CLI before its tasks can run (host 0.9.105).
467
+ ensureStandardImage: () => ensureStandardImage(),
463
468
  });
464
469
  // ADR-117: one-shot connect (pasted key / OpenCode re-detect) over the bridge.
465
470
  const engineConnectManager = createEngineConnectManager({
@@ -609,6 +614,7 @@ function buildCapabilities(): HostCapabilities {
609
614
  HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE,
610
615
  HOST_ENGINE_CONNECT_PROTOCOL_FEATURE,
611
616
  HOST_ENGINE_ACCOUNT_SCOPES_PROTOCOL_FEATURE,
617
+ HOST_ENGINE_ACCOUNT_MANAGE_PROTOCOL_FEATURE,
612
618
  // The echo adapter cannot execute the in-task CLI. Advertising typed
613
619
  // dispatch in mock mode would let the composer create a Secretary that
614
620
  // has no way to wake crew.
@@ -2443,7 +2449,68 @@ async function dispatchCommand(
2443
2449
  if (removal.ok) sendCapabilities();
2444
2450
  return removal;
2445
2451
  }
2452
+ case "engineAccountRename": {
2453
+ const renamed = await hostCommands.engineAccountRename(
2454
+ ctx,
2455
+ expectEngineAccountRenameInput(args, 0),
2456
+ );
2457
+ if (renamed.ok) sendCapabilities();
2458
+ return renamed;
2459
+ }
2460
+ case "engineAccountReorder": {
2461
+ const reordered = await hostCommands.engineAccountReorder(
2462
+ ctx,
2463
+ expectEngineAccountReorderInput(args, 0),
2464
+ );
2465
+ if (reordered.ok) sendCapabilities();
2466
+ return reordered;
2467
+ }
2468
+ }
2469
+ }
2470
+
2471
+ function expectEngineAccountRenameInput(
2472
+ args: unknown[],
2473
+ index: number,
2474
+ ): { id: string; label: string; scope?: string } {
2475
+ const base = expectEngineAccountRemoveInput(args, index);
2476
+ const label = (args[index] as { label?: unknown }).label;
2477
+ if (!isEngineAccountLabel(label)) {
2478
+ throw new Error(`invalid command args: bad account label at ${index}`);
2479
+ }
2480
+ return { ...base, label };
2481
+ }
2482
+
2483
+ function expectEngineAccountReorderInput(
2484
+ args: unknown[],
2485
+ index: number,
2486
+ ): { kind: string; ids: string[]; scope?: string } {
2487
+ const value = args[index];
2488
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2489
+ throw new Error(`invalid command args: expected reorder input at ${index}`);
2446
2490
  }
2491
+ const { kind, ids, scope } = value as {
2492
+ kind?: unknown;
2493
+ ids?: unknown;
2494
+ scope?: unknown;
2495
+ };
2496
+ if (typeof kind !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(kind)) {
2497
+ throw new Error(`invalid command args: bad account kind at ${index}`);
2498
+ }
2499
+ if (
2500
+ !Array.isArray(ids) ||
2501
+ ids.length > 64 ||
2502
+ ids.some((id) => typeof id !== "string" || id.length === 0 || id.length > 256)
2503
+ ) {
2504
+ throw new Error(`invalid command args: bad account id list at ${index}`);
2505
+ }
2506
+ if (scope !== undefined && !isEngineAccountScope(scope)) {
2507
+ throw new Error(`invalid command args: bad account scope at ${index}`);
2508
+ }
2509
+ return {
2510
+ kind,
2511
+ ids: ids as string[],
2512
+ ...(scope !== undefined ? { scope } : {}),
2513
+ };
2447
2514
  }
2448
2515
 
2449
2516
  function expectEngineAccountRemoveInput(
@@ -2981,6 +3048,8 @@ const HOST_COMMAND_NAMES = {
2981
3048
  appendTranscript: true,
2982
3049
  previewEnsure: true,
2983
3050
  engineAccountRemove: true,
3051
+ engineAccountRename: true,
3052
+ engineAccountReorder: true,
2984
3053
  } satisfies Record<keyof HostCommands, true>;
2985
3054
 
2986
3055
  function isHostCommand(command: string): command is keyof HostCommands {
package/src/protocol.ts CHANGED
@@ -109,6 +109,12 @@ export const HOST_ENGINE_CONNECT_PROTOCOL_FEATURE = "host-engine-connect-v1";
109
109
  * and stamps `scope` on every EngineAccountSummary it advertises. The cloud
110
110
  * sends none of those to a host without the feature (the v1 exact-shape
111
111
  * guards would reject the frames). */
112
+ /** Account management v1 (host 0.9.104): `engineAccountRename` and
113
+ * `engineAccountReorder` commands; login `succeeded` events carry the created
114
+ * `accountId` for labeled captures; codex `awaiting_confirmation` carries the
115
+ * device `code`. Extras are advertised in priority order (position). */
116
+ export const HOST_ENGINE_ACCOUNT_MANAGE_PROTOCOL_FEATURE =
117
+ "host-engine-account-manage-v1";
112
118
  export const HOST_ENGINE_ACCOUNT_SCOPES_PROTOCOL_FEATURE =
113
119
  "host-engine-account-scopes-v1";
114
120
  /** The owner-level account scope; every pre-scope account lives here. */
@@ -320,7 +326,10 @@ export type HostLogsEndFrame =
320
326
  message: string;
321
327
  };
322
328
 
323
- export type EngineLoginKind = "claude" | "codex";
329
+ /** Engines with a REMOTE sign-in (login container driven from the cloud).
330
+ * grok since host 0.9.105: `grok login --device-auth`, same device-code shape
331
+ * as codex. Kimi/OpenCode remain host-terminal (or key) for now. */
332
+ export type EngineLoginKind = "claude" | "codex" | "grok";
324
333
  export type EngineLoginPhase =
325
334
  | "starting"
326
335
  | "authorize"
@@ -386,11 +395,15 @@ export type EngineLoginEventFrame =
386
395
  phase: "authorize";
387
396
  url: string;
388
397
  errorCode?: never;
398
+ code?: never;
399
+ accountId?: never;
389
400
  })
390
401
  | (EngineLoginEventBase & {
391
402
  phase: "failed" | "timed_out";
392
403
  url?: never;
393
404
  errorCode?: EngineLoginErrorCode;
405
+ code?: never;
406
+ accountId?: never;
394
407
  })
395
408
  | (EngineLoginEventBase & {
396
409
  phase:
@@ -404,6 +417,12 @@ export type EngineLoginEventFrame =
404
417
  | "cancelled";
405
418
  url?: never;
406
419
  errorCode?: never;
420
+ /** Device-code flow only (`awaiting_confirmation`): the one-time code
421
+ * the operator enters on the provider's page — shown large in the UI. */
422
+ code?: string;
423
+ /** `succeeded` of a LABELED capture: the id of the account it created,
424
+ * so the client can rename/order it right away. */
425
+ accountId?: string;
407
426
  });
408
427
 
409
428
  /** Rolling-safe, secret-free engine state. v1 intentionally makes no claim
@@ -482,6 +501,9 @@ export interface EngineConnectEventFrame {
482
501
  line?: string;
483
502
  ok?: boolean;
484
503
  message?: string;
504
+ /** Manage-v1: `done` of a LABELED connect — the id of the account it
505
+ * created, so the client can name/order it right away. */
506
+ accountId?: string;
485
507
  }
486
508
 
487
509
  export function isEngineConnectKind(
@@ -541,7 +563,7 @@ export function isEngineConnectEventFrame(
541
563
  const frame = exactWireObject(
542
564
  value,
543
565
  ["kind", "opId", "engine", "phase"],
544
- ["line", "ok", "message"],
566
+ ["line", "ok", "message", "accountId"],
545
567
  );
546
568
  if (
547
569
  !frame ||
@@ -551,6 +573,16 @@ export function isEngineConnectEventFrame(
551
573
  ) {
552
574
  return false;
553
575
  }
576
+ if (
577
+ Object.hasOwn(frame, "accountId") &&
578
+ (frame.phase !== "done" ||
579
+ frame.ok !== true ||
580
+ typeof frame.accountId !== "string" ||
581
+ frame.accountId.length < 1 ||
582
+ frame.accountId.length > 256)
583
+ ) {
584
+ return false;
585
+ }
554
586
  if (frame.phase === "line") {
555
587
  return (
556
588
  typeof frame.line === "string" &&
@@ -639,7 +671,7 @@ function isBoundedMessage(value: unknown): value is string {
639
671
  }
640
672
 
641
673
  function isEngineLoginKind(value: unknown): value is EngineLoginKind {
642
- return value === "claude" || value === "codex";
674
+ return value === "claude" || value === "codex" || value === "grok";
643
675
  }
644
676
 
645
677
  function isEngineLoginErrorCode(value: unknown): value is EngineLoginErrorCode {
@@ -773,6 +805,16 @@ export function isEngineLoginStartFrame(
773
805
  /** ADR-116 account labels: operator-chosen display names, never secrets.
774
806
  * Single-line, bounded, and visibly printable so they render safely in every
775
807
  * surface (capability frames, UI chips, log lines). */
808
+ /** A provider device code: short, printable, no whitespace (`AC57-MDV9G`). */
809
+ export function isEngineLoginDeviceCode(value: unknown): value is string {
810
+ return (
811
+ typeof value === "string" &&
812
+ value.length >= 4 &&
813
+ value.length <= 32 &&
814
+ /^[A-Za-z0-9-]+$/.test(value)
815
+ );
816
+ }
817
+
776
818
  export function isEngineAccountLabel(value: unknown): value is string {
777
819
  return (
778
820
  typeof value === "string" &&
@@ -826,7 +868,7 @@ export function isEngineLoginEventFrame(
826
868
  const frame = exactWireObject(
827
869
  value,
828
870
  ["kind", "opId", "engine", "phase"],
829
- ["url", "errorCode", "message"],
871
+ ["url", "errorCode", "message", "code", "accountId"],
830
872
  );
831
873
  if (
832
874
  !frame ||
@@ -837,6 +879,22 @@ export function isEngineLoginEventFrame(
837
879
  ) {
838
880
  return false;
839
881
  }
882
+ // Manage-v1 extras ride exactly one phase each.
883
+ if (
884
+ Object.hasOwn(frame, "code") &&
885
+ (frame.phase !== "awaiting_confirmation" || !isEngineLoginDeviceCode(frame.code))
886
+ ) {
887
+ return false;
888
+ }
889
+ if (
890
+ Object.hasOwn(frame, "accountId") &&
891
+ (frame.phase !== "succeeded" ||
892
+ typeof frame.accountId !== "string" ||
893
+ frame.accountId.length < 1 ||
894
+ frame.accountId.length > 256)
895
+ ) {
896
+ return false;
897
+ }
840
898
  if (frame.phase === "authorize") {
841
899
  return (
842
900
  isHttpsUrl(frame.url) &&
@@ -1422,6 +1480,19 @@ export interface HostCommands {
1422
1480
  ctx: CommandContext,
1423
1481
  input: { id: string; scope?: string },
1424
1482
  ): Promise<HostCommandResult<{ removed: boolean; message?: string }>>;
1483
+ /** Manage-v1: relabel an extra account (never `default:*`); a scope may
1484
+ * rename only its own accounts. Re-advertises on success. */
1485
+ engineAccountRename(
1486
+ ctx: CommandContext,
1487
+ input: { id: string; label: string; scope?: string },
1488
+ ): Promise<HostCommandResult<{ renamed: boolean; message?: string }>>;
1489
+ /** Manage-v1: set the priority order of a kind's extra accounts within a
1490
+ * scope — `ids` must be exactly that set. Tasks pick the first healthy
1491
+ * account in this order. Re-advertises on success. */
1492
+ engineAccountReorder(
1493
+ ctx: CommandContext,
1494
+ input: { kind: string; ids: string[]; scope?: string },
1495
+ ): Promise<HostCommandResult<{ reordered: boolean; message?: string }>>;
1425
1496
  cloneRepo(
1426
1497
  ctx: CommandContext,
1427
1498
  input: CloneRepoInput,