@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.
- package/db/migrations/0018_engine_account_position.sql +3 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +3 -0
- package/lib/engine-accounts.ts +153 -8
- package/lib/engine-connect.ts +3 -1
- package/lib/engine-login.ts +128 -25
- package/lib/standard-image.ts +11 -4
- package/package.json +1 -1
- package/src/index.ts +33 -1
- package/src/main.ts +69 -0
- package/src/protocol.ts +75 -4
|
@@ -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
|
|
package/lib/engine-accounts.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/lib/engine-connect.ts
CHANGED
|
@@ -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
|
}
|
package/lib/engine-login.ts
CHANGED
|
@@ -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 =
|
|
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]
|
|
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 (
|
|
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
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
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:
|
|
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.
|
|
1078
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
:
|
|
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) {
|
package/lib/standard-image.ts
CHANGED
|
@@ -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:
|
|
117
|
-
grok:
|
|
118
|
-
cursor:
|
|
119
|
-
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
package/src/index.ts
CHANGED
|
@@ -118,7 +118,11 @@ export type {
|
|
|
118
118
|
TaskUpResult,
|
|
119
119
|
} from "../lib/agent";
|
|
120
120
|
|
|
121
|
-
import {
|
|
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
|
-
|
|
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,
|