@vellumai/cli 0.4.48 → 0.4.49
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/README.md +7 -9
- package/package.json +1 -1
- package/src/adapters/install.sh +6 -6
- package/src/commands/clean.ts +11 -6
- package/src/commands/client.ts +5 -13
- package/src/commands/hatch.ts +8 -20
- package/src/commands/ps.ts +21 -3
- package/src/commands/recover.ts +1 -1
- package/src/commands/retire.ts +2 -5
- package/src/commands/setup.ts +172 -0
- package/src/commands/sleep.ts +1 -1
- package/src/commands/wake.ts +2 -2
- package/src/components/DefaultMainScreen.tsx +160 -19
- package/src/components/TextInput.tsx +159 -12
- package/src/index.ts +3 -0
- package/src/lib/assistant-config.ts +1 -6
- package/src/lib/constants.ts +7 -1
- package/src/lib/docker.ts +54 -6
- package/src/lib/doctor-client.ts +1 -1
- package/src/lib/gcp.ts +1 -1
- package/src/lib/http-client.ts +1 -2
- package/src/lib/input-history.ts +44 -0
- package/src/lib/local.ts +160 -161
- package/src/lib/orphan-detection.ts +13 -3
- package/src/lib/process.ts +3 -1
- package/src/lib/terminal-capabilities.ts +124 -0
- package/src/lib/xdg-log.ts +2 -2
package/src/lib/docker.ts
CHANGED
|
@@ -19,6 +19,57 @@ import {
|
|
|
19
19
|
|
|
20
20
|
const _require = createRequire(import.meta.url);
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Checks whether the `docker` CLI and daemon are available on the system.
|
|
24
|
+
* Installs Colima and Docker via Homebrew if the CLI is missing, and starts
|
|
25
|
+
* Colima if the Docker daemon is not reachable.
|
|
26
|
+
*/
|
|
27
|
+
async function ensureDockerInstalled(): Promise<void> {
|
|
28
|
+
let installed = false;
|
|
29
|
+
try {
|
|
30
|
+
await execOutput("docker", ["--version"]);
|
|
31
|
+
installed = true;
|
|
32
|
+
} catch {
|
|
33
|
+
// docker CLI not found — install it
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!installed) {
|
|
37
|
+
console.log("🐳 Docker not found. Installing via Homebrew...");
|
|
38
|
+
try {
|
|
39
|
+
await exec("brew", ["install", "colima", "docker"]);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Failed to install Docker via Homebrew. Please install Docker manually.\n${message}`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
await execOutput("docker", ["--version"]);
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error(
|
|
51
|
+
"Docker was installed but is still not available on PATH. " +
|
|
52
|
+
"You may need to restart your terminal.",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Verify the Docker daemon is reachable; start Colima if it isn't
|
|
58
|
+
try {
|
|
59
|
+
await exec("docker", ["info"]);
|
|
60
|
+
} catch {
|
|
61
|
+
console.log("🚀 Docker daemon not running. Starting Colima...");
|
|
62
|
+
try {
|
|
63
|
+
await exec("colima", ["start"]);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Failed to start Colima. Please run 'colima start' manually.\n${message}`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
22
73
|
interface DockerRoot {
|
|
23
74
|
/** Directory to use as the Docker build context */
|
|
24
75
|
root: string;
|
|
@@ -180,6 +231,8 @@ export async function hatchDocker(
|
|
|
180
231
|
): Promise<void> {
|
|
181
232
|
resetLogFile("hatch.log");
|
|
182
233
|
|
|
234
|
+
await ensureDockerInstalled();
|
|
235
|
+
|
|
183
236
|
let repoRoot: string;
|
|
184
237
|
let dockerfileDir: string;
|
|
185
238
|
try {
|
|
@@ -251,12 +304,7 @@ export async function hatchDocker(
|
|
|
251
304
|
];
|
|
252
305
|
|
|
253
306
|
// Pass through environment variables the assistant needs
|
|
254
|
-
for (const envVar of [
|
|
255
|
-
"ANTHROPIC_API_KEY",
|
|
256
|
-
"GATEWAY_RUNTIME_PROXY_ENABLED",
|
|
257
|
-
"RUNTIME_PROXY_BEARER_TOKEN",
|
|
258
|
-
"VELLUM_ASSISTANT_PLATFORM_URL",
|
|
259
|
-
]) {
|
|
307
|
+
for (const envVar of ["ANTHROPIC_API_KEY", "VELLUM_PLATFORM_URL"]) {
|
|
260
308
|
if (process.env[envVar]) {
|
|
261
309
|
runArgs.push("-e", `${envVar}=${process.env[envVar]}`);
|
|
262
310
|
}
|
package/src/lib/doctor-client.ts
CHANGED
package/src/lib/gcp.ts
CHANGED
|
@@ -637,7 +637,7 @@ export async function hatchGcp(
|
|
|
637
637
|
species === "vellum" &&
|
|
638
638
|
(await checkCurlFailure(instanceName, project, zone, account))
|
|
639
639
|
) {
|
|
640
|
-
const installScriptUrl = `${process.env.
|
|
640
|
+
const installScriptUrl = `${process.env.VELLUM_PLATFORM_URL ?? "https://assistant.vellum.ai"}/install.sh`;
|
|
641
641
|
console.log(
|
|
642
642
|
`\ud83d\udd04 Detected install script curl failure for ${installScriptUrl}, attempting recovery...`,
|
|
643
643
|
);
|
package/src/lib/http-client.ts
CHANGED
|
@@ -32,8 +32,7 @@ export function buildDaemonUrl(port: number): string {
|
|
|
32
32
|
*/
|
|
33
33
|
export function readHttpToken(instanceDir?: string): string | undefined {
|
|
34
34
|
const baseDataDir =
|
|
35
|
-
instanceDir ??
|
|
36
|
-
(process.env.BASE_DATA_DIR?.trim() || homedir());
|
|
35
|
+
instanceDir ?? (process.env.BASE_DATA_DIR?.trim() || homedir());
|
|
37
36
|
const tokenPath = join(baseDataDir, ".vellum", "http-token");
|
|
38
37
|
try {
|
|
39
38
|
const token = readFileSync(tokenPath, "utf-8").trim();
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { dirname, join } from "path";
|
|
4
|
+
|
|
5
|
+
const MAX_ENTRIES = 1000;
|
|
6
|
+
|
|
7
|
+
function historyPath(): string {
|
|
8
|
+
return join(homedir(), ".vellum", "input-history");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function loadHistory(): string[] {
|
|
12
|
+
try {
|
|
13
|
+
const path = historyPath();
|
|
14
|
+
if (!existsSync(path)) return [];
|
|
15
|
+
const content = readFileSync(path, "utf-8");
|
|
16
|
+
return content
|
|
17
|
+
.split("\n")
|
|
18
|
+
.filter((line) => line.length > 0)
|
|
19
|
+
.slice(-MAX_ENTRIES);
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function appendHistory(entry: string): void {
|
|
26
|
+
const trimmed = entry.trim();
|
|
27
|
+
if (!trimmed || trimmed.startsWith("/")) return;
|
|
28
|
+
try {
|
|
29
|
+
const path = historyPath();
|
|
30
|
+
const dir = dirname(path);
|
|
31
|
+
if (!existsSync(dir)) {
|
|
32
|
+
mkdirSync(dir, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
const existing = loadHistory();
|
|
35
|
+
// Deduplicate: remove previous occurrence of the same entry
|
|
36
|
+
const deduped = existing.filter((e) => e !== trimmed);
|
|
37
|
+
deduped.push(trimmed);
|
|
38
|
+
// Keep only the last MAX_ENTRIES
|
|
39
|
+
const trimmedList = deduped.slice(-MAX_ENTRIES);
|
|
40
|
+
writeFileSync(path, trimmedList.join("\n") + "\n", { mode: 0o600 });
|
|
41
|
+
} catch {
|
|
42
|
+
// Best-effort persistence — don't crash on write failure
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/lib/local.ts
CHANGED
|
@@ -10,10 +10,7 @@ import { createRequire } from "module";
|
|
|
10
10
|
import { homedir, hostname, networkInterfaces, platform } from "os";
|
|
11
11
|
import { dirname, join } from "path";
|
|
12
12
|
|
|
13
|
-
import {
|
|
14
|
-
loadLatestAssistant,
|
|
15
|
-
type LocalInstanceResources,
|
|
16
|
-
} from "./assistant-config.js";
|
|
13
|
+
import { type LocalInstanceResources } from "./assistant-config.js";
|
|
17
14
|
import { GATEWAY_PORT } from "./constants.js";
|
|
18
15
|
import { httpHealthCheck, waitForDaemonReady } from "./http-client.js";
|
|
19
16
|
import { stopProcessByPidFile } from "./process.js";
|
|
@@ -244,11 +241,6 @@ async function startDaemonFromSource(
|
|
|
244
241
|
...process.env,
|
|
245
242
|
RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
|
|
246
243
|
};
|
|
247
|
-
// Preserve TCP listener flag when falling back from bundled desktop daemon
|
|
248
|
-
if (process.env.VELLUM_DESKTOP_APP) {
|
|
249
|
-
env.VELLUM_DAEMON_TCP_ENABLED =
|
|
250
|
-
process.env.VELLUM_DAEMON_TCP_ENABLED || "1";
|
|
251
|
-
}
|
|
252
244
|
if (resources) {
|
|
253
245
|
env.BASE_DATA_DIR = resources.instanceDir;
|
|
254
246
|
env.RUNTIME_HTTP_PORT = String(resources.daemonPort);
|
|
@@ -354,16 +346,6 @@ async function startDaemonWatchFromSource(
|
|
|
354
346
|
}
|
|
355
347
|
|
|
356
348
|
function resolveGatewayDir(): string {
|
|
357
|
-
const override = process.env.VELLUM_GATEWAY_DIR?.trim();
|
|
358
|
-
if (override) {
|
|
359
|
-
if (!isGatewaySourceDir(override)) {
|
|
360
|
-
throw new Error(
|
|
361
|
-
`VELLUM_GATEWAY_DIR is set to "${override}", but it is not a valid gateway source directory.`,
|
|
362
|
-
);
|
|
363
|
-
}
|
|
364
|
-
return override;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
349
|
// Source tree: cli/src/lib/ → ../../.. → repo root → gateway/
|
|
368
350
|
const sourceDir = join(import.meta.dir, "..", "..", "..", "gateway");
|
|
369
351
|
if (isGatewaySourceDir(sourceDir)) {
|
|
@@ -386,7 +368,7 @@ function resolveGatewayDir(): string {
|
|
|
386
368
|
return dirname(pkgPath);
|
|
387
369
|
} catch {
|
|
388
370
|
throw new Error(
|
|
389
|
-
"Gateway not found. Ensure @vellumai/vellum-gateway is installed
|
|
371
|
+
"Gateway not found. Ensure @vellumai/vellum-gateway is installed or run from the source tree.",
|
|
390
372
|
);
|
|
391
373
|
}
|
|
392
374
|
}
|
|
@@ -397,6 +379,79 @@ function normalizeIngressUrl(value: unknown): string | undefined {
|
|
|
397
379
|
return normalized || undefined;
|
|
398
380
|
}
|
|
399
381
|
|
|
382
|
+
// ── Workspace config helpers ──
|
|
383
|
+
|
|
384
|
+
function getWorkspaceConfigPath(instanceDir?: string): string {
|
|
385
|
+
const baseDataDir =
|
|
386
|
+
instanceDir ??
|
|
387
|
+
(process.env.BASE_DATA_DIR?.trim() || (process.env.HOME ?? homedir()));
|
|
388
|
+
return join(baseDataDir, ".vellum", "workspace", "config.json");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function loadWorkspaceConfig(instanceDir?: string): Record<string, unknown> {
|
|
392
|
+
const configPath = getWorkspaceConfigPath(instanceDir);
|
|
393
|
+
try {
|
|
394
|
+
if (!existsSync(configPath)) return {};
|
|
395
|
+
return JSON.parse(readFileSync(configPath, "utf-8")) as Record<
|
|
396
|
+
string,
|
|
397
|
+
unknown
|
|
398
|
+
>;
|
|
399
|
+
} catch {
|
|
400
|
+
return {};
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function saveWorkspaceConfig(
|
|
405
|
+
config: Record<string, unknown>,
|
|
406
|
+
instanceDir?: string,
|
|
407
|
+
): void {
|
|
408
|
+
const configPath = getWorkspaceConfigPath(instanceDir);
|
|
409
|
+
const dir = dirname(configPath);
|
|
410
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
411
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Write gateway operational settings to the workspace config file so the
|
|
416
|
+
* gateway reads them at startup via its config.ts readWorkspaceConfig().
|
|
417
|
+
*/
|
|
418
|
+
function writeGatewayConfig(
|
|
419
|
+
instanceDir?: string,
|
|
420
|
+
opts?: {
|
|
421
|
+
runtimeProxyEnabled?: boolean;
|
|
422
|
+
runtimeProxyRequireAuth?: boolean;
|
|
423
|
+
unmappedPolicy?: "reject" | "default";
|
|
424
|
+
defaultAssistantId?: string;
|
|
425
|
+
routingEntries?: Array<{
|
|
426
|
+
type: "conversation_id" | "actor_id";
|
|
427
|
+
key: string;
|
|
428
|
+
assistantId: string;
|
|
429
|
+
}>;
|
|
430
|
+
},
|
|
431
|
+
): void {
|
|
432
|
+
const config = loadWorkspaceConfig(instanceDir);
|
|
433
|
+
const gateway = (config.gateway ?? {}) as Record<string, unknown>;
|
|
434
|
+
|
|
435
|
+
if (opts?.runtimeProxyEnabled !== undefined) {
|
|
436
|
+
gateway.runtimeProxyEnabled = opts.runtimeProxyEnabled;
|
|
437
|
+
}
|
|
438
|
+
if (opts?.runtimeProxyRequireAuth !== undefined) {
|
|
439
|
+
gateway.runtimeProxyRequireAuth = opts.runtimeProxyRequireAuth;
|
|
440
|
+
}
|
|
441
|
+
if (opts?.unmappedPolicy !== undefined) {
|
|
442
|
+
gateway.unmappedPolicy = opts.unmappedPolicy;
|
|
443
|
+
}
|
|
444
|
+
if (opts?.defaultAssistantId !== undefined) {
|
|
445
|
+
gateway.defaultAssistantId = opts.defaultAssistantId;
|
|
446
|
+
}
|
|
447
|
+
if (opts?.routingEntries !== undefined) {
|
|
448
|
+
gateway.routingEntries = opts.routingEntries;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
config.gateway = gateway;
|
|
452
|
+
saveWorkspaceConfig(config, instanceDir);
|
|
453
|
+
}
|
|
454
|
+
|
|
400
455
|
function readWorkspaceIngressPublicBaseUrl(
|
|
401
456
|
instanceDir?: string,
|
|
402
457
|
): string | undefined {
|
|
@@ -472,48 +527,29 @@ export async function discoverPublicUrl(
|
|
|
472
527
|
port?: number,
|
|
473
528
|
): Promise<string | undefined> {
|
|
474
529
|
const effectivePort = port ?? GATEWAY_PORT;
|
|
475
|
-
const cloud = process.env.VELLUM_CLOUD;
|
|
476
530
|
|
|
477
|
-
|
|
531
|
+
// Discover local and cloud addresses in parallel so the cloud metadata
|
|
532
|
+
// timeout (1s) doesn't block startup when a local address is immediately
|
|
533
|
+
// available.
|
|
534
|
+
const cloudIpPromise = discoverCloudExternalIp();
|
|
478
535
|
|
|
479
|
-
//
|
|
480
|
-
|
|
481
|
-
try {
|
|
482
|
-
if (cloud === "gcp") {
|
|
483
|
-
const resp = await fetch(
|
|
484
|
-
"http://169.254.169.254/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip",
|
|
485
|
-
{ headers: { "Metadata-Flavor": "Google" } },
|
|
486
|
-
);
|
|
487
|
-
if (resp.ok) externalIp = (await resp.text()).trim();
|
|
488
|
-
} else if (cloud === "aws") {
|
|
489
|
-
// Use IMDSv2 (token-based) for compatibility with HttpTokens=required
|
|
490
|
-
const tokenResp = await fetch(
|
|
491
|
-
"http://169.254.169.254/latest/api/token",
|
|
492
|
-
{
|
|
493
|
-
method: "PUT",
|
|
494
|
-
headers: { "X-aws-ec2-metadata-token-ttl-seconds": "30" },
|
|
495
|
-
},
|
|
496
|
-
);
|
|
497
|
-
if (tokenResp.ok) {
|
|
498
|
-
const token = await tokenResp.text();
|
|
499
|
-
const ipResp = await fetch(
|
|
500
|
-
"http://169.254.169.254/latest/meta-data/public-ipv4",
|
|
501
|
-
{ headers: { "X-aws-ec2-metadata-token": token } },
|
|
502
|
-
);
|
|
503
|
-
if (ipResp.ok) externalIp = (await ipResp.text()).trim();
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
} catch {
|
|
507
|
-
// metadata service not reachable
|
|
508
|
-
}
|
|
536
|
+
// Resolve local address synchronously (no I/O).
|
|
537
|
+
const localUrl = discoverLocalUrl(effectivePort);
|
|
509
538
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
}
|
|
539
|
+
const cloudIp = await cloudIpPromise;
|
|
540
|
+
if (cloudIp) {
|
|
541
|
+
console.log(` Discovered external IP: ${cloudIp}`);
|
|
542
|
+
return `http://${cloudIp}:${effectivePort}`;
|
|
514
543
|
}
|
|
515
544
|
|
|
516
|
-
|
|
545
|
+
return localUrl;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Resolve a LAN-reachable URL without any async I/O. Returns the best local
|
|
550
|
+
* address or falls back to localhost.
|
|
551
|
+
*/
|
|
552
|
+
function discoverLocalUrl(effectivePort: number): string {
|
|
517
553
|
// On macOS, prefer the .local hostname (Bonjour/mDNS) so other devices on
|
|
518
554
|
// the same network can reach the gateway by name.
|
|
519
555
|
if (platform() === "darwin") {
|
|
@@ -534,6 +570,58 @@ export async function discoverPublicUrl(
|
|
|
534
570
|
return `http://localhost:${effectivePort}`;
|
|
535
571
|
}
|
|
536
572
|
|
|
573
|
+
/**
|
|
574
|
+
* Attempt to discover the VM's external/public IP via cloud metadata services.
|
|
575
|
+
* Tries GCP and AWS IMDSv2 in parallel with a short timeout. Returns undefined
|
|
576
|
+
* on non-cloud machines (the metadata endpoint is unreachable).
|
|
577
|
+
*/
|
|
578
|
+
async function discoverCloudExternalIp(): Promise<string | undefined> {
|
|
579
|
+
const timeoutMs = 1000;
|
|
580
|
+
|
|
581
|
+
const gcpPromise = (async (): Promise<string | undefined> => {
|
|
582
|
+
try {
|
|
583
|
+
const resp = await fetch(
|
|
584
|
+
"http://169.254.169.254/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip",
|
|
585
|
+
{
|
|
586
|
+
headers: { "Metadata-Flavor": "Google" },
|
|
587
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
588
|
+
},
|
|
589
|
+
);
|
|
590
|
+
if (resp.ok) return (await resp.text()).trim() || undefined;
|
|
591
|
+
} catch {
|
|
592
|
+
// metadata service not reachable
|
|
593
|
+
}
|
|
594
|
+
return undefined;
|
|
595
|
+
})();
|
|
596
|
+
|
|
597
|
+
const awsPromise = (async (): Promise<string | undefined> => {
|
|
598
|
+
try {
|
|
599
|
+
const tokenResp = await fetch("http://169.254.169.254/latest/api/token", {
|
|
600
|
+
method: "PUT",
|
|
601
|
+
headers: { "X-aws-ec2-metadata-token-ttl-seconds": "30" },
|
|
602
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
603
|
+
});
|
|
604
|
+
if (tokenResp.ok) {
|
|
605
|
+
const token = await tokenResp.text();
|
|
606
|
+
const ipResp = await fetch(
|
|
607
|
+
"http://169.254.169.254/latest/meta-data/public-ipv4",
|
|
608
|
+
{
|
|
609
|
+
headers: { "X-aws-ec2-metadata-token": token },
|
|
610
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
611
|
+
},
|
|
612
|
+
);
|
|
613
|
+
if (ipResp.ok) return (await ipResp.text()).trim() || undefined;
|
|
614
|
+
}
|
|
615
|
+
} catch {
|
|
616
|
+
// metadata service not reachable
|
|
617
|
+
}
|
|
618
|
+
return undefined;
|
|
619
|
+
})();
|
|
620
|
+
|
|
621
|
+
const [gcpIp, awsIp] = await Promise.all([gcpPromise, awsPromise]);
|
|
622
|
+
return gcpIp ?? awsIp;
|
|
623
|
+
}
|
|
624
|
+
|
|
537
625
|
/**
|
|
538
626
|
* Returns the macOS Bonjour/mDNS `.local` hostname (e.g. "Vargass-Mac-Mini.local"),
|
|
539
627
|
* or undefined if not running on macOS or the hostname cannot be determined.
|
|
@@ -680,7 +768,6 @@ export async function startLocalDaemon(
|
|
|
680
768
|
const daemonEnv: Record<string, string> = {
|
|
681
769
|
HOME: process.env.HOME || homedir(),
|
|
682
770
|
PATH: `${bunBinDir}:${basePath}`,
|
|
683
|
-
VELLUM_DAEMON_TCP_ENABLED: "1",
|
|
684
771
|
};
|
|
685
772
|
// Forward optional config env vars the daemon may need
|
|
686
773
|
for (const key of [
|
|
@@ -689,10 +776,6 @@ export async function startLocalDaemon(
|
|
|
689
776
|
"QDRANT_HTTP_PORT",
|
|
690
777
|
"QDRANT_URL",
|
|
691
778
|
"RUNTIME_HTTP_PORT",
|
|
692
|
-
"VELLUM_DAEMON_TCP_PORT",
|
|
693
|
-
"VELLUM_DAEMON_TCP_HOST",
|
|
694
|
-
"VELLUM_KEYCHAIN_BROKER_SOCKET",
|
|
695
|
-
"VELLUM_DEBUG",
|
|
696
779
|
"SENTRY_DSN",
|
|
697
780
|
"TMPDIR",
|
|
698
781
|
"USER",
|
|
@@ -804,7 +887,6 @@ export async function startLocalDaemon(
|
|
|
804
887
|
}
|
|
805
888
|
|
|
806
889
|
export async function startGateway(
|
|
807
|
-
assistantId?: string,
|
|
808
890
|
watch: boolean = false,
|
|
809
891
|
resources?: LocalInstanceResources,
|
|
810
892
|
): Promise<string> {
|
|
@@ -827,118 +909,35 @@ export async function startGateway(
|
|
|
827
909
|
|
|
828
910
|
console.log("🌐 Starting gateway...");
|
|
829
911
|
|
|
830
|
-
// Resolve the default assistant ID for the gateway. Prefer the explicitly
|
|
831
|
-
// provided assistantId (from hatch), then env override, then lockfile.
|
|
832
|
-
const resolvedAssistantId =
|
|
833
|
-
assistantId ||
|
|
834
|
-
process.env.GATEWAY_DEFAULT_ASSISTANT_ID ||
|
|
835
|
-
loadLatestAssistant()?.assistantId;
|
|
836
|
-
|
|
837
|
-
// Read the bearer token so the gateway can authenticate proxied requests
|
|
838
|
-
// (e.g. from paired iOS devices). Respect VELLUM_HTTP_TOKEN_PATH and
|
|
839
|
-
// BASE_DATA_DIR for consistency with gateway/config.ts and the daemon.
|
|
840
|
-
// When resources are provided, the token lives under the instance directory.
|
|
841
|
-
const httpTokenPath =
|
|
842
|
-
process.env.VELLUM_HTTP_TOKEN_PATH ??
|
|
843
|
-
(resources
|
|
844
|
-
? join(resources.instanceDir, ".vellum", "http-token")
|
|
845
|
-
: join(
|
|
846
|
-
process.env.BASE_DATA_DIR?.trim() || homedir(),
|
|
847
|
-
".vellum",
|
|
848
|
-
"http-token",
|
|
849
|
-
));
|
|
850
|
-
let runtimeProxyBearerToken: string | undefined;
|
|
851
|
-
try {
|
|
852
|
-
const tok = readFileSync(httpTokenPath, "utf-8").trim();
|
|
853
|
-
if (tok) runtimeProxyBearerToken = tok;
|
|
854
|
-
} catch {
|
|
855
|
-
// Token file doesn't exist yet — daemon hasn't written it.
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
// If no token is available (first startup — daemon hasn't written it yet),
|
|
859
|
-
// poll for the file to appear. On fresh installs the daemon may take 60s+
|
|
860
|
-
// for Qdrant download, migrations, and first-time init. Starting the
|
|
861
|
-
// gateway without auth is a security risk since the config is loaded once
|
|
862
|
-
// at startup and never reloads, so we fail rather than silently disabling auth.
|
|
863
|
-
if (!runtimeProxyBearerToken) {
|
|
864
|
-
console.log(" Waiting for bearer token file...");
|
|
865
|
-
const maxWait = 60000;
|
|
866
|
-
const pollInterval = 500;
|
|
867
|
-
const start = Date.now();
|
|
868
|
-
const pidFile =
|
|
869
|
-
resources?.pidFile ??
|
|
870
|
-
join(
|
|
871
|
-
process.env.BASE_DATA_DIR?.trim() || homedir(),
|
|
872
|
-
".vellum",
|
|
873
|
-
"vellum.pid",
|
|
874
|
-
);
|
|
875
|
-
while (Date.now() - start < maxWait) {
|
|
876
|
-
await new Promise((r) => setTimeout(r, pollInterval));
|
|
877
|
-
try {
|
|
878
|
-
const tok = readFileSync(httpTokenPath, "utf-8").trim();
|
|
879
|
-
if (tok) {
|
|
880
|
-
runtimeProxyBearerToken = tok;
|
|
881
|
-
break;
|
|
882
|
-
}
|
|
883
|
-
} catch {
|
|
884
|
-
// File still doesn't exist, keep polling.
|
|
885
|
-
}
|
|
886
|
-
// Check if the daemon process is still alive — no point waiting if it crashed
|
|
887
|
-
try {
|
|
888
|
-
const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
|
|
889
|
-
if (pid) process.kill(pid, 0); // throws if process doesn't exist
|
|
890
|
-
} catch {
|
|
891
|
-
break; // daemon process is gone
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
if (!runtimeProxyBearerToken) {
|
|
897
|
-
throw new Error(
|
|
898
|
-
`Bearer token file not found at ${httpTokenPath} after 60s.\n` +
|
|
899
|
-
" The gateway cannot start without authentication — this would leave the proxy permanently unauthenticated.\n" +
|
|
900
|
-
" Ensure the daemon is running and has written the token file, or set VELLUM_HTTP_TOKEN_PATH to the correct path.",
|
|
901
|
-
);
|
|
902
|
-
}
|
|
903
912
|
const effectiveDaemonPort =
|
|
904
913
|
resources?.daemonPort ?? Number(process.env.RUNTIME_HTTP_PORT || "7821");
|
|
905
914
|
|
|
915
|
+
// Write gateway operational settings to workspace config before starting
|
|
916
|
+
// the gateway process. The gateway reads these at startup from config.json.
|
|
917
|
+
writeGatewayConfig(resources?.instanceDir, {
|
|
918
|
+
runtimeProxyEnabled: true,
|
|
919
|
+
runtimeProxyRequireAuth: true,
|
|
920
|
+
unmappedPolicy: "default",
|
|
921
|
+
defaultAssistantId: "self",
|
|
922
|
+
});
|
|
923
|
+
|
|
906
924
|
const gatewayEnv: Record<string, string> = {
|
|
907
925
|
...(process.env as Record<string, string>),
|
|
908
|
-
GATEWAY_RUNTIME_PROXY_ENABLED: "true",
|
|
909
|
-
GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: "true",
|
|
910
|
-
RUNTIME_PROXY_BEARER_TOKEN: runtimeProxyBearerToken,
|
|
911
926
|
RUNTIME_HTTP_PORT: String(effectiveDaemonPort),
|
|
912
927
|
GATEWAY_PORT: String(effectiveGatewayPort),
|
|
913
|
-
// Skip the drain window for locally-launched gateways — there is no load
|
|
914
|
-
// balancer draining connections, so waiting serves no purpose and causes
|
|
915
|
-
// `vellum sleep` to SIGKILL the gateway when the CLI timeout is shorter
|
|
916
|
-
// than the drain window. Respect an explicit env override.
|
|
917
|
-
GATEWAY_SHUTDOWN_DRAIN_MS: process.env.GATEWAY_SHUTDOWN_DRAIN_MS || "0",
|
|
918
928
|
...(watch ? { VELLUM_DEV: "1" } : {}),
|
|
919
929
|
// Set BASE_DATA_DIR so the gateway loads the correct signing key and
|
|
920
930
|
// credentials for this instance (mirrors the daemon env setup).
|
|
921
931
|
...(resources ? { BASE_DATA_DIR: resources.instanceDir } : {}),
|
|
922
932
|
};
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
} else {
|
|
927
|
-
gatewayEnv.GATEWAY_UNMAPPED_POLICY = "default";
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
if (resolvedAssistantId) {
|
|
931
|
-
gatewayEnv.GATEWAY_DEFAULT_ASSISTANT_ID = resolvedAssistantId;
|
|
932
|
-
}
|
|
933
|
+
// The gateway reads the ingress URL from the workspace config file via
|
|
934
|
+
// ConfigFileCache — no env var passthrough needed. Log the resolved value
|
|
935
|
+
// for diagnostic visibility during startup.
|
|
933
936
|
const workspaceIngressPublicBaseUrl = readWorkspaceIngressPublicBaseUrl(
|
|
934
937
|
resources?.instanceDir,
|
|
935
938
|
);
|
|
936
|
-
const ingressPublicBaseUrl =
|
|
937
|
-
workspaceIngressPublicBaseUrl ??
|
|
938
|
-
normalizeIngressUrl(process.env.INGRESS_PUBLIC_BASE_URL) ??
|
|
939
|
-
publicUrl;
|
|
939
|
+
const ingressPublicBaseUrl = workspaceIngressPublicBaseUrl ?? publicUrl;
|
|
940
940
|
if (ingressPublicBaseUrl) {
|
|
941
|
-
gatewayEnv.INGRESS_PUBLIC_BASE_URL = ingressPublicBaseUrl;
|
|
942
941
|
console.log(` Ingress URL: ${ingressPublicBaseUrl}`);
|
|
943
942
|
}
|
|
944
943
|
|
|
@@ -1048,8 +1047,8 @@ export async function stopLocalProcesses(
|
|
|
1048
1047
|
await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
|
|
1049
1048
|
|
|
1050
1049
|
// Kill ngrok directly by PID rather than using stopProcessByPidFile, because
|
|
1051
|
-
// isVellumProcess()
|
|
1052
|
-
//
|
|
1050
|
+
// isVellumProcess() won't match the ngrok binary — resulting in a no-op that
|
|
1051
|
+
// leaves ngrok running.
|
|
1053
1052
|
const ngrokPidFile = join(vellumDir, "ngrok.pid");
|
|
1054
1053
|
if (existsSync(ngrokPidFile)) {
|
|
1055
1054
|
try {
|
|
@@ -13,13 +13,23 @@ export interface RemoteProcess {
|
|
|
13
13
|
export function classifyProcess(command: string): string {
|
|
14
14
|
if (/qdrant/.test(command)) return "qdrant";
|
|
15
15
|
if (/vellum-gateway/.test(command)) return "gateway";
|
|
16
|
-
if (
|
|
16
|
+
if (
|
|
17
|
+
/vellum-openclaw-adapter|openclaw-runtime-server|openclaw-http-server/.test(
|
|
18
|
+
command,
|
|
19
|
+
)
|
|
20
|
+
)
|
|
21
|
+
return "openclaw-adapter";
|
|
17
22
|
if (/vellum-daemon/.test(command)) return "assistant";
|
|
18
23
|
if (/daemon\s+(start|restart)/.test(command)) return "assistant";
|
|
24
|
+
if (/vellum-cli/.test(command)) return "vellum";
|
|
19
25
|
// Exclude macOS desktop app processes — their path contains .app/Contents/MacOS/
|
|
20
26
|
// but they are not background service processes.
|
|
21
27
|
if (/\.app\/Contents\/MacOS\//.test(command)) return "unknown";
|
|
22
|
-
|
|
28
|
+
// Match vellum CLI commands (e.g. "vellum hatch", "vellum sleep") but NOT
|
|
29
|
+
// unrelated processes whose working directory or repo path happens to contain
|
|
30
|
+
// "vellum" (e.g. /Users/runner/work/vellum-assistant/vellum-assistant/...).
|
|
31
|
+
// We require a word boundary before "vellum" to avoid matching repo paths.
|
|
32
|
+
if (/(?:^|\/)vellum(?:\s|$)/.test(command)) return "vellum";
|
|
23
33
|
return "unknown";
|
|
24
34
|
}
|
|
25
35
|
|
|
@@ -83,7 +93,7 @@ export async function detectOrphanedProcesses(): Promise<OrphanedProcess[]> {
|
|
|
83
93
|
try {
|
|
84
94
|
const output = await execOutput("sh", [
|
|
85
95
|
"-c",
|
|
86
|
-
"ps ax -o pid=,ppid=,args= | grep -E 'vellum|
|
|
96
|
+
"ps ax -o pid=,ppid=,args= | grep -E 'vellum|qdrant|openclaw' | grep -v grep",
|
|
87
97
|
]);
|
|
88
98
|
const procs = parseRemotePs(output);
|
|
89
99
|
const ownPid = String(process.pid);
|
package/src/lib/process.ts
CHANGED
|
@@ -13,7 +13,9 @@ function isVellumProcess(pid: number): boolean {
|
|
|
13
13
|
timeout: 3000,
|
|
14
14
|
stdio: ["ignore", "pipe", "ignore"],
|
|
15
15
|
}).trim();
|
|
16
|
-
return /vellum|@vellumai
|
|
16
|
+
return /vellum-daemon|vellum-cli|vellum-gateway|@vellumai|\/vellum\/|\/daemon\/main/.test(
|
|
17
|
+
output,
|
|
18
|
+
);
|
|
17
19
|
} catch {
|
|
18
20
|
return false;
|
|
19
21
|
}
|