@runuai/host 0.9.47 → 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.
@@ -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. */
@@ -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: "Login timed out.",
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,
@@ -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<string, unknown>;
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
- return { ref, digest, contextHash };
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.digest}`;
136
+ const source = `${pin.ref}@${standardImagePullDigest(pin, arch)}`;
92
137
  return apple
93
138
  ? {
94
139
  pull: ["image", "pull", source],
@@ -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}@${pin.digest}`,
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} (${pin.digest.slice(0, 19)}…)`,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.47",
3
+ "version": "0.9.48",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
@@ -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=2 \
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,