@runuai/host 0.9.46 → 0.9.48
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 +103 -1
- package/lib/standard-image-pin.ts +204 -0
- package/lib/standard-image.ts +101 -36
- 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);
|
|
@@ -687,6 +712,28 @@ export class EngineLoginManager {
|
|
|
687
712
|
token: string,
|
|
688
713
|
): Promise<void> {
|
|
689
714
|
let terminal: EngineLoginEventFrame;
|
|
715
|
+
if (operation.accountLabel !== null) {
|
|
716
|
+
// ADR-116: labeled capture — an extra account, not the default slot.
|
|
717
|
+
// Extras are picked up at the next agent spawn / rotation, so no
|
|
718
|
+
// running-agent refresh is needed (mirrors the local accounts API).
|
|
719
|
+
const added = this.seams.addClaudeAccount(operation.accountLabel, token);
|
|
720
|
+
terminal = added.ok
|
|
721
|
+
? {
|
|
722
|
+
kind: "engine.login.event",
|
|
723
|
+
opId: operation.opId,
|
|
724
|
+
engine: operation.engine,
|
|
725
|
+
phase: "succeeded",
|
|
726
|
+
message: added.message,
|
|
727
|
+
}
|
|
728
|
+
: failedEvent(
|
|
729
|
+
operation.opId,
|
|
730
|
+
operation.engine,
|
|
731
|
+
"persist_failed",
|
|
732
|
+
added.message,
|
|
733
|
+
);
|
|
734
|
+
await this.finishCommit(operation, terminal);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
690
737
|
try {
|
|
691
738
|
await this.seams.persistClaudeToken(token);
|
|
692
739
|
} catch {
|
|
@@ -730,6 +777,39 @@ export class EngineLoginManager {
|
|
|
730
777
|
const tempDir = operation.tempDir;
|
|
731
778
|
if (!tempDir) return;
|
|
732
779
|
let terminal: EngineLoginEventFrame;
|
|
780
|
+
if (operation.accountLabel !== null) {
|
|
781
|
+
// ADR-116: labeled capture — the auth.json this login produced becomes
|
|
782
|
+
// an isolated extra account instead of overwriting ~/.codex.
|
|
783
|
+
let added: { ok: boolean; message: string };
|
|
784
|
+
try {
|
|
785
|
+
const contents = await readFile(
|
|
786
|
+
join(tempDir, "codex", "auth.json"),
|
|
787
|
+
"utf8",
|
|
788
|
+
);
|
|
789
|
+
added = this.seams.addCodexAccount(operation.accountLabel, contents);
|
|
790
|
+
} catch {
|
|
791
|
+
added = {
|
|
792
|
+
ok: false,
|
|
793
|
+
message: "Codex authorized, but its credential could not be read.",
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
terminal = added.ok
|
|
797
|
+
? {
|
|
798
|
+
kind: "engine.login.event",
|
|
799
|
+
opId: operation.opId,
|
|
800
|
+
engine: operation.engine,
|
|
801
|
+
phase: "succeeded",
|
|
802
|
+
message: added.message,
|
|
803
|
+
}
|
|
804
|
+
: failedEvent(
|
|
805
|
+
operation.opId,
|
|
806
|
+
operation.engine,
|
|
807
|
+
"persist_failed",
|
|
808
|
+
added.message,
|
|
809
|
+
);
|
|
810
|
+
await this.finishCommit(operation, terminal);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
733
813
|
try {
|
|
734
814
|
await this.seams.persistCodexAuth(
|
|
735
815
|
join(tempDir, "codex", "auth.json"),
|
|
@@ -770,6 +850,10 @@ export class EngineLoginManager {
|
|
|
770
850
|
await operation.finishPromise;
|
|
771
851
|
return;
|
|
772
852
|
}
|
|
853
|
+
// ADR-116: attach the CLI's last visible line — a remote operator staring
|
|
854
|
+
// at "Waiting for the host…" (live 2026-08-20, apple-container) otherwise
|
|
855
|
+
// has zero evidence of where the flow died.
|
|
856
|
+
const evidence = lastSignificantOutputLine(operation.outputTail);
|
|
773
857
|
this.detach(operation);
|
|
774
858
|
safeEmit(operation.emit, {
|
|
775
859
|
kind: "engine.login.event",
|
|
@@ -777,7 +861,9 @@ export class EngineLoginManager {
|
|
|
777
861
|
engine: operation.engine,
|
|
778
862
|
phase: "timed_out",
|
|
779
863
|
errorCode: "timeout",
|
|
780
|
-
message:
|
|
864
|
+
message: evidence
|
|
865
|
+
? `Login timed out. Last output: ${evidence}`
|
|
866
|
+
: "Login timed out.",
|
|
781
867
|
});
|
|
782
868
|
await this.ensureSettlement(operation);
|
|
783
869
|
}
|
|
@@ -916,6 +1002,22 @@ export function createEngineLoginManager(
|
|
|
916
1002
|
return new EngineLoginManager(options);
|
|
917
1003
|
}
|
|
918
1004
|
|
|
1005
|
+
/** The last visible, sanitized line of the login CLI's output — bounded and
|
|
1006
|
+
* secret-checked so it can ride a terminal event message (ADR-116). Anything
|
|
1007
|
+
* token-shaped disqualifies the whole line rather than risking a partial. */
|
|
1008
|
+
function lastSignificantOutputLine(tail: string): string | null {
|
|
1009
|
+
const lines = tail
|
|
1010
|
+
.replace(/\u001b\[[0-9;?]*[ -\/]*[@-~]/g, "")
|
|
1011
|
+
.replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, "")
|
|
1012
|
+
.split("\n")
|
|
1013
|
+
.map((line) => line.trim())
|
|
1014
|
+
.filter((line) => line.length > 3);
|
|
1015
|
+
const last = lines.at(-1);
|
|
1016
|
+
if (!last) return null;
|
|
1017
|
+
if (/sk-ant-|Bearer |[A-Za-z0-9_-]{40,}/.test(last)) return null;
|
|
1018
|
+
return last.slice(0, 200);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
919
1021
|
function safeEmit(
|
|
920
1022
|
emit: EmitEngineLoginEvent,
|
|
921
1023
|
event: EngineLoginEventFrame,
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-114: the standard image is prebuilt in CI and pulled by digest.
|
|
3
|
+
*
|
|
4
|
+
* This module is the shared identity/grammar core, deliberately free of any
|
|
5
|
+
* host runtime import so the release-side identity script and the unit tests
|
|
6
|
+
* can load it without dragging the container-runtime graph along. Everything
|
|
7
|
+
* here is consumed from three places:
|
|
8
|
+
*
|
|
9
|
+
* - CI (`host-agent/scripts/release/standard-image-identity.ts`) computes
|
|
10
|
+
* the superset context hash and the registry tag to build/push.
|
|
11
|
+
* - Release packaging (`scripts/host-runtime/stage.mjs`) embeds a validated
|
|
12
|
+
* pin file in the signed payload (its .mjs copy of the grammar cites this
|
|
13
|
+
* module as authoritative).
|
|
14
|
+
* - The host (`standard-image.ts`) reads the packaged pin and pulls by
|
|
15
|
+
* digest instead of building, falling back to the local build.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
|
|
22
|
+
/** Image label carrying the build-context content hash (rebuild trigger). */
|
|
23
|
+
export const CONTEXT_HASH_LABEL = "com.runuai.context-hash";
|
|
24
|
+
|
|
25
|
+
/** The public registry repository CI publishes the prebuilt image to. */
|
|
26
|
+
export const STANDARD_IMAGE_RELEASE_REF = "ghcr.io/runuai/uai-standard";
|
|
27
|
+
|
|
28
|
+
/** Pin file basename; packaged at `agent/images/<basename>` in the payload,
|
|
29
|
+
* one directory above the build context so it never perturbs the hash. */
|
|
30
|
+
export const STANDARD_IMAGE_PIN_BASENAME = "standard-image.pin.json";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The prebuilt image is the SUPERSET: every optional engine baked in. The
|
|
34
|
+
* layers are additive and non-fatal, a disabled engine is simply never
|
|
35
|
+
* invoked, and superset-vs-config means one published image serves every
|
|
36
|
+
* host — and toggling an engine on stops forcing a host-side rebuild.
|
|
37
|
+
*/
|
|
38
|
+
export const STANDARD_IMAGE_SUPERSET_EXTRA =
|
|
39
|
+
"kimi=1;grok=1;cursor=1;opencode=1";
|
|
40
|
+
|
|
41
|
+
export const STANDARD_IMAGE_SUPERSET_BUILD_ARGS: readonly string[] = [
|
|
42
|
+
"INSTALL_KIMI=1",
|
|
43
|
+
"INSTALL_GROK=1",
|
|
44
|
+
"INSTALL_CURSOR=1",
|
|
45
|
+
"INSTALL_OPENCODE=1",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export interface StandardImagePin {
|
|
49
|
+
readonly ref: string;
|
|
50
|
+
readonly digest: string;
|
|
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 };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The pin arrives inside a signature-verified payload; this grammar is
|
|
63
|
+
// defense-in-depth so no parsed field can smuggle registry ports, tags,
|
|
64
|
+
// or shell-significant bytes into an engine command line.
|
|
65
|
+
const REF_PATTERN = /^[a-z0-9]+(?:[a-z0-9._-]*[a-z0-9])?(?:\/[a-z0-9]+(?:[a-z0-9._-]*[a-z0-9])?)+$/;
|
|
66
|
+
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
67
|
+
const CONTEXT_HASH_PATTERN = /^[a-f0-9]{32}$/;
|
|
68
|
+
|
|
69
|
+
/** Strict parse of a pin document; null on ANY deviation. */
|
|
70
|
+
export function parseStandardImagePin(raw: string): StandardImagePin | null {
|
|
71
|
+
let parsed: unknown;
|
|
72
|
+
try {
|
|
73
|
+
parsed = JSON.parse(raw);
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
78
|
+
const { ref, digest, contextHash, platforms } = parsed as Record<
|
|
79
|
+
string,
|
|
80
|
+
unknown
|
|
81
|
+
>;
|
|
82
|
+
if (
|
|
83
|
+
typeof ref !== "string" ||
|
|
84
|
+
typeof digest !== "string" ||
|
|
85
|
+
typeof contextHash !== "string" ||
|
|
86
|
+
!REF_PATTERN.test(ref) ||
|
|
87
|
+
!DIGEST_PATTERN.test(digest) ||
|
|
88
|
+
!CONTEXT_HASH_PATTERN.test(contextHash)
|
|
89
|
+
) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
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;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The exact engine argv for pulling and tagging the pinned image. Docker
|
|
129
|
+
* and the Apple `container` CLI differ only in the `image` subcommand prefix. */
|
|
130
|
+
export function standardImagePullCommands(
|
|
131
|
+
pin: StandardImagePin,
|
|
132
|
+
targetTag: string,
|
|
133
|
+
apple: boolean,
|
|
134
|
+
arch: string = process.arch,
|
|
135
|
+
): { pull: string[]; tag: string[] } {
|
|
136
|
+
const source = `${pin.ref}@${standardImagePullDigest(pin, arch)}`;
|
|
137
|
+
return apple
|
|
138
|
+
? {
|
|
139
|
+
pull: ["image", "pull", source],
|
|
140
|
+
tag: ["image", "tag", source, targetTag],
|
|
141
|
+
}
|
|
142
|
+
: { pull: ["pull", source], tag: ["tag", source, targetTag] };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Content-hash a standard-image build context: every file under the context
|
|
147
|
+
* dir, sorted by relative path, plus an `extra` string carrying the build-arg
|
|
148
|
+
* configuration (part of the image identity). Null when the context can't be
|
|
149
|
+
* read — callers keep whatever image exists.
|
|
150
|
+
*/
|
|
151
|
+
export async function hashStandardImageContext(
|
|
152
|
+
contextDir: string,
|
|
153
|
+
extra = "",
|
|
154
|
+
): Promise<string | null> {
|
|
155
|
+
try {
|
|
156
|
+
const files: string[] = [];
|
|
157
|
+
const walk = async (dir: string, prefix: string): Promise<void> => {
|
|
158
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
159
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
160
|
+
if (entry.isDirectory()) await walk(join(dir, entry.name), rel);
|
|
161
|
+
else files.push(rel);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
await walk(contextDir, "");
|
|
165
|
+
files.sort();
|
|
166
|
+
const hash = createHash("sha256");
|
|
167
|
+
for (const rel of files) {
|
|
168
|
+
hash.update(rel);
|
|
169
|
+
hash.update("\0");
|
|
170
|
+
hash.update(await readFile(join(contextDir, rel)));
|
|
171
|
+
hash.update("\0");
|
|
172
|
+
}
|
|
173
|
+
hash.update(extra);
|
|
174
|
+
hash.update("\0");
|
|
175
|
+
return hash.digest("hex").slice(0, 32);
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Everything CI needs to build/push/pin one release image, or null when the
|
|
182
|
+
* context is unreadable (a broken checkout must fail the release loudly). */
|
|
183
|
+
export async function standardImageReleaseIdentity(
|
|
184
|
+
contextDir: string,
|
|
185
|
+
): Promise<{
|
|
186
|
+
ref: string;
|
|
187
|
+
tag: string;
|
|
188
|
+
contextHash: string;
|
|
189
|
+
label: string;
|
|
190
|
+
buildArgs: readonly string[];
|
|
191
|
+
} | null> {
|
|
192
|
+
const contextHash = await hashStandardImageContext(
|
|
193
|
+
contextDir,
|
|
194
|
+
STANDARD_IMAGE_SUPERSET_EXTRA,
|
|
195
|
+
);
|
|
196
|
+
if (contextHash === null) return null;
|
|
197
|
+
return {
|
|
198
|
+
ref: STANDARD_IMAGE_RELEASE_REF,
|
|
199
|
+
tag: `ctx-${contextHash}`,
|
|
200
|
+
contextHash,
|
|
201
|
+
label: `${CONTEXT_HASH_LABEL}=${contextHash}`,
|
|
202
|
+
buildArgs: STANDARD_IMAGE_SUPERSET_BUILD_ARGS,
|
|
203
|
+
};
|
|
204
|
+
}
|
package/lib/standard-image.ts
CHANGED
|
@@ -17,9 +17,8 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
|
-
import { createHash } from "node:crypto";
|
|
21
20
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
22
|
-
import {
|
|
21
|
+
import { readFile } from "node:fs/promises";
|
|
23
22
|
import { dirname, join, resolve } from "node:path";
|
|
24
23
|
import { fileURLToPath } from "node:url";
|
|
25
24
|
|
|
@@ -36,6 +35,15 @@ import {
|
|
|
36
35
|
pinnedContainerRuntimeProvider,
|
|
37
36
|
} from "./container-runtime";
|
|
38
37
|
import { KeyedPromiseTail } from "./keyed-promise-tail";
|
|
38
|
+
import {
|
|
39
|
+
CONTEXT_HASH_LABEL,
|
|
40
|
+
hashStandardImageContext,
|
|
41
|
+
parseStandardImagePin,
|
|
42
|
+
STANDARD_IMAGE_PIN_BASENAME,
|
|
43
|
+
type StandardImagePin,
|
|
44
|
+
standardImagePullCommands,
|
|
45
|
+
standardImagePullDigest,
|
|
46
|
+
} from "./standard-image-pin";
|
|
39
47
|
|
|
40
48
|
/** Pinned, host-wide constants (must match task-up.sh and the compose gen). */
|
|
41
49
|
export const STANDARD_IMAGE_TAG = "uai-standard:dev";
|
|
@@ -90,14 +98,6 @@ export function standardRuntimes(): Array<{
|
|
|
90
98
|
}));
|
|
91
99
|
}
|
|
92
100
|
|
|
93
|
-
/** Image label carrying the build-context content hash (rebuild trigger). */
|
|
94
|
-
const CONTEXT_HASH_LABEL = "com.runuai.context-hash";
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Content-hash the build context (every file under images/standard, sorted
|
|
98
|
-
* by relative path). Null when the context can't be read — the caller then
|
|
99
|
-
* keeps whatever image exists.
|
|
100
|
-
*/
|
|
101
101
|
/**
|
|
102
102
|
* Which OPTIONAL agent CLIs the operator has actually configured — so the
|
|
103
103
|
* image installs only those, not every engine on every host. Delegates to
|
|
@@ -130,34 +130,36 @@ function optionalEngineConfigurationKey(
|
|
|
130
130
|
return JSON.stringify(engines);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
|
|
133
|
+
// Build args (which optional engines are installed) are part of the image
|
|
134
|
+
// identity — a config change must invalidate the label so it rebuilds. The
|
|
135
|
+
// walk-and-hash itself lives in standard-image-pin.ts so CI computes the
|
|
136
|
+
// identical identity (ADR-114).
|
|
137
|
+
function hashBuildContext(extra = ""): Promise<string | null> {
|
|
138
|
+
return hashStandardImageContext(standardImageDir(), extra);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The packaged digest pin (ADR-114) at `agent/images/standard-image.pin.json`,
|
|
142
|
+
* one level above the build context so it never perturbs the context hash.
|
|
143
|
+
* Absent in dev checkouts and npm installs — those keep the local build.
|
|
144
|
+
* `UAI_IMAGE_PULL=0` is the operator kill switch back to local semantics. */
|
|
145
|
+
async function readStandardImagePin(): Promise<StandardImagePin | null> {
|
|
146
|
+
if (process.env.UAI_IMAGE_PULL === "0") return null;
|
|
147
|
+
let raw: string;
|
|
134
148
|
try {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
140
|
-
if (entry.isDirectory()) await walk(join(dir, entry.name), rel);
|
|
141
|
-
else files.push(rel);
|
|
142
|
-
}
|
|
143
|
-
};
|
|
144
|
-
await walk(root, "");
|
|
145
|
-
files.sort();
|
|
146
|
-
const hash = createHash("sha256");
|
|
147
|
-
for (const rel of files) {
|
|
148
|
-
hash.update(rel);
|
|
149
|
-
hash.update("\0");
|
|
150
|
-
hash.update(await readFile(join(root, rel)));
|
|
151
|
-
hash.update("\0");
|
|
152
|
-
}
|
|
153
|
-
// Build args (which optional engines are installed) are part of the image
|
|
154
|
-
// identity — a config change must invalidate the label so it rebuilds.
|
|
155
|
-
hash.update(extra);
|
|
156
|
-
hash.update("\0");
|
|
157
|
-
return hash.digest("hex").slice(0, 32);
|
|
149
|
+
raw = await readFile(
|
|
150
|
+
resolve(standardImageDir(), "..", STANDARD_IMAGE_PIN_BASENAME),
|
|
151
|
+
"utf8",
|
|
152
|
+
);
|
|
158
153
|
} catch {
|
|
159
154
|
return null;
|
|
160
155
|
}
|
|
156
|
+
const pin = parseStandardImagePin(raw);
|
|
157
|
+
if (pin === null) {
|
|
158
|
+
console.warn(
|
|
159
|
+
"[host-agent] packaged standard-image pin is malformed; using the local build path",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return pin;
|
|
161
163
|
}
|
|
162
164
|
|
|
163
165
|
/** Absolute path to the standard image build context. */
|
|
@@ -201,7 +203,18 @@ interface RunResult {
|
|
|
201
203
|
|
|
202
204
|
const SHORT_DOCKER_TIMEOUT_MS = 30_000;
|
|
203
205
|
const CLI_MAINTENANCE_TIMEOUT_MS = 10 * 60_000;
|
|
204
|
-
|
|
206
|
+
// Overridable ceiling: the hard-coded 15 minutes looped a fanless Air forever
|
|
207
|
+
// (live 2026-08-19 — every attempt died at 15:00 and the activation driver
|
|
208
|
+
// silently restarted it). Slow hardware needs a bigger window, not a loop.
|
|
209
|
+
const IMAGE_BUILD_TIMEOUT_MS = imageBuildTimeoutMs();
|
|
210
|
+
function imageBuildTimeoutMs(): number {
|
|
211
|
+
const minutes = Number(process.env.UAI_IMAGE_BUILD_TIMEOUT_MINUTES ?? "");
|
|
212
|
+
if (Number.isFinite(minutes) && minutes >= 1) {
|
|
213
|
+
return Math.min(minutes, 240) * 60_000;
|
|
214
|
+
}
|
|
215
|
+
return 15 * 60_000;
|
|
216
|
+
}
|
|
217
|
+
const IMAGE_PULL_TIMEOUT_MS = 15 * 60_000;
|
|
205
218
|
const TERMINATE_GRACE_MS = 5_000;
|
|
206
219
|
export const ASDF_MAINTENANCE_LOCK_PATH =
|
|
207
220
|
"/opt/asdf-data/.uai-maintenance.lock";
|
|
@@ -1233,7 +1246,59 @@ async function ensureStandardImageInner(
|
|
|
1233
1246
|
};
|
|
1234
1247
|
}
|
|
1235
1248
|
if (inspect.code !== 0) labeledHash = null;
|
|
1236
|
-
|
|
1249
|
+
|
|
1250
|
+
// ADR-114: when the payload pins a prebuilt digest, the pin decides image
|
|
1251
|
+
// identity — the label must equal the PIN's context hash (the CI superset
|
|
1252
|
+
// hash, not this host's per-config hash, which would disagree by design
|
|
1253
|
+
// and rebuild forever). Any pull-path failure falls through to the local
|
|
1254
|
+
// build below, so offline and registry-down hosts behave exactly as today.
|
|
1255
|
+
const pin = await readStandardImagePin();
|
|
1256
|
+
if (pin !== null) {
|
|
1257
|
+
if (inspect.code === 0 && labeledHash === pin.contextHash) {
|
|
1258
|
+
console.log(
|
|
1259
|
+
`[host-agent] standard image ${STANDARD_IMAGE_TAG} current (pinned)`,
|
|
1260
|
+
);
|
|
1261
|
+
imageReady = true;
|
|
1262
|
+
} else {
|
|
1263
|
+
const pullDigest = standardImagePullDigest(pin);
|
|
1264
|
+
console.log(
|
|
1265
|
+
`[host-agent] pulling standard image ${pin.ref}@${pullDigest}`,
|
|
1266
|
+
);
|
|
1267
|
+
const commands = standardImagePullCommands(
|
|
1268
|
+
pin,
|
|
1269
|
+
STANDARD_IMAGE_TAG,
|
|
1270
|
+
engine.apple,
|
|
1271
|
+
);
|
|
1272
|
+
const pulled = await run(
|
|
1273
|
+
engine.command,
|
|
1274
|
+
commands.pull,
|
|
1275
|
+
IMAGE_PULL_TIMEOUT_MS,
|
|
1276
|
+
);
|
|
1277
|
+
if (pulled.code === 0) {
|
|
1278
|
+
const tagged = await run(engine.command, commands.tag);
|
|
1279
|
+
if (tagged.code === 0) {
|
|
1280
|
+
console.log(
|
|
1281
|
+
`[host-agent] pulled standard image ${STANDARD_IMAGE_TAG} (${pullDigest.slice(0, 19)}…)`,
|
|
1282
|
+
);
|
|
1283
|
+
imageReady = true;
|
|
1284
|
+
} else {
|
|
1285
|
+
console.warn(
|
|
1286
|
+
"[host-agent] pulled standard image could not be tagged; " +
|
|
1287
|
+
`falling back to a local build. ${tagged.stderr.trim()}`,
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
} else {
|
|
1291
|
+
console.warn(
|
|
1292
|
+
"[host-agent] standard image pull failed; falling back to a " +
|
|
1293
|
+
`local build. ${pulled.stderr.trim()}`,
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
if (imageReady) {
|
|
1300
|
+
// Pinned image adopted — skip the local-hash decision entirely.
|
|
1301
|
+
} else if (inspect.code === 0 && contextHash !== null && labeledHash === contextHash) {
|
|
1237
1302
|
console.log(`[host-agent] standard image ${STANDARD_IMAGE_TAG} current`);
|
|
1238
1303
|
imageReady = true;
|
|
1239
1304
|
} else if (inspect.code === 0 && contextHash === null) {
|
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,
|