@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.
- package/AGENTS.md +12 -2
- package/README.md +3 -3
- package/bun.lock +17 -17
- package/bunfig.toml +6 -0
- package/package.json +18 -18
- package/src/__tests__/assistant-config.test.ts +124 -0
- package/src/__tests__/env-drift.test.ts +87 -0
- package/src/__tests__/guardian-token.test.ts +225 -0
- package/src/__tests__/llm-provider-env-var-parity.test.ts +64 -0
- package/src/__tests__/multi-local.test.ts +90 -13
- package/src/__tests__/orphan-detection.test.ts +214 -0
- package/src/__tests__/platform-client.test.ts +204 -0
- package/src/__tests__/preload.ts +27 -0
- package/src/__tests__/ssh-user-guard.test.ts +28 -0
- package/src/__tests__/teleport.test.ts +1073 -56
- package/src/commands/backup.ts +8 -0
- package/src/commands/exec.ts +186 -0
- package/src/commands/hatch.ts +1 -1
- package/src/commands/login.ts +209 -9
- package/src/commands/logs.ts +652 -0
- package/src/commands/pair.ts +9 -1
- package/src/commands/ps.ts +37 -7
- package/src/commands/recover.ts +8 -4
- package/src/commands/restore.ts +8 -0
- package/src/commands/retire.ts +16 -9
- package/src/commands/rollback.ts +32 -33
- package/src/commands/ssh.ts +7 -0
- package/src/commands/teleport.ts +253 -1
- package/src/commands/upgrade.ts +43 -52
- package/src/commands/wake.ts +25 -10
- package/src/components/DefaultMainScreen.tsx +7 -1
- package/src/index.ts +6 -0
- package/src/lib/__tests__/docker.test.ts +168 -0
- package/src/lib/assistant-config.ts +82 -108
- package/src/lib/aws.ts +12 -1
- package/src/lib/config-utils.ts +4 -4
- package/src/lib/constants.ts +0 -10
- package/src/lib/docker.ts +158 -8
- package/src/lib/environments/__tests__/paths.test.ts +228 -0
- package/src/lib/environments/__tests__/resolve.test.ts +226 -0
- package/src/lib/environments/__tests__/seeds.test.ts +72 -0
- package/src/lib/environments/paths.ts +109 -0
- package/src/lib/environments/resolve.ts +96 -0
- package/src/lib/environments/seeds.ts +74 -0
- package/src/lib/environments/types.ts +60 -0
- package/src/lib/exec-apple-container.ts +122 -0
- package/src/lib/gcp.ts +12 -1
- package/src/lib/guardian-token.ts +71 -10
- package/src/lib/hatch-local.ts +44 -23
- package/src/lib/local.ts +47 -5
- package/src/lib/orphan-detection.ts +28 -12
- package/src/lib/platform-client.ts +354 -24
- package/src/lib/retire-apple-container.ts +102 -0
- package/src/lib/ssh-apple-container.ts +166 -0
- package/src/lib/upgrade-lifecycle.ts +101 -28
- 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
|
-
|
|
13
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
+
}
|