@runuai/host 0.9.47 → 0.9.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/engine-accounts.ts +63 -0
- package/lib/engine-login.ts +110 -2
- package/lib/standard-image-pin.ts +48 -3
- package/lib/standard-image.ts +4 -2
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +2 -1
- package/src/index.ts +18 -0
- package/src/main.ts +60 -1
- package/src/protocol.ts +45 -2
package/lib/engine-accounts.ts
CHANGED
|
@@ -592,6 +592,69 @@ export function addEngineAccount(
|
|
|
592
592
|
};
|
|
593
593
|
}
|
|
594
594
|
|
|
595
|
+
/**
|
|
596
|
+
* ADR-116: register an EXTRA Codex account from a browser-login capture — the
|
|
597
|
+
* verbatim `auth.json` a `codex login` flow produced (OAuth tokens, not an
|
|
598
|
+
* API key), written into the same isolated `~/.codex-acct-<id>` layout the
|
|
599
|
+
* pasted-key path uses. Callers pass the file CONTENTS; nothing is logged.
|
|
600
|
+
*/
|
|
601
|
+
export function addCodexAccountFromAuthJson(
|
|
602
|
+
label: string,
|
|
603
|
+
authJson: string,
|
|
604
|
+
seams: Partial<AccountSeams> = {},
|
|
605
|
+
): AddAccountResult {
|
|
606
|
+
const s = withDefaults(seams);
|
|
607
|
+
const trimmedLabel = label.trim();
|
|
608
|
+
if (!trimmedLabel) return { ok: false, message: "Give the account a label." };
|
|
609
|
+
try {
|
|
610
|
+
const parsed: unknown = JSON.parse(authJson);
|
|
611
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
612
|
+
throw new Error("not an object");
|
|
613
|
+
}
|
|
614
|
+
} catch {
|
|
615
|
+
return { ok: false, message: "The captured Codex credential is not valid." };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const id = newId();
|
|
619
|
+
const d = KINDS.codex as KindDescriptor;
|
|
620
|
+
const hostDir = (d.extraHostDir as (o: string, i: string) => string)(
|
|
621
|
+
s.ownerHome(),
|
|
622
|
+
id,
|
|
623
|
+
);
|
|
624
|
+
try {
|
|
625
|
+
const file = join(hostDir, "auth.json");
|
|
626
|
+
mkdirSync(hostDir, { recursive: true });
|
|
627
|
+
writeFileSync(file, authJson.endsWith("\n") ? authJson : `${authJson}\n`, {
|
|
628
|
+
mode: 0o600,
|
|
629
|
+
});
|
|
630
|
+
try {
|
|
631
|
+
chmodSync(file, 0o600);
|
|
632
|
+
} catch {
|
|
633
|
+
/* best effort */
|
|
634
|
+
}
|
|
635
|
+
} catch (err) {
|
|
636
|
+
return {
|
|
637
|
+
ok: false,
|
|
638
|
+
message: `Could not write the account config: ${
|
|
639
|
+
err instanceof Error ? err.message : String(err)
|
|
640
|
+
}`,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
getDb()
|
|
644
|
+
.insert(schema.engineAccounts)
|
|
645
|
+
.values({
|
|
646
|
+
id,
|
|
647
|
+
kind: "codex",
|
|
648
|
+
label: trimmedLabel,
|
|
649
|
+
authKind: "config-dir",
|
|
650
|
+
secretEnc: null,
|
|
651
|
+
configDir: hostDir,
|
|
652
|
+
createdAt: Date.now(),
|
|
653
|
+
})
|
|
654
|
+
.run();
|
|
655
|
+
return { ok: true, message: `Added Codex account "${trimmedLabel}".`, id };
|
|
656
|
+
}
|
|
657
|
+
|
|
595
658
|
/** Remove an EXTRA account (idempotent). The synthesized default can't be
|
|
596
659
|
* removed here (use the engine disconnect flow for the legacy slot). The
|
|
597
660
|
* isolated host config dir of a config-dir account is deleted too. */
|
package/lib/engine-login.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
mkdtemp,
|
|
18
18
|
open,
|
|
19
19
|
readdir,
|
|
20
|
+
readFile,
|
|
20
21
|
realpath,
|
|
21
22
|
rename,
|
|
22
23
|
rm,
|
|
@@ -26,6 +27,7 @@ import { homedir, tmpdir } from "node:os";
|
|
|
26
27
|
import { basename, dirname, join, resolve } from "node:path";
|
|
27
28
|
|
|
28
29
|
import {
|
|
30
|
+
isEngineAccountLabel,
|
|
29
31
|
isHostOpId,
|
|
30
32
|
MAX_ENGINE_LOGIN_QUERY_CHARS,
|
|
31
33
|
MAX_ENGINE_LOGIN_TEXT_CHARS,
|
|
@@ -35,6 +37,10 @@ import {
|
|
|
35
37
|
type EngineLoginEventFrame,
|
|
36
38
|
type EngineLoginKind,
|
|
37
39
|
} from "../src/protocol";
|
|
40
|
+
import {
|
|
41
|
+
addCodexAccountFromAuthJson,
|
|
42
|
+
addEngineAccount,
|
|
43
|
+
} from "./engine-accounts";
|
|
38
44
|
import { reinjectCodexRunningTasks } from "./codex-auth";
|
|
39
45
|
import {
|
|
40
46
|
appleContainerRuntimeBinding,
|
|
@@ -125,6 +131,9 @@ export interface EngineLoginSeams {
|
|
|
125
131
|
reconcileStaleContainers(): Promise<void>;
|
|
126
132
|
persistClaudeToken(token: string): Promise<void>;
|
|
127
133
|
persistCodexAuth(sourceFile: string): Promise<void>;
|
|
134
|
+
/** ADR-116: labeled extra-account captures (never the default slots). */
|
|
135
|
+
addClaudeAccount(label: string, token: string): { ok: boolean; message: string };
|
|
136
|
+
addCodexAccount(label: string, authJson: string): { ok: boolean; message: string };
|
|
128
137
|
refreshEngineAccountAgents(engine: "claude"): Promise<void>;
|
|
129
138
|
reinjectCodexRunningTasks(): Promise<void>;
|
|
130
139
|
ownerHome(): string;
|
|
@@ -178,6 +187,9 @@ interface LoginOperation {
|
|
|
178
187
|
callbackUsed: boolean;
|
|
179
188
|
pendingInput: string | null;
|
|
180
189
|
callbackTarget: CodexCallbackTarget | null;
|
|
190
|
+
/** ADR-116: when set, the captured credential lands in a labeled extra
|
|
191
|
+
* account instead of the default slot. */
|
|
192
|
+
accountLabel: string | null;
|
|
181
193
|
}
|
|
182
194
|
|
|
183
195
|
const defaultTimers: EngineLoginTimers = {
|
|
@@ -253,6 +265,10 @@ function defaultSeams(options: EngineLoginManagerOptions): EngineLoginSeams {
|
|
|
253
265
|
reconcileStaleEngineLoginResources({ tempRoot }),
|
|
254
266
|
persistClaudeToken: (token) => persistClaudeTokenAtomic(token),
|
|
255
267
|
persistCodexAuth: (sourceFile) => persistCodexAuthAtomic(sourceFile),
|
|
268
|
+
addClaudeAccount: (label, token) =>
|
|
269
|
+
addEngineAccount("claude", label, { token }),
|
|
270
|
+
addCodexAccount: (label, authJson) =>
|
|
271
|
+
addCodexAccountFromAuthJson(label, authJson),
|
|
256
272
|
refreshEngineAccountAgents: options.refreshEngineAccountAgents,
|
|
257
273
|
reinjectCodexRunningTasks: () => reinjectCodexRunningTasks(),
|
|
258
274
|
ownerHome,
|
|
@@ -319,8 +335,16 @@ export class EngineLoginManager {
|
|
|
319
335
|
opId: string,
|
|
320
336
|
engine: EngineLoginKind,
|
|
321
337
|
emit: EmitEngineLoginEvent,
|
|
338
|
+
label?: string,
|
|
322
339
|
): boolean {
|
|
323
340
|
if (!isHostOpId(opId)) return false;
|
|
341
|
+
if (label !== undefined && !isEngineAccountLabel(label)) {
|
|
342
|
+
safeEmit(
|
|
343
|
+
emit,
|
|
344
|
+
failedEvent(opId, engine, "start_failed", "The account label is invalid."),
|
|
345
|
+
);
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
324
348
|
if (this.operations.has(opId)) {
|
|
325
349
|
safeEmit(emit, failedEvent(opId, engine, "unavailable", "Login operation already exists."));
|
|
326
350
|
return false;
|
|
@@ -366,6 +390,7 @@ export class EngineLoginManager {
|
|
|
366
390
|
callbackUsed: false,
|
|
367
391
|
pendingInput: null,
|
|
368
392
|
callbackTarget: null,
|
|
393
|
+
accountLabel: label ?? null,
|
|
369
394
|
};
|
|
370
395
|
this.operations.set(opId, operation);
|
|
371
396
|
this.engineOperations.set(engine, operation);
|
|
@@ -408,7 +433,13 @@ export class EngineLoginManager {
|
|
|
408
433
|
}
|
|
409
434
|
operation.inputUsed = true;
|
|
410
435
|
operation.outputTail = "";
|
|
411
|
-
|
|
436
|
+
// CARRIAGE RETURN, not newline: `claude setup-token` is an Ink raw-mode
|
|
437
|
+
// TUI whose code prompt submits only on \r. A trailing \n types the code
|
|
438
|
+
// and then sits forever — reproduced live 2026-08-20 (fake code + \n →
|
|
439
|
+
// masked input, no response; the same code + \r → immediate "OAuth
|
|
440
|
+
// error: Invalid code"). Every cloud-pane Claude login ever attempted
|
|
441
|
+
// hung on exactly this.
|
|
442
|
+
const value = `${text}\r`;
|
|
412
443
|
if (!operation.process) {
|
|
413
444
|
operation.pendingInput = value;
|
|
414
445
|
return true;
|
|
@@ -687,6 +718,28 @@ export class EngineLoginManager {
|
|
|
687
718
|
token: string,
|
|
688
719
|
): Promise<void> {
|
|
689
720
|
let terminal: EngineLoginEventFrame;
|
|
721
|
+
if (operation.accountLabel !== null) {
|
|
722
|
+
// ADR-116: labeled capture — an extra account, not the default slot.
|
|
723
|
+
// Extras are picked up at the next agent spawn / rotation, so no
|
|
724
|
+
// running-agent refresh is needed (mirrors the local accounts API).
|
|
725
|
+
const added = this.seams.addClaudeAccount(operation.accountLabel, token);
|
|
726
|
+
terminal = added.ok
|
|
727
|
+
? {
|
|
728
|
+
kind: "engine.login.event",
|
|
729
|
+
opId: operation.opId,
|
|
730
|
+
engine: operation.engine,
|
|
731
|
+
phase: "succeeded",
|
|
732
|
+
message: added.message,
|
|
733
|
+
}
|
|
734
|
+
: failedEvent(
|
|
735
|
+
operation.opId,
|
|
736
|
+
operation.engine,
|
|
737
|
+
"persist_failed",
|
|
738
|
+
added.message,
|
|
739
|
+
);
|
|
740
|
+
await this.finishCommit(operation, terminal);
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
690
743
|
try {
|
|
691
744
|
await this.seams.persistClaudeToken(token);
|
|
692
745
|
} catch {
|
|
@@ -730,6 +783,39 @@ export class EngineLoginManager {
|
|
|
730
783
|
const tempDir = operation.tempDir;
|
|
731
784
|
if (!tempDir) return;
|
|
732
785
|
let terminal: EngineLoginEventFrame;
|
|
786
|
+
if (operation.accountLabel !== null) {
|
|
787
|
+
// ADR-116: labeled capture — the auth.json this login produced becomes
|
|
788
|
+
// an isolated extra account instead of overwriting ~/.codex.
|
|
789
|
+
let added: { ok: boolean; message: string };
|
|
790
|
+
try {
|
|
791
|
+
const contents = await readFile(
|
|
792
|
+
join(tempDir, "codex", "auth.json"),
|
|
793
|
+
"utf8",
|
|
794
|
+
);
|
|
795
|
+
added = this.seams.addCodexAccount(operation.accountLabel, contents);
|
|
796
|
+
} catch {
|
|
797
|
+
added = {
|
|
798
|
+
ok: false,
|
|
799
|
+
message: "Codex authorized, but its credential could not be read.",
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
terminal = added.ok
|
|
803
|
+
? {
|
|
804
|
+
kind: "engine.login.event",
|
|
805
|
+
opId: operation.opId,
|
|
806
|
+
engine: operation.engine,
|
|
807
|
+
phase: "succeeded",
|
|
808
|
+
message: added.message,
|
|
809
|
+
}
|
|
810
|
+
: failedEvent(
|
|
811
|
+
operation.opId,
|
|
812
|
+
operation.engine,
|
|
813
|
+
"persist_failed",
|
|
814
|
+
added.message,
|
|
815
|
+
);
|
|
816
|
+
await this.finishCommit(operation, terminal);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
733
819
|
try {
|
|
734
820
|
await this.seams.persistCodexAuth(
|
|
735
821
|
join(tempDir, "codex", "auth.json"),
|
|
@@ -770,6 +856,10 @@ export class EngineLoginManager {
|
|
|
770
856
|
await operation.finishPromise;
|
|
771
857
|
return;
|
|
772
858
|
}
|
|
859
|
+
// ADR-116: attach the CLI's last visible line — a remote operator staring
|
|
860
|
+
// at "Waiting for the host…" (live 2026-08-20, apple-container) otherwise
|
|
861
|
+
// has zero evidence of where the flow died.
|
|
862
|
+
const evidence = lastSignificantOutputLine(operation.outputTail);
|
|
773
863
|
this.detach(operation);
|
|
774
864
|
safeEmit(operation.emit, {
|
|
775
865
|
kind: "engine.login.event",
|
|
@@ -777,7 +867,9 @@ export class EngineLoginManager {
|
|
|
777
867
|
engine: operation.engine,
|
|
778
868
|
phase: "timed_out",
|
|
779
869
|
errorCode: "timeout",
|
|
780
|
-
message:
|
|
870
|
+
message: evidence
|
|
871
|
+
? `Login timed out. Last output: ${evidence}`
|
|
872
|
+
: "Login timed out.",
|
|
781
873
|
});
|
|
782
874
|
await this.ensureSettlement(operation);
|
|
783
875
|
}
|
|
@@ -916,6 +1008,22 @@ export function createEngineLoginManager(
|
|
|
916
1008
|
return new EngineLoginManager(options);
|
|
917
1009
|
}
|
|
918
1010
|
|
|
1011
|
+
/** The last visible, sanitized line of the login CLI's output — bounded and
|
|
1012
|
+
* secret-checked so it can ride a terminal event message (ADR-116). Anything
|
|
1013
|
+
* token-shaped disqualifies the whole line rather than risking a partial. */
|
|
1014
|
+
function lastSignificantOutputLine(tail: string): string | null {
|
|
1015
|
+
const lines = tail
|
|
1016
|
+
.replace(/\u001b\[[0-9;?]*[ -\/]*[@-~]/g, "")
|
|
1017
|
+
.replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, "")
|
|
1018
|
+
.split("\n")
|
|
1019
|
+
.map((line) => line.trim())
|
|
1020
|
+
.filter((line) => line.length > 3);
|
|
1021
|
+
const last = lines.at(-1);
|
|
1022
|
+
if (!last) return null;
|
|
1023
|
+
if (/sk-ant-|Bearer |[A-Za-z0-9_-]{40,}/.test(last)) return null;
|
|
1024
|
+
return last.slice(0, 200);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
919
1027
|
function safeEmit(
|
|
920
1028
|
emit: EmitEngineLoginEvent,
|
|
921
1029
|
event: EngineLoginEventFrame,
|
|
@@ -49,6 +49,14 @@ export interface StandardImagePin {
|
|
|
49
49
|
readonly ref: string;
|
|
50
50
|
readonly digest: string;
|
|
51
51
|
readonly contextHash: string;
|
|
52
|
+
/**
|
|
53
|
+
* Per-architecture image digests. A host pulls ITS OWN architecture's
|
|
54
|
+
* digest — pulling the manifest-list digest stores a two-variant image on
|
|
55
|
+
* the Apple runtime, and task-up's apple guard refuses any task image with
|
|
56
|
+
* more than one variant (found live on the Air's first task, 2026-08-20).
|
|
57
|
+
* Optional so a list-only pin still works on Docker via `digest`.
|
|
58
|
+
*/
|
|
59
|
+
readonly platforms?: { readonly arm64?: string; readonly x64?: string };
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
// The pin arrives inside a signature-verified payload; this grammar is
|
|
@@ -67,7 +75,10 @@ export function parseStandardImagePin(raw: string): StandardImagePin | null {
|
|
|
67
75
|
return null;
|
|
68
76
|
}
|
|
69
77
|
if (typeof parsed !== "object" || parsed === null) return null;
|
|
70
|
-
const { ref, digest, contextHash } = parsed as Record<
|
|
78
|
+
const { ref, digest, contextHash, platforms } = parsed as Record<
|
|
79
|
+
string,
|
|
80
|
+
unknown
|
|
81
|
+
>;
|
|
71
82
|
if (
|
|
72
83
|
typeof ref !== "string" ||
|
|
73
84
|
typeof digest !== "string" ||
|
|
@@ -78,7 +89,40 @@ export function parseStandardImagePin(raw: string): StandardImagePin | null {
|
|
|
78
89
|
) {
|
|
79
90
|
return null;
|
|
80
91
|
}
|
|
81
|
-
|
|
92
|
+
let parsedPlatforms: { arm64?: string; x64?: string } | undefined;
|
|
93
|
+
if (platforms !== undefined) {
|
|
94
|
+
if (typeof platforms !== "object" || platforms === null) return null;
|
|
95
|
+
parsedPlatforms = {};
|
|
96
|
+
for (const arch of ["arm64", "x64"] as const) {
|
|
97
|
+
const value = (platforms as Record<string, unknown>)[arch];
|
|
98
|
+
if (value === undefined) continue;
|
|
99
|
+
if (typeof value !== "string" || !DIGEST_PATTERN.test(value)) return null;
|
|
100
|
+
parsedPlatforms[arch] = value;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
ref,
|
|
105
|
+
digest,
|
|
106
|
+
contextHash,
|
|
107
|
+
...(parsedPlatforms ? { platforms: parsedPlatforms } : {}),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The digest a given host should pull: its own architecture's single-variant
|
|
113
|
+
* image when the pin carries one, else the manifest list. Docker resolves a
|
|
114
|
+
* list to the native variant; the Apple runtime stores the whole index, which
|
|
115
|
+
* task-up's one-variant guard then refuses — so arch digests win.
|
|
116
|
+
*/
|
|
117
|
+
export function standardImagePullDigest(
|
|
118
|
+
pin: StandardImagePin,
|
|
119
|
+
arch: string = process.arch,
|
|
120
|
+
): string {
|
|
121
|
+
if (arch === "arm64" || arch === "x64") {
|
|
122
|
+
const platformDigest = pin.platforms?.[arch];
|
|
123
|
+
if (platformDigest !== undefined) return platformDigest;
|
|
124
|
+
}
|
|
125
|
+
return pin.digest;
|
|
82
126
|
}
|
|
83
127
|
|
|
84
128
|
/** The exact engine argv for pulling and tagging the pinned image. Docker
|
|
@@ -87,8 +131,9 @@ export function standardImagePullCommands(
|
|
|
87
131
|
pin: StandardImagePin,
|
|
88
132
|
targetTag: string,
|
|
89
133
|
apple: boolean,
|
|
134
|
+
arch: string = process.arch,
|
|
90
135
|
): { pull: string[]; tag: string[] } {
|
|
91
|
-
const source = `${pin.ref}@${pin
|
|
136
|
+
const source = `${pin.ref}@${standardImagePullDigest(pin, arch)}`;
|
|
92
137
|
return apple
|
|
93
138
|
? {
|
|
94
139
|
pull: ["image", "pull", source],
|
package/lib/standard-image.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
STANDARD_IMAGE_PIN_BASENAME,
|
|
43
43
|
type StandardImagePin,
|
|
44
44
|
standardImagePullCommands,
|
|
45
|
+
standardImagePullDigest,
|
|
45
46
|
} from "./standard-image-pin";
|
|
46
47
|
|
|
47
48
|
/** Pinned, host-wide constants (must match task-up.sh and the compose gen). */
|
|
@@ -1259,8 +1260,9 @@ async function ensureStandardImageInner(
|
|
|
1259
1260
|
);
|
|
1260
1261
|
imageReady = true;
|
|
1261
1262
|
} else {
|
|
1263
|
+
const pullDigest = standardImagePullDigest(pin);
|
|
1262
1264
|
console.log(
|
|
1263
|
-
`[host-agent] pulling standard image ${pin.ref}@${
|
|
1265
|
+
`[host-agent] pulling standard image ${pin.ref}@${pullDigest}`,
|
|
1264
1266
|
);
|
|
1265
1267
|
const commands = standardImagePullCommands(
|
|
1266
1268
|
pin,
|
|
@@ -1276,7 +1278,7 @@ async function ensureStandardImageInner(
|
|
|
1276
1278
|
const tagged = await run(engine.command, commands.tag);
|
|
1277
1279
|
if (tagged.code === 0) {
|
|
1278
1280
|
console.log(
|
|
1279
|
-
`[host-agent] pulled standard image ${STANDARD_IMAGE_TAG} (${
|
|
1281
|
+
`[host-agent] pulled standard image ${STANDARD_IMAGE_TAG} (${pullDigest.slice(0, 19)}…)`,
|
|
1280
1282
|
);
|
|
1281
1283
|
imageReady = true;
|
|
1282
1284
|
} else {
|
package/package.json
CHANGED
package/scripts/agent/task-up.sh
CHANGED
|
@@ -667,9 +667,10 @@ git_host_control() (
|
|
|
667
667
|
# This helper is for local repository bookkeeping and public/local remotes.
|
|
668
668
|
# It must never inherit the operator's SSH agent or credential helper. Every
|
|
669
669
|
# authenticated GitHub fetch goes through one of the explicit helpers above.
|
|
670
|
-
GIT_CONFIG_COUNT=
|
|
670
|
+
GIT_CONFIG_COUNT=3 \
|
|
671
671
|
GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0=/dev/null \
|
|
672
672
|
GIT_CONFIG_KEY_1=core.fsmonitor GIT_CONFIG_VALUE_1=false \
|
|
673
|
+
GIT_CONFIG_KEY_2=advice.defaultBranchName GIT_CONFIG_VALUE_2=false \
|
|
673
674
|
GIT_SSH_COMMAND=/bin/false GIT_TERMINAL_PROMPT=0 git \
|
|
674
675
|
-c credential.helper= \
|
|
675
676
|
"$@"
|
package/src/index.ts
CHANGED
|
@@ -101,6 +101,8 @@ export type {
|
|
|
101
101
|
TaskUpResult,
|
|
102
102
|
} from "../lib/agent";
|
|
103
103
|
|
|
104
|
+
import { removeEngineAccount } from "../lib/engine-accounts";
|
|
105
|
+
|
|
104
106
|
export {
|
|
105
107
|
parseMentions,
|
|
106
108
|
resolveAddressing,
|
|
@@ -1029,6 +1031,22 @@ export const hostCommands: HostCommands = {
|
|
|
1029
1031
|
});
|
|
1030
1032
|
},
|
|
1031
1033
|
|
|
1034
|
+
// ADR-116: remove a labeled engine account. Delegates to the same internals
|
|
1035
|
+
// the local UI uses; `default:*` refusal and idempotency live there. The
|
|
1036
|
+
// dispatcher re-advertises capabilities after an ok result.
|
|
1037
|
+
async engineAccountRemove(ctx, input) {
|
|
1038
|
+
logCommand(ctx, "engineAccountRemove", input.id);
|
|
1039
|
+
try {
|
|
1040
|
+
const result = removeEngineAccount(input.id);
|
|
1041
|
+
if (!result.ok) {
|
|
1042
|
+
return ok({ removed: false, message: result.message });
|
|
1043
|
+
}
|
|
1044
|
+
return ok({ removed: true });
|
|
1045
|
+
} catch (err) {
|
|
1046
|
+
return failFromUnknown(err);
|
|
1047
|
+
}
|
|
1048
|
+
},
|
|
1049
|
+
|
|
1032
1050
|
async channelResolvePermission(ctx, taskId, agentId, requestId, decision) {
|
|
1033
1051
|
logCommand(ctx, "channelResolvePermission", taskId, agentId, requestId);
|
|
1034
1052
|
try {
|
package/src/main.ts
CHANGED
|
@@ -143,6 +143,7 @@ import {
|
|
|
143
143
|
patchHostConfig,
|
|
144
144
|
type HostConfigResult,
|
|
145
145
|
} from "../lib/host-config";
|
|
146
|
+
import { listEngineAccounts } from "../lib/engine-accounts";
|
|
146
147
|
import { createEngineLoginManager } from "../lib/engine-login";
|
|
147
148
|
// Importing the real factory triggers the built-in adapters' register()
|
|
148
149
|
// calls (claude, codex), so the registry is populated before we advertise.
|
|
@@ -164,9 +165,13 @@ import {
|
|
|
164
165
|
GITHUB_REPOSITORY_ACCESS_PROTOCOL_FEATURE,
|
|
165
166
|
HOST_MAINTENANCE_READINESS_PROTOCOL_FEATURE,
|
|
166
167
|
HOST_CONFIG_PROTOCOL_FEATURE,
|
|
168
|
+
HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE,
|
|
167
169
|
HOST_ENGINE_LOGIN_PROTOCOL_FEATURE,
|
|
168
170
|
HOST_LOGS_PROTOCOL_FEATURE,
|
|
169
171
|
HOST_TASK_INVENTORY_PROTOCOL_FEATURE,
|
|
172
|
+
MAX_ENGINE_ACCOUNT_LABEL_CHARS,
|
|
173
|
+
MAX_ENGINE_ACCOUNTS_ADVERTISED,
|
|
174
|
+
type EngineAccountSummary,
|
|
170
175
|
MAX_HOST_TASK_INVENTORY_PAGE_SIZE,
|
|
171
176
|
MCP_GATEWAY_HEALTH_PROTOCOL_FEATURE,
|
|
172
177
|
SECRETARY_TYPED_DISPATCH_PROTOCOL_FEATURE,
|
|
@@ -543,6 +548,7 @@ function buildCapabilities(): HostCapabilities {
|
|
|
543
548
|
HOST_LOGS_PROTOCOL_FEATURE,
|
|
544
549
|
HOST_ENGINE_LOGIN_PROTOCOL_FEATURE,
|
|
545
550
|
HOST_CONFIG_PROTOCOL_FEATURE,
|
|
551
|
+
HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE,
|
|
546
552
|
// The echo adapter cannot execute the in-task CLI. Advertising typed
|
|
547
553
|
// dispatch in mock mode would let the composer create a Secretary that
|
|
548
554
|
// has no way to wake crew.
|
|
@@ -558,10 +564,34 @@ function buildCapabilities(): HostCapabilities {
|
|
|
558
564
|
maintenanceReady: areAgentClisReady(),
|
|
559
565
|
mcpGateway: mcpGateway.state(),
|
|
560
566
|
engineLogins: engineLoginManager.capabilities(),
|
|
567
|
+
engineAccounts: engineAccountSummaries(),
|
|
561
568
|
githubUsers: connectedUserIds(),
|
|
562
569
|
};
|
|
563
570
|
}
|
|
564
571
|
|
|
572
|
+
/** ADR-116: the labeled-accounts advertisement — bounded, labels-only. */
|
|
573
|
+
function engineAccountSummaries(): EngineAccountSummary[] {
|
|
574
|
+
const summaries: EngineAccountSummary[] = [];
|
|
575
|
+
for (const kind of ["claude", "codex", "opencode"] as const) {
|
|
576
|
+
try {
|
|
577
|
+
for (const account of listEngineAccounts(kind)) {
|
|
578
|
+
summaries.push({
|
|
579
|
+
id: account.id,
|
|
580
|
+
kind,
|
|
581
|
+
label: account.label.slice(0, MAX_ENGINE_ACCOUNT_LABEL_CHARS),
|
|
582
|
+
authKind: account.authKind,
|
|
583
|
+
isDefault: account.isDefault,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
} catch (error) {
|
|
587
|
+
console.warn(
|
|
588
|
+
`[host-agent] listing ${kind} accounts for capabilities failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return summaries.slice(0, MAX_ENGINE_ACCOUNTS_ADVERTISED);
|
|
593
|
+
}
|
|
594
|
+
|
|
565
595
|
/**
|
|
566
596
|
* Re-advertise capabilities on the active connection. Safe to call any time;
|
|
567
597
|
* a no-op when no socket is connected/open (the next `connect()` re-sends on
|
|
@@ -1074,6 +1104,7 @@ async function connect(): Promise<void> {
|
|
|
1074
1104
|
frame.opId,
|
|
1075
1105
|
frame.engine,
|
|
1076
1106
|
emitEngineLoginEvent,
|
|
1107
|
+
frame.label,
|
|
1077
1108
|
);
|
|
1078
1109
|
break;
|
|
1079
1110
|
case "engine.login.input":
|
|
@@ -2093,7 +2124,7 @@ function sendTunnelData(
|
|
|
2093
2124
|
}
|
|
2094
2125
|
}
|
|
2095
2126
|
|
|
2096
|
-
function dispatchCommand(
|
|
2127
|
+
async function dispatchCommand(
|
|
2097
2128
|
ctx: CommandContext,
|
|
2098
2129
|
command: keyof HostCommands,
|
|
2099
2130
|
args: unknown[],
|
|
@@ -2163,7 +2194,34 @@ function dispatchCommand(
|
|
|
2163
2194
|
expectString(args, 1),
|
|
2164
2195
|
expectNumberArg(args, 2),
|
|
2165
2196
|
);
|
|
2197
|
+
case "engineAccountRemove": {
|
|
2198
|
+
// ADR-116: mirror the local API's post-mutation behavior — the cloud's
|
|
2199
|
+
// account list self-heals through the capability re-advertisement.
|
|
2200
|
+
const removal = await hostCommands.engineAccountRemove(
|
|
2201
|
+
ctx,
|
|
2202
|
+
expectEngineAccountRemoveInput(args, 0),
|
|
2203
|
+
);
|
|
2204
|
+
if (removal.ok) sendCapabilities();
|
|
2205
|
+
return removal;
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
function expectEngineAccountRemoveInput(
|
|
2211
|
+
args: unknown[],
|
|
2212
|
+
index: number,
|
|
2213
|
+
): { id: string } {
|
|
2214
|
+
const value = args[index];
|
|
2215
|
+
if (
|
|
2216
|
+
!value ||
|
|
2217
|
+
typeof value !== "object" ||
|
|
2218
|
+
typeof (value as { id?: unknown }).id !== "string" ||
|
|
2219
|
+
(value as { id: string }).id.length === 0 ||
|
|
2220
|
+
(value as { id: string }).id.length > 256
|
|
2221
|
+
) {
|
|
2222
|
+
throw new Error(`invalid command args: expected account id at ${index}`);
|
|
2166
2223
|
}
|
|
2224
|
+
return { id: (value as { id: string }).id };
|
|
2167
2225
|
}
|
|
2168
2226
|
|
|
2169
2227
|
function expectNumberArg(args: unknown[], index: number): number {
|
|
@@ -2655,6 +2713,7 @@ function isHostCommand(command: string): command is keyof HostCommands {
|
|
|
2655
2713
|
"channelInterrupt",
|
|
2656
2714
|
"appendTranscript",
|
|
2657
2715
|
"previewEnsure",
|
|
2716
|
+
"engineAccountRemove",
|
|
2658
2717
|
].includes(command);
|
|
2659
2718
|
}
|
|
2660
2719
|
|
package/src/protocol.ts
CHANGED
|
@@ -86,6 +86,12 @@ export const MCP_GATEWAY_HEALTH_PROTOCOL_FEATURE = "mcp-gateway-health-v1";
|
|
|
86
86
|
export const HOST_LOGS_PROTOCOL_FEATURE = "host-logs-v1";
|
|
87
87
|
export const HOST_ENGINE_LOGIN_PROTOCOL_FEATURE = "host-engine-login-v1";
|
|
88
88
|
export const HOST_CONFIG_PROTOCOL_FEATURE = "host-config-v1";
|
|
89
|
+
/** ADR-116: labeled engine accounts on the cloud host page — account list in
|
|
90
|
+
* capabilities, account-targeted login (`label` on engine.login.start), and
|
|
91
|
+
* the engineAccountRemove command. */
|
|
92
|
+
export const HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE = "host-engine-accounts-v1";
|
|
93
|
+
export const MAX_ENGINE_ACCOUNT_LABEL_CHARS = 64;
|
|
94
|
+
export const MAX_ENGINE_ACCOUNTS_ADVERTISED = 48;
|
|
89
95
|
export const MAX_HOST_OP_ID_CHARS = 128;
|
|
90
96
|
export const MAX_HOST_LOG_LINES = 1_000;
|
|
91
97
|
export const MAX_HOST_LOG_LINE_BYTES = 4 * 1_024;
|
|
@@ -282,6 +288,10 @@ export interface EngineLoginStartFrame {
|
|
|
282
288
|
kind: "engine.login.start";
|
|
283
289
|
opId: HostOpId;
|
|
284
290
|
engine: EngineLoginKind;
|
|
291
|
+
/** ADR-116: capture into a labeled extra account instead of the default
|
|
292
|
+
* slot. Only sent to hosts advertising `host-engine-accounts-v1`; the v1
|
|
293
|
+
* exact-shape guard on older hosts rejects frames carrying it. */
|
|
294
|
+
label?: string;
|
|
285
295
|
}
|
|
286
296
|
|
|
287
297
|
/** The only generic one-time response channel. Long-lived tokens must never
|
|
@@ -343,6 +353,16 @@ export interface EngineLoginCapability {
|
|
|
343
353
|
loginSupported: true;
|
|
344
354
|
}
|
|
345
355
|
|
|
356
|
+
/** ADR-116: one labeled engine account, as advertised in capabilities.
|
|
357
|
+
* Operator-chosen label + shape metadata only — never credentials. */
|
|
358
|
+
export interface EngineAccountSummary {
|
|
359
|
+
id: string;
|
|
360
|
+
kind: "claude" | "codex" | "opencode";
|
|
361
|
+
label: string;
|
|
362
|
+
authKind: "env" | "config-dir";
|
|
363
|
+
isDefault: boolean;
|
|
364
|
+
}
|
|
365
|
+
|
|
346
366
|
/** The exact local host settings ADR-100 permits over the bridge. */
|
|
347
367
|
export interface HostConfigState {
|
|
348
368
|
agentCliAutoupdate: boolean;
|
|
@@ -530,12 +550,26 @@ export function isHostLogsEndFrame(value: unknown): value is HostLogsEndFrame {
|
|
|
530
550
|
export function isEngineLoginStartFrame(
|
|
531
551
|
value: unknown,
|
|
532
552
|
): value is EngineLoginStartFrame {
|
|
533
|
-
const frame = exactWireObject(value, ["kind", "opId", "engine"]);
|
|
553
|
+
const frame = exactWireObject(value, ["kind", "opId", "engine"], ["label"]);
|
|
534
554
|
return Boolean(
|
|
535
555
|
frame &&
|
|
536
556
|
frame.kind === "engine.login.start" &&
|
|
537
557
|
isHostOpId(frame.opId) &&
|
|
538
|
-
isEngineLoginKind(frame.engine)
|
|
558
|
+
isEngineLoginKind(frame.engine) &&
|
|
559
|
+
(frame.label === undefined || isEngineAccountLabel(frame.label)),
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** ADR-116 account labels: operator-chosen display names, never secrets.
|
|
564
|
+
* Single-line, bounded, and visibly printable so they render safely in every
|
|
565
|
+
* surface (capability frames, UI chips, log lines). */
|
|
566
|
+
export function isEngineAccountLabel(value: unknown): value is string {
|
|
567
|
+
return (
|
|
568
|
+
typeof value === "string" &&
|
|
569
|
+
value.length >= 1 &&
|
|
570
|
+
value.length <= MAX_ENGINE_ACCOUNT_LABEL_CHARS &&
|
|
571
|
+
value.trim() === value &&
|
|
572
|
+
!/[\u0000-\u001f\u007f]/.test(value)
|
|
539
573
|
);
|
|
540
574
|
}
|
|
541
575
|
|
|
@@ -766,6 +800,9 @@ export interface HostCapabilities {
|
|
|
766
800
|
mcpGateway?: McpGatewayCapability;
|
|
767
801
|
/** ADR-100: feature-gated, secret-free remote login availability. */
|
|
768
802
|
engineLogins?: EngineLoginCapability[];
|
|
803
|
+
/** ADR-116: labeled engine accounts (default + extras), re-advertised on
|
|
804
|
+
* every account mutation. Labels only — credentials never leave the host. */
|
|
805
|
+
engineAccounts?: EngineAccountSummary[];
|
|
769
806
|
// ADR-033: cloud user ids that currently have a GitHub token ON THIS HOST —
|
|
770
807
|
// the per-host gh-connected state shown on the host detail page. Re-advertised
|
|
771
808
|
// whenever a token is added/removed. Optional: older hosts omit it.
|
|
@@ -1109,6 +1146,12 @@ export interface HostCommands {
|
|
|
1109
1146
|
name: string,
|
|
1110
1147
|
containerPort: number,
|
|
1111
1148
|
): Promise<HostCommandResult<{ hostPort: number | null }>>;
|
|
1149
|
+
/** ADR-116: remove a labeled engine account by id. Refuses `default:*`
|
|
1150
|
+
* exactly like the local API; re-advertises capabilities on success. */
|
|
1151
|
+
engineAccountRemove(
|
|
1152
|
+
ctx: CommandContext,
|
|
1153
|
+
input: { id: string },
|
|
1154
|
+
): Promise<HostCommandResult<{ removed: boolean; message?: string }>>;
|
|
1112
1155
|
cloneRepo(
|
|
1113
1156
|
ctx: CommandContext,
|
|
1114
1157
|
input: CloneRepoInput,
|