@vellumai/cli 0.6.3 → 0.6.5

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.
Files changed (56) hide show
  1. package/AGENTS.md +12 -2
  2. package/README.md +3 -3
  3. package/bun.lock +17 -17
  4. package/bunfig.toml +6 -0
  5. package/package.json +18 -18
  6. package/src/__tests__/assistant-config.test.ts +124 -0
  7. package/src/__tests__/env-drift.test.ts +87 -0
  8. package/src/__tests__/guardian-token.test.ts +225 -0
  9. package/src/__tests__/llm-provider-env-var-parity.test.ts +64 -0
  10. package/src/__tests__/multi-local.test.ts +90 -13
  11. package/src/__tests__/orphan-detection.test.ts +214 -0
  12. package/src/__tests__/platform-client.test.ts +204 -0
  13. package/src/__tests__/preload.ts +27 -0
  14. package/src/__tests__/ssh-user-guard.test.ts +28 -0
  15. package/src/__tests__/teleport.test.ts +1073 -56
  16. package/src/commands/backup.ts +8 -0
  17. package/src/commands/exec.ts +186 -0
  18. package/src/commands/hatch.ts +1 -1
  19. package/src/commands/login.ts +209 -9
  20. package/src/commands/logs.ts +652 -0
  21. package/src/commands/pair.ts +9 -1
  22. package/src/commands/ps.ts +37 -7
  23. package/src/commands/recover.ts +8 -4
  24. package/src/commands/restore.ts +8 -0
  25. package/src/commands/retire.ts +16 -9
  26. package/src/commands/rollback.ts +32 -33
  27. package/src/commands/ssh.ts +7 -0
  28. package/src/commands/teleport.ts +253 -1
  29. package/src/commands/upgrade.ts +43 -52
  30. package/src/commands/wake.ts +25 -10
  31. package/src/components/DefaultMainScreen.tsx +7 -1
  32. package/src/index.ts +6 -0
  33. package/src/lib/__tests__/docker.test.ts +168 -0
  34. package/src/lib/assistant-config.ts +82 -108
  35. package/src/lib/aws.ts +12 -1
  36. package/src/lib/config-utils.ts +4 -4
  37. package/src/lib/constants.ts +0 -10
  38. package/src/lib/docker.ts +158 -8
  39. package/src/lib/environments/__tests__/paths.test.ts +228 -0
  40. package/src/lib/environments/__tests__/resolve.test.ts +226 -0
  41. package/src/lib/environments/__tests__/seeds.test.ts +72 -0
  42. package/src/lib/environments/paths.ts +109 -0
  43. package/src/lib/environments/resolve.ts +96 -0
  44. package/src/lib/environments/seeds.ts +74 -0
  45. package/src/lib/environments/types.ts +60 -0
  46. package/src/lib/exec-apple-container.ts +122 -0
  47. package/src/lib/gcp.ts +12 -1
  48. package/src/lib/guardian-token.ts +71 -10
  49. package/src/lib/hatch-local.ts +44 -23
  50. package/src/lib/local.ts +47 -5
  51. package/src/lib/orphan-detection.ts +28 -12
  52. package/src/lib/platform-client.ts +354 -24
  53. package/src/lib/retire-apple-container.ts +102 -0
  54. package/src/lib/ssh-apple-container.ts +166 -0
  55. package/src/lib/upgrade-lifecycle.ts +101 -28
  56. package/src/shared/provider-env-vars.ts +30 -6
@@ -6,36 +6,37 @@ import {
6
6
  existsSync,
7
7
  mkdirSync,
8
8
  } from "fs";
9
- import { homedir } from "os";
10
9
  import { join, dirname } from "path";
11
10
 
12
- function getXdgConfigHome(): string {
13
- return process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
14
- }
11
+ import { getLockfilePlatformBaseUrl } from "./assistant-config.js";
12
+ import { getConfigDir } from "./environments/paths.js";
13
+ import { getCurrentEnvironment } from "./environments/resolve.js";
15
14
 
16
15
  function getPlatformTokenPath(): string {
17
- return join(getXdgConfigHome(), "vellum", "platform-token");
16
+ return join(getConfigDir(getCurrentEnvironment()), "platform-token");
18
17
  }
19
18
 
19
+ /**
20
+ * Resolve the platform API base URL. Resolution order:
21
+ * 1. `platformBaseUrl` persisted on the lockfile by
22
+ * {@link syncConfigToLockfile} when the active assistant was last
23
+ * hatched/waked. This is the source of truth for "what URL does the
24
+ * currently-active assistant target" — reading the workspace
25
+ * `config.json` directly is incorrect for multi-instance and
26
+ * non-production XDG layouts because the CLI process has no way to
27
+ * know which instance to read from without first consulting the
28
+ * lockfile anyway.
29
+ * 2. `VELLUM_PLATFORM_URL` env var (explicit override, e.g. in CI).
30
+ * 3. The current environment's seed URL (e.g. `https://dev-platform.vellum.ai`
31
+ * for `VELLUM_ENVIRONMENT=dev`, `https://platform.vellum.ai` for prod).
32
+ * This makes the CLI environment-aware when no lockfile entry exists yet.
33
+ */
20
34
  export function getPlatformUrl(): string {
21
- let configUrl: string | undefined;
22
- try {
23
- const base = process.env.BASE_DATA_DIR?.trim() || homedir();
24
- const configPath = join(base, ".vellum", "workspace", "config.json");
25
- if (existsSync(configPath)) {
26
- const raw = JSON.parse(readFileSync(configPath, "utf-8")) as Record<
27
- string,
28
- unknown
29
- >;
30
- const val = (raw.platform as Record<string, unknown> | undefined)
31
- ?.baseUrl;
32
- if (typeof val === "string" && val.trim()) configUrl = val.trim();
33
- }
34
- } catch {
35
- // Config not available — fall through
36
- }
35
+ const lockfileUrl = getLockfilePlatformBaseUrl();
37
36
  return (
38
- configUrl || process.env.VELLUM_PLATFORM_URL || "https://platform.vellum.ai"
37
+ lockfileUrl ||
38
+ process.env.VELLUM_PLATFORM_URL?.trim() ||
39
+ getCurrentEnvironment().platformUrl
39
40
  );
40
41
  }
41
42
 
@@ -146,10 +147,307 @@ export interface HatchedAssistant {
146
147
  status: string;
147
148
  }
148
149
 
150
+ export interface HatchAssistantResult {
151
+ assistant: HatchedAssistant;
152
+ /** true when the platform returned an existing assistant (HTTP 200) */
153
+ reusedExisting: boolean;
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Self-hosted local assistant registration
158
+ // ---------------------------------------------------------------------------
159
+
160
+ export interface EnsureRegistrationResponse {
161
+ assistant: { id: string; name: string };
162
+ registration: {
163
+ client_installation_id: string;
164
+ runtime_assistant_id: string;
165
+ client_platform: string;
166
+ };
167
+ assistant_api_key: string | null;
168
+ webhook_secret: string;
169
+ }
170
+
171
+ /**
172
+ * Register (or re-confirm) a self-hosted local assistant with the platform.
173
+ *
174
+ * Calls `POST /v1/assistants/self-hosted-local/ensure-registration/`.
175
+ * The endpoint is idempotent: the first call provisions an API key;
176
+ * subsequent calls return `assistant_api_key: null`.
177
+ */
178
+ export async function ensureSelfHostedLocalRegistration(
179
+ token: string,
180
+ organizationId: string,
181
+ clientInstallationId: string,
182
+ runtimeAssistantId: string,
183
+ clientPlatform: string,
184
+ assistantVersion?: string,
185
+ platformUrl?: string,
186
+ ): Promise<EnsureRegistrationResponse> {
187
+ const resolvedUrl = platformUrl || getPlatformUrl();
188
+ const body: Record<string, string> = {
189
+ client_installation_id: clientInstallationId,
190
+ runtime_assistant_id: runtimeAssistantId,
191
+ client_platform: clientPlatform,
192
+ };
193
+ if (assistantVersion) {
194
+ body.assistant_version = assistantVersion;
195
+ }
196
+
197
+ const response = await fetch(
198
+ `${resolvedUrl}/v1/assistants/self-hosted-local/ensure-registration/`,
199
+ {
200
+ method: "POST",
201
+ headers: {
202
+ "Content-Type": "application/json",
203
+ Accept: "application/json",
204
+ "X-Session-Token": token,
205
+ "Vellum-Organization-Id": organizationId,
206
+ },
207
+ body: JSON.stringify(body),
208
+ },
209
+ );
210
+
211
+ if (response.status === 401 || response.status === 403) {
212
+ throw new Error("Authentication required for assistant registration.");
213
+ }
214
+
215
+ if (!response.ok) {
216
+ const detail = await response.text().catch(() => "");
217
+ throw new Error(
218
+ `Registration failed (${response.status}): ${detail || response.statusText}`,
219
+ );
220
+ }
221
+
222
+ return (await response.json()) as EnsureRegistrationResponse;
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // API key reprovisioning
227
+ // ---------------------------------------------------------------------------
228
+
229
+ export interface ReprovisionApiKeyResponse {
230
+ provisioning: {
231
+ assistant_api_key: string;
232
+ };
233
+ }
234
+
235
+ /**
236
+ * Reprovision (rotate) the API key for a self-hosted local assistant.
237
+ *
238
+ * Calls `POST /v1/assistants/self-hosted-local/reprovision-api-key/`.
239
+ * Returns a fresh API key. The previous key is revoked server-side.
240
+ */
241
+ export async function reprovisionAssistantApiKey(
242
+ token: string,
243
+ organizationId: string,
244
+ clientInstallationId: string,
245
+ runtimeAssistantId: string,
246
+ clientPlatform: string,
247
+ assistantVersion?: string,
248
+ platformUrl?: string,
249
+ ): Promise<ReprovisionApiKeyResponse> {
250
+ const resolvedUrl = platformUrl || getPlatformUrl();
251
+ const body: Record<string, string> = {
252
+ client_installation_id: clientInstallationId,
253
+ runtime_assistant_id: runtimeAssistantId,
254
+ client_platform: clientPlatform,
255
+ };
256
+ if (assistantVersion) {
257
+ body.assistant_version = assistantVersion;
258
+ }
259
+
260
+ const response = await fetch(
261
+ `${resolvedUrl}/v1/assistants/self-hosted-local/reprovision-api-key/`,
262
+ {
263
+ method: "POST",
264
+ headers: {
265
+ "Content-Type": "application/json",
266
+ Accept: "application/json",
267
+ "X-Session-Token": token,
268
+ "Vellum-Organization-Id": organizationId,
269
+ },
270
+ body: JSON.stringify(body),
271
+ },
272
+ );
273
+
274
+ if (response.status === 401 || response.status === 403) {
275
+ throw new Error("Authentication required for API key reprovisioning.");
276
+ }
277
+
278
+ if (!response.ok) {
279
+ const detail = await response.text().catch(() => "");
280
+ throw new Error(
281
+ `API key reprovisioning failed (${response.status}): ${detail || response.statusText}`,
282
+ );
283
+ }
284
+
285
+ return (await response.json()) as ReprovisionApiKeyResponse;
286
+ }
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // Credential reading from running assistant via gateway
290
+ // ---------------------------------------------------------------------------
291
+
292
+ export interface GatewayCredentialResult {
293
+ /** The credential value, if found. */
294
+ value: string | null;
295
+ /** True when the gateway/daemon was unreachable (network error, timeout, etc.). */
296
+ unreachable: boolean;
297
+ }
298
+
299
+ /**
300
+ * Read an existing credential from the assistant's secret store via the
301
+ * gateway-proxied `POST /v1/secrets/read` endpoint (with `reveal: true`).
302
+ *
303
+ * Returns a result distinguishing "key not found" (`value: null,
304
+ * unreachable: false`) from "gateway unreachable" (`value: null,
305
+ * unreachable: true`). Callers should only reprovision when the gateway
306
+ * is reachable but the key is genuinely missing — reprovisioning while
307
+ * the gateway is down would revoke the old key server-side without being
308
+ * able to inject the replacement.
309
+ *
310
+ * Never throws.
311
+ */
312
+ export async function readGatewayCredential(
313
+ gatewayUrl: string,
314
+ name: string,
315
+ bearerToken?: string,
316
+ ): Promise<GatewayCredentialResult> {
317
+ try {
318
+ const headers: Record<string, string> = {
319
+ "Content-Type": "application/json",
320
+ Accept: "application/json",
321
+ };
322
+ if (bearerToken) {
323
+ headers["Authorization"] = `Bearer ${bearerToken}`;
324
+ }
325
+
326
+ const response = await fetch(`${gatewayUrl}/v1/secrets/read`, {
327
+ method: "POST",
328
+ headers,
329
+ body: JSON.stringify({ type: "credential", name, reveal: true }),
330
+ signal: AbortSignal.timeout(10_000),
331
+ });
332
+
333
+ if (!response.ok) {
334
+ // 5xx means the gateway/daemon backend is down — treat as unreachable
335
+ // so callers don't revoke a potentially valid key.
336
+ return { value: null, unreachable: response.status >= 500 };
337
+ }
338
+
339
+ const json = (await response.json()) as {
340
+ found: boolean;
341
+ value?: string;
342
+ unreachable?: boolean;
343
+ };
344
+ // The daemon's /v1/secrets/read returns `unreachable: true` when the
345
+ // credential backend (CES) can't be reached. Respect that signal.
346
+ if (json.unreachable) {
347
+ return { value: null, unreachable: true };
348
+ }
349
+ return {
350
+ value: json.found && json.value ? json.value : null,
351
+ unreachable: false,
352
+ };
353
+ } catch {
354
+ // Network error, timeout, or gateway down
355
+ return { value: null, unreachable: true };
356
+ }
357
+ }
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // Credential injection into running assistant via gateway
361
+ // ---------------------------------------------------------------------------
362
+
363
+ /**
364
+ * Inject a single credential into the assistant's secret store via the
365
+ * gateway's `POST /v1/secrets` endpoint.
366
+ *
367
+ * Mirrors the desktop app's `GatewayHTTPClient.post(path: "secrets", …)`
368
+ * calls in `LocalAssistantBootstrapService.swift`.
369
+ */
370
+ async function injectGatewayCredential(
371
+ gatewayUrl: string,
372
+ name: string,
373
+ value: string,
374
+ bearerToken?: string,
375
+ ): Promise<boolean> {
376
+ const headers: Record<string, string> = {
377
+ "Content-Type": "application/json",
378
+ Accept: "application/json",
379
+ };
380
+ if (bearerToken) {
381
+ headers["Authorization"] = `Bearer ${bearerToken}`;
382
+ }
383
+
384
+ const response = await fetch(`${gatewayUrl}/v1/secrets`, {
385
+ method: "POST",
386
+ headers,
387
+ body: JSON.stringify({ type: "credential", name, value }),
388
+ signal: AbortSignal.timeout(10_000),
389
+ });
390
+ return response.ok;
391
+ }
392
+
393
+ export interface CredentialInjectionParams {
394
+ gatewayUrl: string;
395
+ bearerToken?: string;
396
+ assistantApiKey?: string | null;
397
+ platformAssistantId: string;
398
+ platformBaseUrl: string;
399
+ organizationId: string;
400
+ userId?: string;
401
+ webhookSecret?: string | null;
402
+ }
403
+
404
+ /**
405
+ * Inject platform credentials into a running assistant via the gateway,
406
+ * mirroring `LocalAssistantBootstrapService.injectKeyIntoAssistant` et al.
407
+ *
408
+ * Each credential is posted individually. Failures are collected but do
409
+ * not prevent the remaining credentials from being injected.
410
+ *
411
+ * Returns true if all injections succeeded.
412
+ */
413
+ export async function injectCredentialsIntoAssistant(
414
+ params: CredentialInjectionParams,
415
+ ): Promise<boolean> {
416
+ const inject = (name: string, value: string) =>
417
+ injectGatewayCredential(params.gatewayUrl, name, value, params.bearerToken);
418
+
419
+ const promises: Promise<boolean>[] = [];
420
+
421
+ if (params.assistantApiKey) {
422
+ promises.push(inject("vellum:assistant_api_key", params.assistantApiKey));
423
+ }
424
+
425
+ promises.push(
426
+ inject("vellum:platform_assistant_id", params.platformAssistantId),
427
+ );
428
+
429
+ promises.push(inject("vellum:platform_base_url", params.platformBaseUrl));
430
+
431
+ promises.push(
432
+ inject("vellum:platform_organization_id", params.organizationId),
433
+ );
434
+
435
+ if (params.userId) {
436
+ promises.push(inject("vellum:platform_user_id", params.userId));
437
+ }
438
+
439
+ if (params.webhookSecret) {
440
+ promises.push(inject("vellum:webhook_secret", params.webhookSecret));
441
+ }
442
+
443
+ const results = await Promise.all(promises);
444
+ return results.every(Boolean);
445
+ }
446
+
149
447
  export async function hatchAssistant(
150
448
  token: string,
151
449
  platformUrl?: string,
152
- ): Promise<HatchedAssistant> {
450
+ ): Promise<HatchAssistantResult> {
153
451
  const resolvedUrl = platformUrl || getPlatformUrl();
154
452
  const url = `${resolvedUrl}/v1/assistants/hatch/`;
155
453
 
@@ -160,7 +458,8 @@ export async function hatchAssistant(
160
458
  });
161
459
 
162
460
  if (response.ok) {
163
- return (await response.json()) as HatchedAssistant;
461
+ const assistant = (await response.json()) as HatchedAssistant;
462
+ return { assistant, reusedExisting: response.status === 200 };
164
463
  }
165
464
 
166
465
  if (response.status === 401 || response.status === 403) {
@@ -186,6 +485,37 @@ export async function hatchAssistant(
186
485
  );
187
486
  }
188
487
 
488
+ /**
489
+ * Lightweight pre-check: returns the first active managed assistant for the
490
+ * authenticated user, or `null` if none exists. Calls `GET /v1/assistants/`
491
+ * and looks for any assistant with status "active".
492
+ *
493
+ * Used by the teleport flow to block BEFORE the expensive GCS upload when
494
+ * the user already has a platform assistant.
495
+ */
496
+ export async function checkExistingPlatformAssistant(
497
+ token: string,
498
+ platformUrl?: string,
499
+ ): Promise<HatchedAssistant | null> {
500
+ const resolvedUrl = platformUrl || getPlatformUrl();
501
+ const url = `${resolvedUrl}/v1/assistants/`;
502
+
503
+ const response = await fetch(url, {
504
+ headers: await authHeaders(token, platformUrl),
505
+ });
506
+
507
+ if (!response.ok) {
508
+ // Non-fatal: if the list call fails, fall through and let hatch handle it.
509
+ return null;
510
+ }
511
+
512
+ const body = (await response.json()) as {
513
+ results?: HatchedAssistant[];
514
+ };
515
+ const active = body.results?.find((a) => a.status === "active");
516
+ return active ?? null;
517
+ }
518
+
189
519
  export interface PlatformUser {
190
520
  id: string;
191
521
  email: string;
@@ -0,0 +1,102 @@
1
+ import { createConnection } from "net";
2
+ import { existsSync } from "fs";
3
+
4
+ import type { AssistantEntry } from "./assistant-config.js";
5
+
6
+ /**
7
+ * Retire an Apple Container assistant by sending a retire command to the
8
+ * macOS app via the management socket. The app handles the full lifecycle:
9
+ * stop the pod, archive the instance directory, remove the guardian token,
10
+ * deregister from the platform, and remove the lockfile entry.
11
+ */
12
+ export async function retireAppleContainer(
13
+ name: string,
14
+ entry: AssistantEntry,
15
+ ): Promise<void> {
16
+ console.log(`\u{1F5D1}\ufe0f Retiring Apple Container assistant '${name}'...\n`);
17
+
18
+ const mgmtSocket = entry.mgmtSocket as string | undefined;
19
+ if (!mgmtSocket) {
20
+ console.error(
21
+ `No management socket found for '${name}'.\n` +
22
+ "The assistant may not be running. If the macOS app is closed, " +
23
+ "open it and try again.",
24
+ );
25
+ process.exit(1);
26
+ }
27
+
28
+ if (!existsSync(mgmtSocket)) {
29
+ console.error(
30
+ `Management socket not found at ${mgmtSocket}.\n` +
31
+ "The assistant may have been stopped. Open the macOS app and try again.",
32
+ );
33
+ process.exit(1);
34
+ }
35
+
36
+ const handshake = JSON.stringify({ action: "retire" }) + "\n";
37
+
38
+ return new Promise<void>((resolve, reject) => {
39
+ const socket = createConnection({ path: mgmtSocket }, () => {
40
+ socket.write(handshake);
41
+ });
42
+
43
+ const TIMEOUT_MS = 30_000;
44
+ const chunks: Buffer[] = [];
45
+ let totalLen = 0;
46
+
47
+ socket.setTimeout(TIMEOUT_MS);
48
+ socket.on("timeout", () => {
49
+ console.error("Timed out waiting for retire response from the macOS app.");
50
+ socket.destroy();
51
+ process.exit(1);
52
+ });
53
+
54
+ socket.on("data", (data: Buffer) => {
55
+ chunks.push(data);
56
+ totalLen += data.length;
57
+ const accumulated = Buffer.concat(chunks, totalLen);
58
+ const nlIndex = accumulated.indexOf(0x0a);
59
+ if (nlIndex === -1) return;
60
+
61
+ const responseLine = accumulated.slice(0, nlIndex).toString("utf-8");
62
+ socket.destroy();
63
+
64
+ let response: { status: string; message?: string };
65
+ try {
66
+ response = JSON.parse(responseLine) as {
67
+ status: string;
68
+ message?: string;
69
+ };
70
+ } catch {
71
+ reject(new Error("Invalid response from management socket."));
72
+ return;
73
+ }
74
+
75
+ if (response.status === "ok") {
76
+ console.log(`\u2705 Apple Container assistant '${name}' retired.`);
77
+ resolve();
78
+ } else {
79
+ reject(
80
+ new Error(
81
+ `Retire failed: ${response.message || "unknown error"}`,
82
+ ),
83
+ );
84
+ }
85
+ });
86
+
87
+ socket.on("error", (err) => {
88
+ reject(new Error(`Management socket error: ${err.message}`));
89
+ });
90
+
91
+ socket.on("end", () => {
92
+ if (chunks.length === 0) {
93
+ reject(
94
+ new Error(
95
+ "Management socket closed without responding. " +
96
+ "The macOS app may have crashed during retire.",
97
+ ),
98
+ );
99
+ }
100
+ });
101
+ });
102
+ }
@@ -0,0 +1,166 @@
1
+ import { createConnection } from "net";
2
+ import { existsSync } from "fs";
3
+
4
+ import type { AssistantEntry } from "./assistant-config";
5
+
6
+ /**
7
+ * Connect to an Apple Container assistant via its management socket.
8
+ * Sends a JSON handshake then relays stdin/stdout in raw mode.
9
+ */
10
+ export async function sshAppleContainer(
11
+ entry: AssistantEntry,
12
+ command?: string[],
13
+ service?: string,
14
+ ): Promise<void> {
15
+ const mgmtSocket = entry.mgmtSocket as string | undefined;
16
+ if (!mgmtSocket) {
17
+ console.error(
18
+ `No management socket found for '${entry.assistantId}'.\n` +
19
+ "The assistant may not have finished starting. Try again in a moment.",
20
+ );
21
+ process.exit(1);
22
+ }
23
+
24
+ if (!existsSync(mgmtSocket)) {
25
+ console.error(
26
+ `Management socket not found at ${mgmtSocket}.\n` +
27
+ "The assistant may have been stopped. Run 'vellum hatch' to start it.",
28
+ );
29
+ process.exit(1);
30
+ }
31
+
32
+ console.log(
33
+ `🔗 Connecting to ${entry.assistantId} via apple container exec...\n`,
34
+ );
35
+
36
+ const cols = process.stdout.columns || 120;
37
+ const rows = process.stdout.rows || 40;
38
+
39
+ const handshake =
40
+ JSON.stringify({
41
+ command: command && command.length > 0 ? command : ["/bin/bash"],
42
+ service: service || "vellum-assistant",
43
+ cols,
44
+ rows,
45
+ }) + "\n";
46
+
47
+ return new Promise<void>((resolve, reject) => {
48
+ const socket = createConnection({ path: mgmtSocket }, () => {
49
+ // Send handshake as soon as connected.
50
+ socket.write(handshake);
51
+ });
52
+
53
+ // 10s handshake timeout — matches SSH ConnectTimeout.
54
+ const HANDSHAKE_TIMEOUT_MS = 10_000;
55
+ let handshakeComplete = false;
56
+ const handshakeChunks: Buffer[] = [];
57
+ let handshakeLen = 0;
58
+
59
+ socket.setTimeout(HANDSHAKE_TIMEOUT_MS);
60
+ socket.on("timeout", () => {
61
+ if (!handshakeComplete) {
62
+ console.error(
63
+ "Timed out waiting for handshake response from management socket.",
64
+ );
65
+ socket.destroy();
66
+ process.exit(1);
67
+ }
68
+ // After handshake, no timeout — interactive session runs indefinitely.
69
+ });
70
+
71
+ socket.on("data", (data: Buffer) => {
72
+ if (!handshakeComplete) {
73
+ // Accumulate raw buffers until we find a newline (end of JSON response).
74
+ handshakeChunks.push(data);
75
+ handshakeLen += data.length;
76
+ const accumulated = Buffer.concat(handshakeChunks, handshakeLen);
77
+ const nlIndex = accumulated.indexOf(0x0a);
78
+ if (nlIndex === -1) return; // Wait for more data.
79
+
80
+ const responseLine = accumulated.slice(0, nlIndex).toString("utf-8");
81
+ const remainder = accumulated.slice(nlIndex + 1);
82
+ handshakeComplete = true;
83
+ socket.setTimeout(0); // Disable timeout for interactive session.
84
+
85
+ let response: { status: string; message?: string };
86
+ try {
87
+ response = JSON.parse(responseLine) as {
88
+ status: string;
89
+ message?: string;
90
+ };
91
+ } catch {
92
+ console.error("Invalid handshake response from management socket.");
93
+ socket.destroy();
94
+ process.exit(1);
95
+ return;
96
+ }
97
+
98
+ if (response.status !== "ok") {
99
+ console.error(`Exec failed: ${response.message || "unknown error"}`);
100
+ socket.destroy();
101
+ process.exit(1);
102
+ return;
103
+ }
104
+
105
+ // Handshake succeeded — enter raw mode and relay stdio.
106
+ if (process.stdin.isTTY) {
107
+ process.stdin.setRawMode(true);
108
+ }
109
+ process.stdin.resume();
110
+ process.stdin.pipe(socket);
111
+
112
+ // Write any raw bytes that arrived after the handshake newline.
113
+ if (remainder.length > 0) {
114
+ process.stdout.write(remainder);
115
+ }
116
+
117
+ // From now on, relay socket data to stdout.
118
+ return;
119
+ }
120
+
121
+ // Raw mode: relay container output to stdout.
122
+ process.stdout.write(data);
123
+ });
124
+
125
+ socket.on("end", () => {
126
+ cleanup();
127
+ if (handshakeComplete) {
128
+ resolve();
129
+ } else {
130
+ reject(
131
+ new Error(
132
+ "Management socket closed before handshake completed. " +
133
+ "The assistant may be restarting.",
134
+ ),
135
+ );
136
+ }
137
+ });
138
+
139
+ socket.on("error", (err) => {
140
+ cleanup();
141
+ reject(new Error(`Management socket error: ${err.message}`));
142
+ });
143
+
144
+ socket.on("close", () => {
145
+ cleanup();
146
+ if (handshakeComplete) {
147
+ resolve();
148
+ } else {
149
+ reject(
150
+ new Error(
151
+ "Management socket closed before handshake completed. " +
152
+ "The assistant may be restarting.",
153
+ ),
154
+ );
155
+ }
156
+ });
157
+
158
+ function cleanup(): void {
159
+ if (process.stdin.isTTY) {
160
+ process.stdin.setRawMode(false);
161
+ }
162
+ process.stdin.unpipe(socket);
163
+ process.stdin.pause();
164
+ }
165
+ });
166
+ }