@runuai/host 0.9.102 → 0.9.104
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 +126 -3
- package/lib/engine-connect.ts +3 -1
- package/lib/engine-login.ts +6 -3
- package/package.json +1 -1
- package/src/index.ts +33 -1
- package/src/main.ts +66 -0
- package/src/protocol.ts +70 -2
|
@@ -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";
|
|
@@ -280,7 +281,8 @@ function resolveExtras(
|
|
|
280
281
|
(r) =>
|
|
281
282
|
r.kind === kind &&
|
|
282
283
|
accountInScope((r as { scope?: string | null }).scope, scope),
|
|
283
|
-
)
|
|
284
|
+
)
|
|
285
|
+
.sort(compareAccountRows);
|
|
284
286
|
const out: ResolvedEngineAccount[] = [];
|
|
285
287
|
for (const row of rows) {
|
|
286
288
|
if (row.authKind === "env") {
|
|
@@ -440,8 +442,16 @@ export function selectAccount(
|
|
|
440
442
|
if (candidates.length === 0) return null;
|
|
441
443
|
const lru = (a: ResolvedEngineAccount): number => state(a.id).lastUsedAt;
|
|
442
444
|
const healthy = candidates.filter((a) => state(a.id).cooldownUntil <= now);
|
|
443
|
-
|
|
444
|
-
|
|
445
|
+
// Manage-v1: the list arrives in PRIORITY order (default slot, then extras
|
|
446
|
+
// by position) — the first healthy account wins, so the operator's
|
|
447
|
+
// ordering decides which account a task uses and which are fallbacks.
|
|
448
|
+
// Only when every account is cooling do we fall back to the least recently
|
|
449
|
+
// used one (the stalest cooldown is the likeliest to have lifted).
|
|
450
|
+
if (healthy.length > 0) return healthy[0]!;
|
|
451
|
+
return candidates.reduce(
|
|
452
|
+
(best, a) => (lru(a) < lru(best) ? a : best),
|
|
453
|
+
candidates[0]!,
|
|
454
|
+
);
|
|
445
455
|
}
|
|
446
456
|
|
|
447
457
|
/** Pick an account for a kind (default + extras), applying rotation state. */
|
|
@@ -537,6 +547,16 @@ async function copyDirInto(
|
|
|
537
547
|
}
|
|
538
548
|
};
|
|
539
549
|
await run(["/bin/mkdir", "-p", containerDir], `mkdir ${containerDir}`);
|
|
550
|
+
// The mkdir above runs as root, but a MACHINE copy extracts as the ssh
|
|
551
|
+
// user (node) — into a root-owned directory it dies with "Cannot open:
|
|
552
|
+
// Permission denied" and the account never authenticates (live
|
|
553
|
+
// 2026-09-07, first scoped Codex account on the shared host). Hand the
|
|
554
|
+
// directory to node BEFORE the copy; this also repairs a directory an
|
|
555
|
+
// earlier failed attempt left root-owned on a still-running machine.
|
|
556
|
+
await run(
|
|
557
|
+
["/bin/chown", "-R", "node:node", containerDir],
|
|
558
|
+
`chown -R node:node ${containerDir}`,
|
|
559
|
+
);
|
|
540
560
|
await environment.copy({
|
|
541
561
|
direction: "into",
|
|
542
562
|
source: hostDir,
|
|
@@ -653,6 +673,30 @@ export async function provisionEngineAccounts(
|
|
|
653
673
|
return { copied, failed, mayHaveMutated };
|
|
654
674
|
}
|
|
655
675
|
|
|
676
|
+
/** Priority order within (scope, kind): position, then age. Rows from before
|
|
677
|
+
* manage-v1 all carry 0 and therefore keep their creation order. */
|
|
678
|
+
function compareAccountRows(
|
|
679
|
+
a: { position?: number | null; createdAt: number; id: string },
|
|
680
|
+
b: { position?: number | null; createdAt: number; id: string },
|
|
681
|
+
): number {
|
|
682
|
+
return (
|
|
683
|
+
(a.position ?? 0) - (b.position ?? 0) ||
|
|
684
|
+
a.createdAt - b.createdAt ||
|
|
685
|
+
a.id.localeCompare(b.id)
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** New accounts join the END of their (scope, kind) order. */
|
|
690
|
+
function nextPosition(kind: string, scope: string): number {
|
|
691
|
+
let max = -1;
|
|
692
|
+
for (const row of getDb().select().from(schema.engineAccounts).all()) {
|
|
693
|
+
if (row.kind !== kind) continue;
|
|
694
|
+
if (((row as { scope?: string | null }).scope ?? HOST_ACCOUNT_SCOPE) !== scope) continue;
|
|
695
|
+
max = Math.max(max, (row as { position?: number | null }).position ?? 0);
|
|
696
|
+
}
|
|
697
|
+
return max + 1;
|
|
698
|
+
}
|
|
699
|
+
|
|
656
700
|
// ---------------------------------------------------------------------------
|
|
657
701
|
// Mutations (host UI).
|
|
658
702
|
// ---------------------------------------------------------------------------
|
|
@@ -705,6 +749,7 @@ export function addEngineAccount(
|
|
|
705
749
|
configDir: null,
|
|
706
750
|
createdAt: now,
|
|
707
751
|
scope,
|
|
752
|
+
position: nextPosition(kind, scope),
|
|
708
753
|
})
|
|
709
754
|
.run();
|
|
710
755
|
return { ok: true, message: `Added Claude account "${trimmedLabel}".`, id };
|
|
@@ -723,6 +768,7 @@ export function addEngineAccount(
|
|
|
723
768
|
configDir: null,
|
|
724
769
|
createdAt: now,
|
|
725
770
|
scope,
|
|
771
|
+
position: nextPosition(kind, scope),
|
|
726
772
|
})
|
|
727
773
|
.run();
|
|
728
774
|
return {
|
|
@@ -768,6 +814,7 @@ export function addEngineAccount(
|
|
|
768
814
|
configDir: hostDir,
|
|
769
815
|
createdAt: now,
|
|
770
816
|
scope,
|
|
817
|
+
position: nextPosition(kind, scope),
|
|
771
818
|
})
|
|
772
819
|
.run();
|
|
773
820
|
return { ok: true, message: `Added Codex account "${trimmedLabel}".`, id };
|
|
@@ -842,6 +889,7 @@ export function addCodexAccountFromAuthJson(
|
|
|
842
889
|
configDir: hostDir,
|
|
843
890
|
createdAt: Date.now(),
|
|
844
891
|
scope,
|
|
892
|
+
position: nextPosition("codex", scope),
|
|
845
893
|
})
|
|
846
894
|
.run();
|
|
847
895
|
return { ok: true, message: `Added Codex account "${trimmedLabel}".`, id };
|
|
@@ -890,3 +938,78 @@ export function removeEngineAccount(
|
|
|
890
938
|
}
|
|
891
939
|
return { ok: true, message: `Removed "${row.label}".` };
|
|
892
940
|
}
|
|
941
|
+
|
|
942
|
+
/** Manage-v1: relabel an extra account. Same scope rule as removal. */
|
|
943
|
+
export function renameEngineAccount(
|
|
944
|
+
id: string,
|
|
945
|
+
label: string,
|
|
946
|
+
scope?: string,
|
|
947
|
+
): { ok: boolean; message: string } {
|
|
948
|
+
if (id.startsWith("default:")) {
|
|
949
|
+
return { ok: false, message: "The default account cannot be renamed." };
|
|
950
|
+
}
|
|
951
|
+
const trimmed = label.trim();
|
|
952
|
+
if (!isEngineAccountLabel(trimmed)) {
|
|
953
|
+
return { ok: false, message: "Give the account a label (up to 64 characters)." };
|
|
954
|
+
}
|
|
955
|
+
const row = getDb()
|
|
956
|
+
.select()
|
|
957
|
+
.from(schema.engineAccounts)
|
|
958
|
+
.where(eq(schema.engineAccounts.id, id))
|
|
959
|
+
.get();
|
|
960
|
+
if (!row) return { ok: false, message: "That account no longer exists." };
|
|
961
|
+
if (!accountRemovableInScope(row, scope)) {
|
|
962
|
+
return { ok: false, message: "That account belongs to a different scope." };
|
|
963
|
+
}
|
|
964
|
+
getDb()
|
|
965
|
+
.update(schema.engineAccounts)
|
|
966
|
+
.set({ label: trimmed })
|
|
967
|
+
.where(eq(schema.engineAccounts.id, id))
|
|
968
|
+
.run();
|
|
969
|
+
return { ok: true, message: `Renamed to "${trimmed}".` };
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Manage-v1: set the priority order of `kind`'s extra accounts in `scope`.
|
|
974
|
+
* `ids` must be exactly the set of that kind's rows in the scope — a stale
|
|
975
|
+
* list (an account added or removed meanwhile) is refused rather than
|
|
976
|
+
* silently partially applied.
|
|
977
|
+
*/
|
|
978
|
+
export function reorderEngineAccounts(
|
|
979
|
+
kind: string,
|
|
980
|
+
ids: readonly string[],
|
|
981
|
+
scope: string = HOST_ACCOUNT_SCOPE,
|
|
982
|
+
): { ok: boolean; message: string } {
|
|
983
|
+
if (!isEngineAccountScope(scope)) {
|
|
984
|
+
return { ok: false, message: "The account scope is invalid." };
|
|
985
|
+
}
|
|
986
|
+
const rows = getDb()
|
|
987
|
+
.select()
|
|
988
|
+
.from(schema.engineAccounts)
|
|
989
|
+
.all()
|
|
990
|
+
.filter(
|
|
991
|
+
(r) =>
|
|
992
|
+
r.kind === kind &&
|
|
993
|
+
((r as { scope?: string | null }).scope ?? HOST_ACCOUNT_SCOPE) === scope,
|
|
994
|
+
);
|
|
995
|
+
const have = new Set(rows.map((r) => r.id));
|
|
996
|
+
const want = new Set(ids);
|
|
997
|
+
if (
|
|
998
|
+
have.size !== want.size ||
|
|
999
|
+
ids.length !== want.size ||
|
|
1000
|
+
[...have].some((id) => !want.has(id))
|
|
1001
|
+
) {
|
|
1002
|
+
return {
|
|
1003
|
+
ok: false,
|
|
1004
|
+
message: "The account list changed — refresh and try again.",
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
ids.forEach((id, index) => {
|
|
1008
|
+
getDb()
|
|
1009
|
+
.update(schema.engineAccounts)
|
|
1010
|
+
.set({ position: index })
|
|
1011
|
+
.where(eq(schema.engineAccounts.id, id))
|
|
1012
|
+
.run();
|
|
1013
|
+
});
|
|
1014
|
+
return { ok: true, message: "Order saved." };
|
|
1015
|
+
}
|
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
|
@@ -151,12 +151,12 @@ export interface EngineLoginSeams {
|
|
|
151
151
|
label: string,
|
|
152
152
|
token: string,
|
|
153
153
|
scope: string,
|
|
154
|
-
): { ok: boolean; message: string };
|
|
154
|
+
): { ok: boolean; message: string; id?: string };
|
|
155
155
|
addCodexAccount(
|
|
156
156
|
label: string,
|
|
157
157
|
authJson: string,
|
|
158
158
|
scope: string,
|
|
159
|
-
): { ok: boolean; message: string };
|
|
159
|
+
): { ok: boolean; message: string; id?: string };
|
|
160
160
|
refreshEngineAccountAgents(engine: "claude"): Promise<void>;
|
|
161
161
|
reinjectCodexRunningTasks(): Promise<void>;
|
|
162
162
|
ownerHome(): string;
|
|
@@ -868,6 +868,7 @@ export class EngineLoginManager {
|
|
|
868
868
|
opId: operation.opId,
|
|
869
869
|
engine: operation.engine,
|
|
870
870
|
phase: "awaiting_confirmation",
|
|
871
|
+
code: device.userCode,
|
|
871
872
|
message: `Enter code ${device.userCode} on the verification page. Nothing to paste here — this completes on its own.`,
|
|
872
873
|
});
|
|
873
874
|
return;
|
|
@@ -983,6 +984,7 @@ export class EngineLoginManager {
|
|
|
983
984
|
engine: operation.engine,
|
|
984
985
|
phase: "succeeded",
|
|
985
986
|
message: added.message,
|
|
987
|
+
...(added.id === undefined ? {} : { accountId: added.id }),
|
|
986
988
|
}
|
|
987
989
|
: failedEvent(
|
|
988
990
|
operation.opId,
|
|
@@ -1039,7 +1041,7 @@ export class EngineLoginManager {
|
|
|
1039
1041
|
if (operation.accountLabel !== null) {
|
|
1040
1042
|
// ADR-116: labeled capture — the auth.json this login produced becomes
|
|
1041
1043
|
// an isolated extra account instead of overwriting ~/.codex.
|
|
1042
|
-
let added: { ok: boolean; message: string };
|
|
1044
|
+
let added: { ok: boolean; message: string; id?: string };
|
|
1043
1045
|
try {
|
|
1044
1046
|
const contents = await readFile(
|
|
1045
1047
|
join(tempDir, "codex", "auth.json"),
|
|
@@ -1063,6 +1065,7 @@ export class EngineLoginManager {
|
|
|
1063
1065
|
engine: operation.engine,
|
|
1064
1066
|
phase: "succeeded",
|
|
1065
1067
|
message: added.message,
|
|
1068
|
+
...(added.id === undefined ? {} : { accountId: added.id }),
|
|
1066
1069
|
}
|
|
1067
1070
|
: failedEvent(
|
|
1068
1071
|
operation.opId,
|
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);
|
|
@@ -609,6 +611,7 @@ function buildCapabilities(): HostCapabilities {
|
|
|
609
611
|
HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE,
|
|
610
612
|
HOST_ENGINE_CONNECT_PROTOCOL_FEATURE,
|
|
611
613
|
HOST_ENGINE_ACCOUNT_SCOPES_PROTOCOL_FEATURE,
|
|
614
|
+
HOST_ENGINE_ACCOUNT_MANAGE_PROTOCOL_FEATURE,
|
|
612
615
|
// The echo adapter cannot execute the in-task CLI. Advertising typed
|
|
613
616
|
// dispatch in mock mode would let the composer create a Secretary that
|
|
614
617
|
// has no way to wake crew.
|
|
@@ -2443,9 +2446,70 @@ async function dispatchCommand(
|
|
|
2443
2446
|
if (removal.ok) sendCapabilities();
|
|
2444
2447
|
return removal;
|
|
2445
2448
|
}
|
|
2449
|
+
case "engineAccountRename": {
|
|
2450
|
+
const renamed = await hostCommands.engineAccountRename(
|
|
2451
|
+
ctx,
|
|
2452
|
+
expectEngineAccountRenameInput(args, 0),
|
|
2453
|
+
);
|
|
2454
|
+
if (renamed.ok) sendCapabilities();
|
|
2455
|
+
return renamed;
|
|
2456
|
+
}
|
|
2457
|
+
case "engineAccountReorder": {
|
|
2458
|
+
const reordered = await hostCommands.engineAccountReorder(
|
|
2459
|
+
ctx,
|
|
2460
|
+
expectEngineAccountReorderInput(args, 0),
|
|
2461
|
+
);
|
|
2462
|
+
if (reordered.ok) sendCapabilities();
|
|
2463
|
+
return reordered;
|
|
2464
|
+
}
|
|
2446
2465
|
}
|
|
2447
2466
|
}
|
|
2448
2467
|
|
|
2468
|
+
function expectEngineAccountRenameInput(
|
|
2469
|
+
args: unknown[],
|
|
2470
|
+
index: number,
|
|
2471
|
+
): { id: string; label: string; scope?: string } {
|
|
2472
|
+
const base = expectEngineAccountRemoveInput(args, index);
|
|
2473
|
+
const label = (args[index] as { label?: unknown }).label;
|
|
2474
|
+
if (!isEngineAccountLabel(label)) {
|
|
2475
|
+
throw new Error(`invalid command args: bad account label at ${index}`);
|
|
2476
|
+
}
|
|
2477
|
+
return { ...base, label };
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
function expectEngineAccountReorderInput(
|
|
2481
|
+
args: unknown[],
|
|
2482
|
+
index: number,
|
|
2483
|
+
): { kind: string; ids: string[]; scope?: string } {
|
|
2484
|
+
const value = args[index];
|
|
2485
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
2486
|
+
throw new Error(`invalid command args: expected reorder input at ${index}`);
|
|
2487
|
+
}
|
|
2488
|
+
const { kind, ids, scope } = value as {
|
|
2489
|
+
kind?: unknown;
|
|
2490
|
+
ids?: unknown;
|
|
2491
|
+
scope?: unknown;
|
|
2492
|
+
};
|
|
2493
|
+
if (typeof kind !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(kind)) {
|
|
2494
|
+
throw new Error(`invalid command args: bad account kind at ${index}`);
|
|
2495
|
+
}
|
|
2496
|
+
if (
|
|
2497
|
+
!Array.isArray(ids) ||
|
|
2498
|
+
ids.length > 64 ||
|
|
2499
|
+
ids.some((id) => typeof id !== "string" || id.length === 0 || id.length > 256)
|
|
2500
|
+
) {
|
|
2501
|
+
throw new Error(`invalid command args: bad account id list at ${index}`);
|
|
2502
|
+
}
|
|
2503
|
+
if (scope !== undefined && !isEngineAccountScope(scope)) {
|
|
2504
|
+
throw new Error(`invalid command args: bad account scope at ${index}`);
|
|
2505
|
+
}
|
|
2506
|
+
return {
|
|
2507
|
+
kind,
|
|
2508
|
+
ids: ids as string[],
|
|
2509
|
+
...(scope !== undefined ? { scope } : {}),
|
|
2510
|
+
};
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2449
2513
|
function expectEngineAccountRemoveInput(
|
|
2450
2514
|
args: unknown[],
|
|
2451
2515
|
index: number,
|
|
@@ -2981,6 +3045,8 @@ const HOST_COMMAND_NAMES = {
|
|
|
2981
3045
|
appendTranscript: true,
|
|
2982
3046
|
previewEnsure: true,
|
|
2983
3047
|
engineAccountRemove: true,
|
|
3048
|
+
engineAccountRename: true,
|
|
3049
|
+
engineAccountReorder: true,
|
|
2984
3050
|
} satisfies Record<keyof HostCommands, true>;
|
|
2985
3051
|
|
|
2986
3052
|
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. */
|
|
@@ -386,11 +392,15 @@ export type EngineLoginEventFrame =
|
|
|
386
392
|
phase: "authorize";
|
|
387
393
|
url: string;
|
|
388
394
|
errorCode?: never;
|
|
395
|
+
code?: never;
|
|
396
|
+
accountId?: never;
|
|
389
397
|
})
|
|
390
398
|
| (EngineLoginEventBase & {
|
|
391
399
|
phase: "failed" | "timed_out";
|
|
392
400
|
url?: never;
|
|
393
401
|
errorCode?: EngineLoginErrorCode;
|
|
402
|
+
code?: never;
|
|
403
|
+
accountId?: never;
|
|
394
404
|
})
|
|
395
405
|
| (EngineLoginEventBase & {
|
|
396
406
|
phase:
|
|
@@ -404,6 +414,12 @@ export type EngineLoginEventFrame =
|
|
|
404
414
|
| "cancelled";
|
|
405
415
|
url?: never;
|
|
406
416
|
errorCode?: never;
|
|
417
|
+
/** Device-code flow only (`awaiting_confirmation`): the one-time code
|
|
418
|
+
* the operator enters on the provider's page — shown large in the UI. */
|
|
419
|
+
code?: string;
|
|
420
|
+
/** `succeeded` of a LABELED capture: the id of the account it created,
|
|
421
|
+
* so the client can rename/order it right away. */
|
|
422
|
+
accountId?: string;
|
|
407
423
|
});
|
|
408
424
|
|
|
409
425
|
/** Rolling-safe, secret-free engine state. v1 intentionally makes no claim
|
|
@@ -482,6 +498,9 @@ export interface EngineConnectEventFrame {
|
|
|
482
498
|
line?: string;
|
|
483
499
|
ok?: boolean;
|
|
484
500
|
message?: string;
|
|
501
|
+
/** Manage-v1: `done` of a LABELED connect — the id of the account it
|
|
502
|
+
* created, so the client can name/order it right away. */
|
|
503
|
+
accountId?: string;
|
|
485
504
|
}
|
|
486
505
|
|
|
487
506
|
export function isEngineConnectKind(
|
|
@@ -541,7 +560,7 @@ export function isEngineConnectEventFrame(
|
|
|
541
560
|
const frame = exactWireObject(
|
|
542
561
|
value,
|
|
543
562
|
["kind", "opId", "engine", "phase"],
|
|
544
|
-
["line", "ok", "message"],
|
|
563
|
+
["line", "ok", "message", "accountId"],
|
|
545
564
|
);
|
|
546
565
|
if (
|
|
547
566
|
!frame ||
|
|
@@ -551,6 +570,16 @@ export function isEngineConnectEventFrame(
|
|
|
551
570
|
) {
|
|
552
571
|
return false;
|
|
553
572
|
}
|
|
573
|
+
if (
|
|
574
|
+
Object.hasOwn(frame, "accountId") &&
|
|
575
|
+
(frame.phase !== "done" ||
|
|
576
|
+
frame.ok !== true ||
|
|
577
|
+
typeof frame.accountId !== "string" ||
|
|
578
|
+
frame.accountId.length < 1 ||
|
|
579
|
+
frame.accountId.length > 256)
|
|
580
|
+
) {
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
554
583
|
if (frame.phase === "line") {
|
|
555
584
|
return (
|
|
556
585
|
typeof frame.line === "string" &&
|
|
@@ -773,6 +802,16 @@ export function isEngineLoginStartFrame(
|
|
|
773
802
|
/** ADR-116 account labels: operator-chosen display names, never secrets.
|
|
774
803
|
* Single-line, bounded, and visibly printable so they render safely in every
|
|
775
804
|
* surface (capability frames, UI chips, log lines). */
|
|
805
|
+
/** A provider device code: short, printable, no whitespace (`AC57-MDV9G`). */
|
|
806
|
+
export function isEngineLoginDeviceCode(value: unknown): value is string {
|
|
807
|
+
return (
|
|
808
|
+
typeof value === "string" &&
|
|
809
|
+
value.length >= 4 &&
|
|
810
|
+
value.length <= 32 &&
|
|
811
|
+
/^[A-Za-z0-9-]+$/.test(value)
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
|
|
776
815
|
export function isEngineAccountLabel(value: unknown): value is string {
|
|
777
816
|
return (
|
|
778
817
|
typeof value === "string" &&
|
|
@@ -826,7 +865,7 @@ export function isEngineLoginEventFrame(
|
|
|
826
865
|
const frame = exactWireObject(
|
|
827
866
|
value,
|
|
828
867
|
["kind", "opId", "engine", "phase"],
|
|
829
|
-
["url", "errorCode", "message"],
|
|
868
|
+
["url", "errorCode", "message", "code", "accountId"],
|
|
830
869
|
);
|
|
831
870
|
if (
|
|
832
871
|
!frame ||
|
|
@@ -837,6 +876,22 @@ export function isEngineLoginEventFrame(
|
|
|
837
876
|
) {
|
|
838
877
|
return false;
|
|
839
878
|
}
|
|
879
|
+
// Manage-v1 extras ride exactly one phase each.
|
|
880
|
+
if (
|
|
881
|
+
Object.hasOwn(frame, "code") &&
|
|
882
|
+
(frame.phase !== "awaiting_confirmation" || !isEngineLoginDeviceCode(frame.code))
|
|
883
|
+
) {
|
|
884
|
+
return false;
|
|
885
|
+
}
|
|
886
|
+
if (
|
|
887
|
+
Object.hasOwn(frame, "accountId") &&
|
|
888
|
+
(frame.phase !== "succeeded" ||
|
|
889
|
+
typeof frame.accountId !== "string" ||
|
|
890
|
+
frame.accountId.length < 1 ||
|
|
891
|
+
frame.accountId.length > 256)
|
|
892
|
+
) {
|
|
893
|
+
return false;
|
|
894
|
+
}
|
|
840
895
|
if (frame.phase === "authorize") {
|
|
841
896
|
return (
|
|
842
897
|
isHttpsUrl(frame.url) &&
|
|
@@ -1422,6 +1477,19 @@ export interface HostCommands {
|
|
|
1422
1477
|
ctx: CommandContext,
|
|
1423
1478
|
input: { id: string; scope?: string },
|
|
1424
1479
|
): Promise<HostCommandResult<{ removed: boolean; message?: string }>>;
|
|
1480
|
+
/** Manage-v1: relabel an extra account (never `default:*`); a scope may
|
|
1481
|
+
* rename only its own accounts. Re-advertises on success. */
|
|
1482
|
+
engineAccountRename(
|
|
1483
|
+
ctx: CommandContext,
|
|
1484
|
+
input: { id: string; label: string; scope?: string },
|
|
1485
|
+
): Promise<HostCommandResult<{ renamed: boolean; message?: string }>>;
|
|
1486
|
+
/** Manage-v1: set the priority order of a kind's extra accounts within a
|
|
1487
|
+
* scope — `ids` must be exactly that set. Tasks pick the first healthy
|
|
1488
|
+
* account in this order. Re-advertises on success. */
|
|
1489
|
+
engineAccountReorder(
|
|
1490
|
+
ctx: CommandContext,
|
|
1491
|
+
input: { kind: string; ids: string[]; scope?: string },
|
|
1492
|
+
): Promise<HostCommandResult<{ reordered: boolean; message?: string }>>;
|
|
1425
1493
|
cloneRepo(
|
|
1426
1494
|
ctx: CommandContext,
|
|
1427
1495
|
input: CloneRepoInput,
|