@runuai/host 0.9.49 → 0.9.51

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.
@@ -0,0 +1,221 @@
1
+ /**
2
+ * ADR-117: one-shot engine connect over the bridge.
3
+ *
4
+ * Much simpler than engine-login: nothing is spawned in a container. With a
5
+ * `secret` this runs the SAME paste paths the local UI's connect uses
6
+ * (`connectEngine` with pastedToken/apiKey, or the ADR-116 labeled-account
7
+ * sink); without one it runs OpenCode's re-detect. The secret arrives in the
8
+ * start frame, goes straight into `connectEngine`/`addEngineAccount`, and is
9
+ * never logged or echoed into any event.
10
+ */
11
+
12
+ import {
13
+ isEngineAccountLabel,
14
+ isHostOpId,
15
+ MAX_ENGINE_CONNECT_LINE_CHARS,
16
+ MAX_ENGINE_CONNECT_SECRET_CHARS,
17
+ type EngineConnectEventFrame,
18
+ type EngineConnectKind,
19
+ } from "../src/protocol";
20
+ import { addEngineAccount } from "./engine-accounts";
21
+ import { connectEngine, type EngineKind } from "./engines";
22
+
23
+ const CONNECT_TIMEOUT_MS = 2 * 60_000;
24
+ const MAX_CONCURRENT_CONNECTS = 1;
25
+
26
+ export type EmitEngineConnectEvent = (frame: EngineConnectEventFrame) => void;
27
+
28
+ export interface EngineConnectManagerOptions {
29
+ /** Re-advertise capabilities after a successful mutation (main.ts). */
30
+ readvertise(): void;
31
+ /** Refresh running agents of a kind after a DEFAULT credential change. */
32
+ refreshEngineAccountAgents(kind: string): Promise<void>;
33
+ /** Best-effort post-connect image maintenance (optional engines bake in). */
34
+ ensureStandardImage(): Promise<unknown>;
35
+ timers?: {
36
+ setTimeout(handler: () => void, ms: number): NodeJS.Timeout;
37
+ clearTimeout(timer: NodeJS.Timeout): void;
38
+ };
39
+ }
40
+
41
+ interface ConnectOperation {
42
+ opId: string;
43
+ engine: EngineConnectKind;
44
+ emit: EmitEngineConnectEvent;
45
+ done: boolean;
46
+ timer: NodeJS.Timeout | null;
47
+ }
48
+
49
+ export class EngineConnectManager {
50
+ private readonly operations = new Map<string, ConnectOperation>();
51
+
52
+ constructor(private readonly options: EngineConnectManagerOptions) {}
53
+
54
+ start(
55
+ opId: string,
56
+ engine: EngineConnectKind,
57
+ secret: string | undefined,
58
+ label: string | undefined,
59
+ emit: EmitEngineConnectEvent,
60
+ ): boolean {
61
+ if (!isHostOpId(opId) || this.operations.has(opId)) return false;
62
+ if (
63
+ secret !== undefined &&
64
+ (secret.length < 1 || secret.length > MAX_ENGINE_CONNECT_SECRET_CHARS)
65
+ ) {
66
+ return false;
67
+ }
68
+ if (label !== undefined && !isEngineAccountLabel(label)) return false;
69
+ if (this.operations.size >= MAX_CONCURRENT_CONNECTS) {
70
+ safeEmit(emit, {
71
+ kind: "engine.connect.event",
72
+ opId,
73
+ engine,
74
+ phase: "done",
75
+ ok: false,
76
+ message: "Another engine connect is already in progress.",
77
+ });
78
+ return true;
79
+ }
80
+ const operation: ConnectOperation = {
81
+ opId,
82
+ engine,
83
+ emit,
84
+ done: false,
85
+ timer: null,
86
+ };
87
+ this.operations.set(opId, operation);
88
+ const timers = this.options.timers ?? {
89
+ setTimeout: (handler: () => void, ms: number) => setTimeout(handler, ms),
90
+ clearTimeout: (timer: NodeJS.Timeout) => clearTimeout(timer),
91
+ };
92
+ operation.timer = timers.setTimeout(() => {
93
+ this.finish(operation, false, "Engine connect timed out.");
94
+ }, CONNECT_TIMEOUT_MS);
95
+ operation.timer.unref?.();
96
+ void this.run(operation, secret, label).catch((error: unknown) => {
97
+ this.finish(
98
+ operation,
99
+ false,
100
+ error instanceof Error ? error.message : "Engine connect failed.",
101
+ );
102
+ });
103
+ return true;
104
+ }
105
+
106
+ stop(opId: string): void {
107
+ const operation = this.operations.get(opId);
108
+ if (operation) this.finish(operation, false, "Cancelled.");
109
+ }
110
+
111
+ stopAll(): void {
112
+ for (const operation of [...this.operations.values()]) {
113
+ this.finish(operation, false, "The connection was replaced.");
114
+ }
115
+ }
116
+
117
+ private async run(
118
+ operation: ConnectOperation,
119
+ secret: string | undefined,
120
+ label: string | undefined,
121
+ ): Promise<void> {
122
+ const { engine } = operation;
123
+ const emitLine = (line: string): void => {
124
+ const bounded = line.trim().slice(0, MAX_ENGINE_CONNECT_LINE_CHARS);
125
+ if (!bounded || operation.done) return;
126
+ safeEmit(operation.emit, {
127
+ kind: "engine.connect.event",
128
+ opId: operation.opId,
129
+ engine,
130
+ phase: "line",
131
+ line: bounded,
132
+ });
133
+ };
134
+
135
+ if (secret === undefined && engine !== "opencode") {
136
+ this.finish(
137
+ operation,
138
+ false,
139
+ "This engine needs a credential here, or its sign-in run on the host.",
140
+ );
141
+ return;
142
+ }
143
+ if (secret !== undefined && engine === "opencode") {
144
+ // saveApiKey has no opencode branch and would claim success without
145
+ // storing anything — OpenCode connects on the host, we only re-detect.
146
+ this.finish(
147
+ operation,
148
+ false,
149
+ "OpenCode signs in on the host (`opencode auth login`); use Check connection here.",
150
+ );
151
+ return;
152
+ }
153
+
154
+ // ADR-116 labeled extra account — the sink validates kind support.
155
+ if (label !== undefined && secret !== undefined) {
156
+ const added = addEngineAccount(engine, label, { token: secret });
157
+ if (added.ok) this.afterMutation(engine, false);
158
+ this.finish(operation, added.ok, added.message);
159
+ return;
160
+ }
161
+
162
+ const result = await connectEngine(
163
+ engine as EngineKind,
164
+ secret === undefined
165
+ ? {}
166
+ : engine === "claude"
167
+ ? { pastedToken: secret }
168
+ : { apiKey: secret },
169
+ emitLine,
170
+ );
171
+ if (result.ok) this.afterMutation(engine, true);
172
+ this.finish(operation, result.ok, result.message);
173
+ }
174
+
175
+ /** Mirror the local UI's post-connect behavior (ui/server.ts). */
176
+ private afterMutation(engine: EngineConnectKind, defaultSlot: boolean): void {
177
+ this.options.readvertise();
178
+ void this.options.ensureStandardImage().catch(() => {});
179
+ if (defaultSlot) {
180
+ void this.options.refreshEngineAccountAgents(engine).catch(() => {});
181
+ }
182
+ }
183
+
184
+ private finish(
185
+ operation: ConnectOperation,
186
+ ok: boolean,
187
+ message: string,
188
+ ): void {
189
+ if (operation.done) return;
190
+ operation.done = true;
191
+ if (operation.timer) {
192
+ (this.options.timers ?? { clearTimeout }).clearTimeout(operation.timer);
193
+ }
194
+ this.operations.delete(operation.opId);
195
+ safeEmit(operation.emit, {
196
+ kind: "engine.connect.event",
197
+ opId: operation.opId,
198
+ engine: operation.engine,
199
+ phase: "done",
200
+ ok,
201
+ message,
202
+ });
203
+ }
204
+ }
205
+
206
+ function safeEmit(
207
+ emit: EmitEngineConnectEvent,
208
+ frame: EngineConnectEventFrame,
209
+ ): void {
210
+ try {
211
+ emit(frame);
212
+ } catch {
213
+ // The bridge socket owns delivery failures.
214
+ }
215
+ }
216
+
217
+ export function createEngineConnectManager(
218
+ options: EngineConnectManagerOptions,
219
+ ): EngineConnectManager {
220
+ return new EngineConnectManager(options);
221
+ }
@@ -413,17 +413,35 @@ export class EngineLoginManager {
413
413
  /** Consume Claude's one-time code exactly once. */
414
414
  input(opId: string, text: string): boolean {
415
415
  const operation = this.operations.get(opId);
416
- if (
417
- !operation ||
418
- !this.isCurrent(operation) ||
419
- operation.engine !== "claude" ||
420
- operation.stage !== "awaiting_input" ||
421
- operation.inputUsed ||
422
- operation.finishing
423
- ) {
416
+ // Every rejection names itself in the host log: a silently dropped code
417
+ // reads as "Waiting for the host…" forever on the cloud pane (live
418
+ // 2026-08-20, the guard chain gave the operator nothing to act on).
419
+ // Never log `text` — it is one-time OAuth material.
420
+ if (!operation) {
421
+ console.warn(`[engine-login] input dropped: unknown opId ${opId}`);
422
+ return false;
423
+ }
424
+ const rejection = !this.isCurrent(operation)
425
+ ? "operation belongs to a previous bridge generation"
426
+ : operation.engine !== "claude"
427
+ ? `engine is ${operation.engine}, not claude`
428
+ : operation.stage !== "awaiting_input"
429
+ ? `stage is ${operation.stage}, not awaiting_input`
430
+ : operation.inputUsed
431
+ ? "a code was already submitted for this operation"
432
+ : operation.finishing
433
+ ? "the operation is already finishing"
434
+ : null;
435
+ if (rejection !== null) {
436
+ console.warn(
437
+ `[engine-login] input dropped for ${opId}: ${rejection}`,
438
+ );
424
439
  return false;
425
440
  }
426
441
  if (!isValidOneTimeInput(text)) {
442
+ console.warn(
443
+ `[engine-login] input rejected for ${opId}: failed one-time-input validation (length ${text.length})`,
444
+ );
427
445
  void this.fail(
428
446
  operation,
429
447
  "invalid_input",
@@ -431,6 +449,9 @@ export class EngineLoginManager {
431
449
  );
432
450
  return false;
433
451
  }
452
+ console.log(
453
+ `[engine-login] input accepted for ${opId} (${text.length} chars)`,
454
+ );
434
455
  operation.inputUsed = true;
435
456
  operation.outputTail = "";
436
457
  // CARRIAGE RETURN, not newline: `claude setup-token` is an Ink raw-mode
@@ -441,13 +462,20 @@ export class EngineLoginManager {
441
462
  // hung on exactly this.
442
463
  const value = `${text}\r`;
443
464
  if (!operation.process) {
465
+ console.log(
466
+ `[engine-login] input buffered for ${opId} (login process still starting)`,
467
+ );
444
468
  operation.pendingInput = value;
445
469
  return true;
446
470
  }
447
471
  try {
448
472
  operation.process.writeInput(value);
473
+ console.log(`[engine-login] input written to the login process for ${opId}`);
449
474
  return true;
450
475
  } catch {
476
+ console.warn(
477
+ `[engine-login] input write FAILED for ${opId}: the login process rejected stdin`,
478
+ );
451
479
  void this.fail(
452
480
  operation,
453
481
  "invalid_input",
@@ -643,6 +671,9 @@ export class EngineLoginManager {
643
671
  ? extractClaudeOAuthToken(operation.outputTail)
644
672
  : null;
645
673
  if (token) {
674
+ console.log(
675
+ `[engine-login] token detected in CLI output for ${operation.opId}; persisting`,
676
+ );
646
677
  operation.outputTail = "";
647
678
  this.beginClaudeFinish(operation, token);
648
679
  return;
@@ -650,6 +681,9 @@ export class EngineLoginManager {
650
681
  if (operation.stage === "starting") {
651
682
  const authorizeUrl = findSafeHttpsUrl(operation.outputTail);
652
683
  if (authorizeUrl) {
684
+ console.log(
685
+ `[engine-login] authorize URL found for ${operation.opId}; awaiting the one-time code`,
686
+ );
653
687
  operation.stage = "awaiting_input";
654
688
  safeEmit(operation.emit, authorizeEvent(operation, authorizeUrl));
655
689
  safeEmit(operation.emit, {
@@ -694,12 +728,17 @@ export class EngineLoginManager {
694
728
  if (operation.finishPromise) await operation.finishPromise;
695
729
  return;
696
730
  }
731
+ const evidence = lastSignificantOutputLine(operation.outputTail);
732
+ console.warn(
733
+ `[engine-login] login process for ${operation.opId} ended (exit ${result.code ?? "spawn-failed"}) before completion` +
734
+ (evidence ? `; last output: ${evidence}` : ""),
735
+ );
697
736
  await this.fail(
698
737
  operation,
699
738
  result.spawnFailed ? "unavailable" : "start_failed",
700
739
  result.spawnFailed
701
740
  ? "The login runtime is unavailable."
702
- : `${engineLabel(operation.engine)} login did not complete.`,
741
+ : `${engineLabel(operation.engine)} login did not complete.${evidence ? ` Last output: ${evidence}` : ""}`,
703
742
  );
704
743
  }
705
744
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.49",
3
+ "version": "0.9.51",
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>",
package/src/main.ts CHANGED
@@ -144,7 +144,13 @@ import {
144
144
  type HostConfigResult,
145
145
  } from "../lib/host-config";
146
146
  import { listEngineAccounts } from "../lib/engine-accounts";
147
+ import { createEngineConnectManager } from "../lib/engine-connect";
147
148
  import { createEngineLoginManager } from "../lib/engine-login";
149
+ import {
150
+ engineCatalog,
151
+ engineCliStatuses,
152
+ engineStatuses,
153
+ } from "../lib/engines";
148
154
  // Importing the real factory triggers the built-in adapters' register()
149
155
  // calls (claude, codex), so the registry is populated before we advertise.
150
156
  import "../lib/agents/factory";
@@ -166,12 +172,17 @@ import {
166
172
  HOST_MAINTENANCE_READINESS_PROTOCOL_FEATURE,
167
173
  HOST_CONFIG_PROTOCOL_FEATURE,
168
174
  HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE,
175
+ HOST_ENGINE_CONNECT_PROTOCOL_FEATURE,
169
176
  HOST_ENGINE_LOGIN_PROTOCOL_FEATURE,
170
177
  HOST_LOGS_PROTOCOL_FEATURE,
171
178
  HOST_TASK_INVENTORY_PROTOCOL_FEATURE,
172
179
  MAX_ENGINE_ACCOUNT_LABEL_CHARS,
173
180
  MAX_ENGINE_ACCOUNTS_ADVERTISED,
181
+ MAX_ENGINE_CATALOG_ENTRIES,
182
+ isEngineConnectStartFrame,
183
+ isEngineConnectStopFrame,
174
184
  type EngineAccountSummary,
185
+ type EngineCatalogCapability,
175
186
  MAX_HOST_TASK_INVENTORY_PAGE_SIZE,
176
187
  MCP_GATEWAY_HEALTH_PROTOCOL_FEATURE,
177
188
  SECRETARY_TYPED_DISPATCH_PROTOCOL_FEATURE,
@@ -268,6 +279,7 @@ function trackRemoteOperationCleanup(cleanup: Promise<void>): Promise<void> {
268
279
 
269
280
  function stopRemoteOperationManagers(): Promise<void> {
270
281
  hostLogManager.stopAll();
282
+ engineConnectManager.stopAll();
271
283
  return trackRemoteOperationCleanup(
272
284
  Promise.all([
273
285
  engineLoginManager.stopAll(),
@@ -427,6 +439,13 @@ const engineLoginManager = createEngineLoginManager({
427
439
  tempRoot: join(env.uaiHome, "engine-login-bind"),
428
440
  reconcileOnCreate: false,
429
441
  });
442
+ // ADR-117: one-shot connect (pasted key / OpenCode re-detect) over the bridge.
443
+ const engineConnectManager = createEngineConnectManager({
444
+ readvertise: () => sendCapabilities(),
445
+ refreshEngineAccountAgents: (engine) =>
446
+ getOrchestrator().refreshEngineAccountAgents(engine),
447
+ ensureStandardImage: () => ensureStandardImage(),
448
+ });
430
449
  if (initialRuntime.status === "ready") {
431
450
  console.log(
432
451
  `[host-agent] container runtime ready (${initialRuntime.provider}, ${initialRuntime.preference})`,
@@ -549,6 +568,7 @@ function buildCapabilities(): HostCapabilities {
549
568
  HOST_ENGINE_LOGIN_PROTOCOL_FEATURE,
550
569
  HOST_CONFIG_PROTOCOL_FEATURE,
551
570
  HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE,
571
+ HOST_ENGINE_CONNECT_PROTOCOL_FEATURE,
552
572
  // The echo adapter cannot execute the in-task CLI. Advertising typed
553
573
  // dispatch in mock mode would let the composer create a Secretary that
554
574
  // has no way to wake crew.
@@ -565,10 +585,68 @@ function buildCapabilities(): HostCapabilities {
565
585
  mcpGateway: mcpGateway.state(),
566
586
  engineLogins: engineLoginManager.capabilities(),
567
587
  engineAccounts: engineAccountSummaries(),
588
+ engines: engineCatalogCapabilities(),
568
589
  githubUsers: connectedUserIds(),
569
590
  };
570
591
  }
571
592
 
593
+ // engineCliStatuses probes binaries asynchronously; capabilities build
594
+ // synchronously. The snapshot starts empty (cliInstalled: false) and each
595
+ // build kicks a refresh that re-advertises only when the answer changed.
596
+ let cliStatusSnapshot: Partial<Record<string, boolean>> = {};
597
+ let cliStatusRefreshing = false;
598
+ function refreshCliStatusSnapshot(): void {
599
+ if (cliStatusRefreshing) return;
600
+ cliStatusRefreshing = true;
601
+ void engineCliStatuses()
602
+ .then((next) => {
603
+ const changed =
604
+ JSON.stringify(next) !== JSON.stringify(cliStatusSnapshot);
605
+ cliStatusSnapshot = next;
606
+ if (changed) sendCapabilities();
607
+ })
608
+ .catch(() => {})
609
+ .finally(() => {
610
+ cliStatusRefreshing = false;
611
+ });
612
+ }
613
+
614
+ /** ADR-117: the full engine catalog for the cloud pane — descriptor metadata
615
+ * plus live connection/CLI state. The descriptor table stays authoritative. */
616
+ function engineCatalogCapabilities(): EngineCatalogCapability[] {
617
+ try {
618
+ const statuses = engineStatuses();
619
+ const cli = cliStatusSnapshot;
620
+ refreshCliStatusSnapshot();
621
+ return engineCatalog()
622
+ .slice(0, MAX_ENGINE_CATALOG_ENTRIES)
623
+ .map((entry): EngineCatalogCapability => {
624
+ const keyUrl = entry.apiKeyUrl ?? entry.getKeyUrl;
625
+ return {
626
+ kind: entry.kind,
627
+ label: entry.label,
628
+ connected: statuses[entry.kind] === true,
629
+ cliInstalled: cli[entry.kind] === true,
630
+ remote:
631
+ entry.kind === "kimi"
632
+ ? "none"
633
+ : entry.authMode === "external-login"
634
+ ? "detect"
635
+ : "secret",
636
+ ...(entry.apiKeyHint !== null ? { hint: entry.apiKeyHint } : {}),
637
+ ...(keyUrl !== null ? { keyUrl } : {}),
638
+ notes: entry.notes ?? "",
639
+ ...(entry.loginCmd !== null ? { loginCmd: entry.loginCmd } : {}),
640
+ };
641
+ });
642
+ } catch (error) {
643
+ console.warn(
644
+ `[host-agent] engine catalog for capabilities failed: ${error instanceof Error ? error.message : String(error)}`,
645
+ );
646
+ return [];
647
+ }
648
+ }
649
+
572
650
  /** ADR-116: the labeled-accounts advertisement — bounded, labels-only. */
573
651
  function engineAccountSummaries(): EngineAccountSummary[] {
574
652
  const summaries: EngineAccountSummary[] = [];
@@ -1118,6 +1196,18 @@ async function connect(): Promise<void> {
1118
1196
  engineLoginManager.stop(frame.opId).then(() => {}),
1119
1197
  );
1120
1198
  break;
1199
+ case "engine.connect.start":
1200
+ engineConnectManager.start(
1201
+ frame.opId,
1202
+ frame.engine,
1203
+ frame.secret,
1204
+ frame.label,
1205
+ (event) => sendRemoteOperationFrame(event),
1206
+ );
1207
+ break;
1208
+ case "engine.connect.stop":
1209
+ engineConnectManager.stop(frame.opId);
1210
+ break;
1121
1211
  case "host.config.get":
1122
1212
  sendHostConfigAck(
1123
1213
  sendRemoteOperationFrame,
@@ -2279,6 +2369,8 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
2279
2369
  if (isEngineLoginInputFrame(frame)) return frame;
2280
2370
  if (isEngineLoginCallbackFrame(frame)) return frame;
2281
2371
  if (isEngineLoginStopFrame(frame)) return frame;
2372
+ if (isEngineConnectStartFrame(frame)) return frame;
2373
+ if (isEngineConnectStopFrame(frame)) return frame;
2282
2374
  if (isHostConfigGetFrame(frame)) return frame;
2283
2375
  if (isHostConfigSetFrame(frame)) return frame;
2284
2376
  if (
@@ -2589,6 +2681,16 @@ function rejectFrameDuringShutdown(
2589
2681
  message: error,
2590
2682
  });
2591
2683
  return true;
2684
+ case "engine.connect.start":
2685
+ send(socket, {
2686
+ kind: "engine.connect.event",
2687
+ opId: frame.opId,
2688
+ engine: frame.engine,
2689
+ phase: "done",
2690
+ ok: false,
2691
+ message: error,
2692
+ });
2693
+ return true;
2592
2694
  case "host.config.get":
2593
2695
  case "host.config.set":
2594
2696
  send(socket, {
@@ -2668,6 +2770,7 @@ function rejectFrameDuringShutdown(
2668
2770
  return true;
2669
2771
  case "host.logs.stop":
2670
2772
  case "engine.login.stop":
2773
+ case "engine.connect.stop":
2671
2774
  // Stops remain idempotent during drain and cannot admit new work.
2672
2775
  return false;
2673
2776
  case "engine.login.input":
package/src/protocol.ts CHANGED
@@ -92,6 +92,12 @@ export const HOST_CONFIG_PROTOCOL_FEATURE = "host-config-v1";
92
92
  export const HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE = "host-engine-accounts-v1";
93
93
  export const MAX_ENGINE_ACCOUNT_LABEL_CHARS = 64;
94
94
  export const MAX_ENGINE_ACCOUNTS_ADVERTISED = 48;
95
+ /** ADR-117: one-shot engine credentials over the bridge + the full engine
96
+ * catalog in capabilities, so every engine renders on the cloud pane. */
97
+ export const HOST_ENGINE_CONNECT_PROTOCOL_FEATURE = "host-engine-connect-v1";
98
+ export const MAX_ENGINE_CONNECT_SECRET_CHARS = 8_192;
99
+ export const MAX_ENGINE_CONNECT_LINE_CHARS = 512;
100
+ export const MAX_ENGINE_CATALOG_ENTRIES = 8;
95
101
  export const MAX_HOST_OP_ID_CHARS = 128;
96
102
  export const MAX_HOST_LOG_LINES = 1_000;
97
103
  export const MAX_HOST_LOG_LINE_BYTES = 4 * 1_024;
@@ -363,6 +369,143 @@ export interface EngineAccountSummary {
363
369
  isDefault: boolean;
364
370
  }
365
371
 
372
+ /** ADR-117 engine kinds — every engine the host's descriptor table knows. */
373
+ export type EngineConnectKind =
374
+ | "claude"
375
+ | "codex"
376
+ | "kimi"
377
+ | "grok"
378
+ | "cursor"
379
+ | "opencode";
380
+
381
+ /** ADR-117: one catalog row per engine, advertised in capabilities so the
382
+ * cloud pane renders every engine from host truth — including the exact
383
+ * instruction copy for flows that only work on the host itself. */
384
+ export interface EngineCatalogCapability {
385
+ kind: EngineConnectKind;
386
+ label: string;
387
+ connected: boolean;
388
+ cliInstalled: boolean;
389
+ /** What the cloud can do remotely: submit a one-shot secret, re-detect a
390
+ * host-side login, or nothing (instructions only). */
391
+ remote: "secret" | "detect" | "none";
392
+ hint?: string;
393
+ keyUrl?: string;
394
+ notes: string;
395
+ loginCmd?: string;
396
+ }
397
+
398
+ /** ADR-117: start a one-shot connect. `secret` is the operator's pasted
399
+ * credential — the ONLY frame it ever rides; it is never echoed back,
400
+ * logged, or persisted cloud-side (ADR-100 amendment). */
401
+ export interface EngineConnectStartFrame {
402
+ kind: "engine.connect.start";
403
+ opId: HostOpId;
404
+ engine: EngineConnectKind;
405
+ secret?: string;
406
+ label?: string;
407
+ }
408
+
409
+ export interface EngineConnectStopFrame {
410
+ kind: "engine.connect.stop";
411
+ opId: HostOpId;
412
+ }
413
+
414
+ /** Secret-blind host→cloud connect progress: sanitized CLI/progress lines,
415
+ * then exactly one `done`. */
416
+ export interface EngineConnectEventFrame {
417
+ kind: "engine.connect.event";
418
+ opId: HostOpId;
419
+ engine: EngineConnectKind;
420
+ phase: "line" | "done";
421
+ line?: string;
422
+ ok?: boolean;
423
+ message?: string;
424
+ }
425
+
426
+ export function isEngineConnectKind(
427
+ value: unknown,
428
+ ): value is EngineConnectKind {
429
+ return (
430
+ value === "claude" ||
431
+ value === "codex" ||
432
+ value === "kimi" ||
433
+ value === "grok" ||
434
+ value === "cursor" ||
435
+ value === "opencode"
436
+ );
437
+ }
438
+
439
+ export function isEngineConnectStartFrame(
440
+ value: unknown,
441
+ ): value is EngineConnectStartFrame {
442
+ const frame = exactWireObject(
443
+ value,
444
+ ["kind", "opId", "engine"],
445
+ ["secret", "label"],
446
+ );
447
+ if (
448
+ !frame ||
449
+ frame.kind !== "engine.connect.start" ||
450
+ !isHostOpId(frame.opId) ||
451
+ !isEngineConnectKind(frame.engine)
452
+ ) {
453
+ return false;
454
+ }
455
+ if (frame.secret !== undefined) {
456
+ if (
457
+ typeof frame.secret !== "string" ||
458
+ frame.secret.length < 1 ||
459
+ frame.secret.length > MAX_ENGINE_CONNECT_SECRET_CHARS
460
+ ) {
461
+ return false;
462
+ }
463
+ }
464
+ return frame.label === undefined || isEngineAccountLabel(frame.label);
465
+ }
466
+
467
+ export function isEngineConnectStopFrame(
468
+ value: unknown,
469
+ ): value is EngineConnectStopFrame {
470
+ const frame = exactWireObject(value, ["kind", "opId"]);
471
+ return Boolean(
472
+ frame && frame.kind === "engine.connect.stop" && isHostOpId(frame.opId),
473
+ );
474
+ }
475
+
476
+ export function isEngineConnectEventFrame(
477
+ value: unknown,
478
+ ): value is EngineConnectEventFrame {
479
+ const frame = exactWireObject(
480
+ value,
481
+ ["kind", "opId", "engine", "phase"],
482
+ ["line", "ok", "message"],
483
+ );
484
+ if (
485
+ !frame ||
486
+ frame.kind !== "engine.connect.event" ||
487
+ !isHostOpId(frame.opId) ||
488
+ !isEngineConnectKind(frame.engine)
489
+ ) {
490
+ return false;
491
+ }
492
+ if (frame.phase === "line") {
493
+ return (
494
+ typeof frame.line === "string" &&
495
+ frame.line.length >= 1 &&
496
+ frame.line.length <= MAX_ENGINE_CONNECT_LINE_CHARS &&
497
+ frame.ok === undefined &&
498
+ frame.message === undefined
499
+ );
500
+ }
501
+ if (frame.phase !== "done") return false;
502
+ return (
503
+ typeof frame.ok === "boolean" &&
504
+ frame.line === undefined &&
505
+ (frame.message === undefined || isBoundedMessage(frame.message))
506
+ );
507
+ }
508
+
366
509
  /** The exact local host settings ADR-100 permits over the bridge. */
367
510
  export interface HostConfigState {
368
511
  agentCliAutoupdate: boolean;
@@ -803,6 +946,10 @@ export interface HostCapabilities {
803
946
  /** ADR-116: labeled engine accounts (default + extras), re-advertised on
804
947
  * every account mutation. Labels only — credentials never leave the host. */
805
948
  engineAccounts?: EngineAccountSummary[];
949
+ /** ADR-117: the full engine catalog — connection state plus the metadata
950
+ * the cloud pane needs to render every engine, including the instruction
951
+ * copy for host-only sign-in flows. */
952
+ engines?: EngineCatalogCapability[];
806
953
  // ADR-033: cloud user ids that currently have a GitHub token ON THIS HOST —
807
954
  // the per-host gh-connected state shown on the host detail page. Re-advertised
808
955
  // whenever a token is added/removed. Optional: older hosts omit it.
@@ -1320,6 +1467,8 @@ export type CloudToHost =
1320
1467
  | EngineLoginInputFrame
1321
1468
  | EngineLoginCallbackFrame
1322
1469
  | EngineLoginStopFrame
1470
+ | EngineConnectStartFrame
1471
+ | EngineConnectStopFrame
1323
1472
  | HostConfigGetFrame
1324
1473
  | HostConfigSetFrame
1325
1474
  | {
@@ -1470,6 +1619,7 @@ export type HostToCloud =
1470
1619
  | HostLogsChunkFrame
1471
1620
  | HostLogsEndFrame
1472
1621
  | EngineLoginEventFrame
1622
+ | EngineConnectEventFrame
1473
1623
  | HostConfigAckFrame;
1474
1624
 
1475
1625
  export type HostEvent =