@runuai/host 0.9.49 → 0.9.50
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-connect.ts +221 -0
- package/package.json +1 -1
- package/src/main.ts +103 -0
- package/src/protocol.ts +150 -0
|
@@ -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
|
+
}
|
package/package.json
CHANGED
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 =
|