@vellumai/cli 0.7.0 → 0.7.2
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 +3 -11
- package/README.md +49 -0
- package/bun.lock +0 -15
- package/package.json +1 -6
- package/src/__tests__/backup.test.ts +591 -0
- package/src/__tests__/config-utils.test.ts +35 -48
- package/src/__tests__/teleport.test.ts +597 -37
- package/src/commands/backup.ts +149 -70
- package/src/commands/client.ts +56 -14
- package/src/commands/events.ts +3 -0
- package/src/commands/exec.ts +34 -12
- package/src/commands/hatch.ts +3 -7
- package/src/commands/login.ts +15 -33
- package/src/commands/logs.ts +2 -7
- package/src/commands/ps.ts +41 -6
- package/src/commands/restore.ts +32 -47
- package/src/commands/setup.ts +38 -73
- package/src/commands/ssh.ts +2 -5
- package/src/commands/teleport.ts +148 -34
- package/src/commands/tunnel.ts +2 -7
- package/src/commands/upgrade.ts +114 -7
- package/src/commands/wake.ts +5 -16
- package/src/components/DefaultMainScreen.tsx +65 -129
- package/src/index.ts +2 -13
- package/src/lib/__tests__/docker.test.ts +50 -32
- package/src/lib/__tests__/local-runtime-client.test.ts +308 -25
- package/src/lib/__tests__/platform-client-signed-url.test.ts +237 -2
- package/src/lib/__tests__/runtime-url.test.ts +125 -0
- package/src/lib/__tests__/terminal-session.test.ts +202 -0
- package/src/lib/assistant-client.ts +18 -26
- package/src/lib/assistant-config.ts +34 -41
- package/src/lib/backup-ops.ts +43 -17
- package/src/lib/cli-error.ts +1 -0
- package/src/lib/client-identity.ts +1 -1
- package/src/lib/config-utils.ts +1 -97
- package/src/lib/docker-statefulset.ts +381 -0
- package/src/lib/docker.ts +8 -247
- package/src/lib/guardian-token.ts +56 -6
- package/src/lib/hatch-local.ts +3 -26
- package/src/lib/job-polling.ts +1 -1
- package/src/lib/local-runtime-client.ts +162 -28
- package/src/lib/local.ts +35 -64
- package/src/lib/ngrok.ts +36 -26
- package/src/lib/platform-client.ts +97 -221
- package/src/lib/platform-releases.ts +23 -0
- package/src/lib/retire-local.ts +2 -2
- package/src/lib/runtime-url.ts +52 -0
- package/src/lib/sync-cloud-assistants.ts +126 -0
- package/src/lib/terminal-client.ts +6 -1
- package/src/lib/terminal-session.ts +127 -48
- package/src/lib/tui-log.ts +60 -0
- package/src/lib/upgrade-lifecycle.ts +65 -0
- package/src/lib/xdg-log.ts +10 -4
- package/src/commands/pair.ts +0 -212
|
@@ -1,7 +1,14 @@
|
|
|
1
|
+
import type { AssistantEntry } from "./assistant-config.js";
|
|
1
2
|
import {
|
|
3
|
+
authHeaders,
|
|
4
|
+
invalidateOrgIdCache,
|
|
2
5
|
parseUnifiedJobStatus,
|
|
3
6
|
type UnifiedJobStatus,
|
|
4
7
|
} from "./platform-client.js";
|
|
8
|
+
import {
|
|
9
|
+
resolveRuntimeMigrationUrl,
|
|
10
|
+
resolveRuntimeUrl,
|
|
11
|
+
} from "./runtime-url.js";
|
|
5
12
|
|
|
6
13
|
/**
|
|
7
14
|
* Thrown when the local runtime returns 409 for an export/import request
|
|
@@ -34,6 +41,29 @@ function bearerHeaders(token: string): Record<string, string> {
|
|
|
34
41
|
};
|
|
35
42
|
}
|
|
36
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Build the auth + content headers for a runtime migration request.
|
|
46
|
+
*
|
|
47
|
+
* - For `cloud === "vellum"` we go through the platform's wildcard runtime
|
|
48
|
+
* proxy, which authenticates user-session / vak_ tokens via DRF's default
|
|
49
|
+
* authentication classes — `authHeaders()` produces the right combination
|
|
50
|
+
* (`X-Session-Token` + `Vellum-Organization-Id`, or `Authorization: Bearer
|
|
51
|
+
* vak_...`).
|
|
52
|
+
* - For local/docker the runtime endpoint expects a guardian-token bearer.
|
|
53
|
+
*/
|
|
54
|
+
async function migrationRequestHeaders(
|
|
55
|
+
entry: Pick<AssistantEntry, "cloud" | "runtimeUrl">,
|
|
56
|
+
token: string,
|
|
57
|
+
): Promise<Record<string, string>> {
|
|
58
|
+
if (entry.cloud === "vellum") {
|
|
59
|
+
return {
|
|
60
|
+
...(await authHeaders(token, entry.runtimeUrl)),
|
|
61
|
+
Accept: "application/json",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return bearerHeaders(token);
|
|
65
|
+
}
|
|
66
|
+
|
|
37
67
|
interface Raw409Body {
|
|
38
68
|
detail?: string;
|
|
39
69
|
// The runtime's current 409 contract nests the payload under `error`:
|
|
@@ -69,13 +99,21 @@ async function throwIfInProgress(
|
|
|
69
99
|
}
|
|
70
100
|
|
|
71
101
|
/**
|
|
72
|
-
* Kick off an async export-to-GCS job on the
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* {
|
|
102
|
+
* Kick off an async export-to-GCS job on the assistant's runtime.
|
|
103
|
+
*
|
|
104
|
+
* For local/docker assistants this POSTs to
|
|
105
|
+
* `{runtimeUrl}/v1/migrations/export-to-gcs` with guardian-token bearer
|
|
106
|
+
* auth. For platform-managed (cloud="vellum") assistants the URL is rewritten
|
|
107
|
+
* to the wildcard-runtime-proxy shape
|
|
108
|
+
* `{platformUrl}/v1/assistants/<assistantId>/migrations/export-to-gcs` and
|
|
109
|
+
* authenticated via the platform-token header set the platform's DRF auth
|
|
110
|
+
* accepts (session / vak_).
|
|
111
|
+
*
|
|
112
|
+
* Returns the 202-accepted `job_id`. On 409 (another export in flight)
|
|
113
|
+
* throws {@link MigrationInProgressError} with the existing job_id.
|
|
76
114
|
*/
|
|
77
115
|
export async function localRuntimeExportToGcs(
|
|
78
|
-
|
|
116
|
+
entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
|
|
79
117
|
token: string,
|
|
80
118
|
params: { uploadUrl: string; description?: string },
|
|
81
119
|
): Promise<{ jobId: string }> {
|
|
@@ -84,11 +122,14 @@ export async function localRuntimeExportToGcs(
|
|
|
84
122
|
body.description = params.description;
|
|
85
123
|
}
|
|
86
124
|
|
|
87
|
-
const response = await fetch(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
125
|
+
const response = await fetch(
|
|
126
|
+
resolveRuntimeMigrationUrl(entry, "export-to-gcs"),
|
|
127
|
+
{
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: await migrationRequestHeaders(entry, token),
|
|
130
|
+
body: JSON.stringify(body),
|
|
131
|
+
},
|
|
132
|
+
);
|
|
92
133
|
|
|
93
134
|
await throwIfInProgress(response, "export_in_progress");
|
|
94
135
|
|
|
@@ -110,20 +151,29 @@ export async function localRuntimeExportToGcs(
|
|
|
110
151
|
}
|
|
111
152
|
|
|
112
153
|
/**
|
|
113
|
-
* Kick off an async import-from-GCS job on the
|
|
114
|
-
*
|
|
115
|
-
*
|
|
154
|
+
* Kick off an async import-from-GCS job on the assistant's runtime.
|
|
155
|
+
*
|
|
156
|
+
* For local/docker assistants this POSTs to
|
|
157
|
+
* `{runtimeUrl}/v1/migrations/import-from-gcs` with guardian-token bearer
|
|
158
|
+
* auth. For platform-managed (cloud="vellum") assistants the URL is rewritten
|
|
159
|
+
* to the wildcard-runtime-proxy shape
|
|
160
|
+
* `{platformUrl}/v1/assistants/<assistantId>/migrations/import-from-gcs` and
|
|
161
|
+
* authenticated via the platform token. On 409 throws
|
|
162
|
+
* {@link MigrationInProgressError}.
|
|
116
163
|
*/
|
|
117
164
|
export async function localRuntimeImportFromGcs(
|
|
118
|
-
|
|
165
|
+
entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
|
|
119
166
|
token: string,
|
|
120
167
|
params: { bundleUrl: string },
|
|
121
168
|
): Promise<{ jobId: string }> {
|
|
122
|
-
const response = await fetch(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
169
|
+
const response = await fetch(
|
|
170
|
+
resolveRuntimeMigrationUrl(entry, "import-from-gcs"),
|
|
171
|
+
{
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: await migrationRequestHeaders(entry, token),
|
|
174
|
+
body: JSON.stringify({ bundle_url: params.bundleUrl }),
|
|
175
|
+
},
|
|
176
|
+
);
|
|
127
177
|
|
|
128
178
|
await throwIfInProgress(response, "import_in_progress");
|
|
129
179
|
|
|
@@ -145,21 +195,28 @@ export async function localRuntimeImportFromGcs(
|
|
|
145
195
|
}
|
|
146
196
|
|
|
147
197
|
/**
|
|
148
|
-
* Poll the
|
|
149
|
-
*
|
|
150
|
-
*
|
|
198
|
+
* Poll the runtime's unified job-status endpoint.
|
|
199
|
+
*
|
|
200
|
+
* For local/docker assistants this GETs
|
|
201
|
+
* `{runtimeUrl}/v1/migrations/jobs/{jobId}` directly (guardian-token
|
|
202
|
+
* bearer). For platform-managed assistants it routes through the wildcard
|
|
203
|
+
* runtime proxy at
|
|
204
|
+
* `{platformUrl}/v1/assistants/<assistantId>/migrations/jobs/{jobId}` with
|
|
205
|
+
* platform-token auth — important: the platform's dedicated
|
|
206
|
+
* `/v1/migrations/jobs/{id}/` endpoint queries platform-side ImportJob
|
|
207
|
+
* records and would 404 on runtime-created job IDs.
|
|
151
208
|
*/
|
|
152
209
|
export async function localRuntimePollJobStatus(
|
|
153
|
-
|
|
210
|
+
entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
|
|
154
211
|
token: string,
|
|
155
212
|
jobId: string,
|
|
156
213
|
): Promise<UnifiedJobStatus> {
|
|
157
|
-
const response = await fetch(
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
214
|
+
const response = await fetch(
|
|
215
|
+
resolveRuntimeMigrationUrl(entry, `jobs/${jobId}`),
|
|
216
|
+
{
|
|
217
|
+
headers: await migrationRequestHeaders(entry, token),
|
|
161
218
|
},
|
|
162
|
-
|
|
219
|
+
);
|
|
163
220
|
|
|
164
221
|
if (response.status === 404) {
|
|
165
222
|
throw new Error("Migration job not found");
|
|
@@ -176,3 +233,80 @@ export async function localRuntimePollJobStatus(
|
|
|
176
233
|
>[0];
|
|
177
234
|
return parseUnifiedJobStatus(raw);
|
|
178
235
|
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The subset of `/v1/health` we care about. The runtime's full response
|
|
239
|
+
* includes additional fields (status, disk, memory, cpu, migrations, etc.)
|
|
240
|
+
* — we only model `version` here because that's all the CLI consumes today.
|
|
241
|
+
*/
|
|
242
|
+
export interface RuntimeIdentity {
|
|
243
|
+
version: string;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Fetch the target runtime's APP_VERSION via `/v1/health`. Used by
|
|
248
|
+
* `vellum teleport` and `vellum backup` to stamp the exported bundle's
|
|
249
|
+
* `min_runtime_version` with the version of the runtime that actually
|
|
250
|
+
* produced it — which can diverge from the orchestrating CLI's version when
|
|
251
|
+
* the target was upgraded independently.
|
|
252
|
+
*
|
|
253
|
+
* GETs `/v1/health` (not `/v1/identity`) so the call works on freshly-
|
|
254
|
+
* hatched runtimes that haven't completed onboarding. The `/v1/identity`
|
|
255
|
+
* handler reads `IDENTITY.md` from the workspace and 404s if it's missing
|
|
256
|
+
* — and `IDENTITY.md` is only written during onboarding, not hatch. The
|
|
257
|
+
* `/v1/health` handler returns the same `version` field unconditionally
|
|
258
|
+
* (no filesystem reads), so it's safe to call against any running runtime.
|
|
259
|
+
*
|
|
260
|
+
* For local/docker assistants this GETs `{runtimeUrl}/v1/health` with
|
|
261
|
+
* guardian-token bearer auth. For platform-managed (cloud="vellum")
|
|
262
|
+
* assistants the URL is rewritten to the wildcard runtime proxy shape
|
|
263
|
+
* `{platformUrl}/v1/assistants/<assistantId>/health` and authenticated via
|
|
264
|
+
* the platform token.
|
|
265
|
+
*
|
|
266
|
+
* For the vellum target this is the FIRST network call in the
|
|
267
|
+
* teleport/backup export flow, so a stale `Vellum-Organization-Id` cache
|
|
268
|
+
* entry would surface as a hard abort before any retry-friendly call (like
|
|
269
|
+
* `platformRequestSignedUrl`) gets a chance to recover. Mirror that helper's
|
|
270
|
+
* one-shot 401-retry: invalidate the org-ID cache and retry once. Local /
|
|
271
|
+
* docker entries do not use the org-ID cache and are wrapped in
|
|
272
|
+
* `callRuntimeWithAuthRetry` by callers for guardian-token refresh, so the
|
|
273
|
+
* retry is intentionally vellum-only.
|
|
274
|
+
*
|
|
275
|
+
* The function name is intentionally retained ("identity-ish info about the
|
|
276
|
+
* runtime") even though the implementation now hits `/v1/health` — renaming
|
|
277
|
+
* would force changes in 4+ callsites for no behavioral benefit.
|
|
278
|
+
*
|
|
279
|
+
* Throws on non-2xx so callers can surface the failure (we never silently
|
|
280
|
+
* fall back — see teleport.ts call site).
|
|
281
|
+
*/
|
|
282
|
+
export async function localRuntimeIdentity(
|
|
283
|
+
entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
|
|
284
|
+
token: string,
|
|
285
|
+
): Promise<RuntimeIdentity> {
|
|
286
|
+
const url = resolveRuntimeUrl(entry, "health");
|
|
287
|
+
const doRequest = async (): Promise<Response> =>
|
|
288
|
+
fetch(url, {
|
|
289
|
+
method: "GET",
|
|
290
|
+
headers: await migrationRequestHeaders(entry, token),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
let response = await doRequest();
|
|
294
|
+
if (response.status === 401 && entry.cloud === "vellum") {
|
|
295
|
+
// `entry.runtimeUrl` is the platform host for vellum-cloud entries
|
|
296
|
+
// (the wildcard runtime proxy lives there). Pass it as the cache key
|
|
297
|
+
// platformUrl so we invalidate the same entry that authHeaders cached.
|
|
298
|
+
invalidateOrgIdCache(token, entry.runtimeUrl);
|
|
299
|
+
response = await doRequest();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!response.ok) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`Failed to fetch runtime identity: ${response.status} ${response.statusText}`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
const body = (await response.json()) as { version?: unknown };
|
|
308
|
+
if (typeof body.version !== "string" || !body.version) {
|
|
309
|
+
throw new Error("Runtime identity response missing version");
|
|
310
|
+
}
|
|
311
|
+
return { version: body.version };
|
|
312
|
+
}
|
package/src/lib/local.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
writeFileSync,
|
|
9
9
|
} from "fs";
|
|
10
10
|
import { createRequire } from "module";
|
|
11
|
-
import { homedir,
|
|
11
|
+
import { homedir, networkInterfaces, platform, tmpdir } from "os";
|
|
12
12
|
import { dirname, join } from "path";
|
|
13
13
|
|
|
14
14
|
import {
|
|
@@ -111,7 +111,9 @@ function computeIpcSocketDirOverride(workspaceDir: string): string | undefined {
|
|
|
111
111
|
* a short override directory and set all IPC socket env vars on the target
|
|
112
112
|
* env object. No-op on non-macOS or when paths are within limits.
|
|
113
113
|
*/
|
|
114
|
-
function applyIpcSocketDirOverride(
|
|
114
|
+
function applyIpcSocketDirOverride(
|
|
115
|
+
env: Record<string, string | undefined>,
|
|
116
|
+
): void {
|
|
115
117
|
const workspaceDir =
|
|
116
118
|
env.VELLUM_WORKSPACE_DIR || join(homedir(), ".vellum", "workspace");
|
|
117
119
|
const override = computeIpcSocketDirOverride(workspaceDir);
|
|
@@ -392,7 +394,6 @@ async function startDaemonFromSource(
|
|
|
392
394
|
: {}),
|
|
393
395
|
};
|
|
394
396
|
if (resources) {
|
|
395
|
-
env.BASE_DATA_DIR = resources.instanceDir;
|
|
396
397
|
env.VELLUM_WORKSPACE_DIR = join(
|
|
397
398
|
resources.instanceDir,
|
|
398
399
|
".vellum",
|
|
@@ -403,6 +404,11 @@ async function startDaemonFromSource(
|
|
|
403
404
|
".vellum",
|
|
404
405
|
"protected",
|
|
405
406
|
);
|
|
407
|
+
env.CREDENTIAL_SECURITY_DIR = join(
|
|
408
|
+
resources.instanceDir,
|
|
409
|
+
".vellum",
|
|
410
|
+
"protected",
|
|
411
|
+
);
|
|
406
412
|
env.RUNTIME_HTTP_PORT = String(resources.daemonPort);
|
|
407
413
|
env.GATEWAY_PORT = String(resources.gatewayPort);
|
|
408
414
|
env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
|
|
@@ -413,6 +419,8 @@ async function startDaemonFromSource(
|
|
|
413
419
|
options.defaultWorkspaceConfigPath;
|
|
414
420
|
}
|
|
415
421
|
|
|
422
|
+
applyIpcSocketDirOverride(env);
|
|
423
|
+
|
|
416
424
|
// Write a sentinel PID file before spawning so concurrent hatch() calls
|
|
417
425
|
// detect the in-progress spawn and wait instead of racing.
|
|
418
426
|
writeFileSync(pidFile, "starting", "utf-8");
|
|
@@ -523,7 +531,6 @@ async function startDaemonWatchFromSource(
|
|
|
523
531
|
: {}),
|
|
524
532
|
};
|
|
525
533
|
if (resources) {
|
|
526
|
-
env.BASE_DATA_DIR = resources.instanceDir;
|
|
527
534
|
env.VELLUM_WORKSPACE_DIR = join(
|
|
528
535
|
resources.instanceDir,
|
|
529
536
|
".vellum",
|
|
@@ -534,6 +541,11 @@ async function startDaemonWatchFromSource(
|
|
|
534
541
|
".vellum",
|
|
535
542
|
"protected",
|
|
536
543
|
);
|
|
544
|
+
env.CREDENTIAL_SECURITY_DIR = join(
|
|
545
|
+
resources.instanceDir,
|
|
546
|
+
".vellum",
|
|
547
|
+
"protected",
|
|
548
|
+
);
|
|
537
549
|
env.RUNTIME_HTTP_PORT = String(resources.daemonPort);
|
|
538
550
|
env.GATEWAY_PORT = String(resources.gatewayPort);
|
|
539
551
|
env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
|
|
@@ -544,6 +556,8 @@ async function startDaemonWatchFromSource(
|
|
|
544
556
|
options.defaultWorkspaceConfigPath;
|
|
545
557
|
}
|
|
546
558
|
|
|
559
|
+
applyIpcSocketDirOverride(env);
|
|
560
|
+
|
|
547
561
|
// Write a sentinel PID file before spawning so concurrent hatch() calls
|
|
548
562
|
// detect the in-progress spawn and wait instead of racing.
|
|
549
563
|
writeFileSync(pidFile, "starting", "utf-8");
|
|
@@ -675,51 +689,18 @@ export async function discoverPublicUrl(
|
|
|
675
689
|
return `http://${cloudIp}:${effectivePort}`;
|
|
676
690
|
}
|
|
677
691
|
|
|
678
|
-
// Log the local address source only when we actually use it.
|
|
679
|
-
if (localResult.source === "hostname") {
|
|
680
|
-
console.log(` Discovered macOS local hostname: ${localResult.label}`);
|
|
681
|
-
} else if (localResult.source === "lan") {
|
|
682
|
-
console.log(` Discovered LAN IP: ${localResult.label}`);
|
|
683
|
-
}
|
|
684
|
-
|
|
685
692
|
return localResult.url;
|
|
686
693
|
}
|
|
687
694
|
|
|
688
695
|
/**
|
|
689
|
-
*
|
|
690
|
-
* address or falls back to localhost. Does not emit any logs — the caller
|
|
691
|
-
* decides whether to log based on which result is actually used.
|
|
696
|
+
* Returns the localhost URL for the gateway on the given port.
|
|
692
697
|
*/
|
|
693
698
|
function discoverLocalUrl(effectivePort: number): {
|
|
694
699
|
url: string;
|
|
695
|
-
source: "
|
|
696
|
-
label?: string;
|
|
700
|
+
source: "localhost";
|
|
697
701
|
} {
|
|
698
|
-
// On macOS, prefer the .local hostname (Bonjour/mDNS) so other devices on
|
|
699
|
-
// the same network can reach the gateway by name.
|
|
700
|
-
if (platform() === "darwin") {
|
|
701
|
-
const localHostname = getMacLocalHostname();
|
|
702
|
-
if (localHostname) {
|
|
703
|
-
return {
|
|
704
|
-
url: `http://${localHostname}:${effectivePort}`,
|
|
705
|
-
source: "hostname",
|
|
706
|
-
label: localHostname,
|
|
707
|
-
};
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
const lanIp = getLocalLanIPv4();
|
|
712
|
-
if (lanIp) {
|
|
713
|
-
return {
|
|
714
|
-
url: `http://${lanIp}:${effectivePort}`,
|
|
715
|
-
source: "lan",
|
|
716
|
-
label: lanIp,
|
|
717
|
-
};
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
// Final fallback to localhost when no LAN address could be discovered.
|
|
721
702
|
return {
|
|
722
|
-
url: `http://
|
|
703
|
+
url: `http://127.0.0.1:${effectivePort}`,
|
|
723
704
|
source: "localhost",
|
|
724
705
|
};
|
|
725
706
|
}
|
|
@@ -776,19 +757,6 @@ async function discoverCloudExternalIp(): Promise<string | undefined> {
|
|
|
776
757
|
return gcpIp ?? awsIp;
|
|
777
758
|
}
|
|
778
759
|
|
|
779
|
-
/**
|
|
780
|
-
* Returns the macOS Bonjour/mDNS `.local` hostname (e.g. "Vargass-Mac-Mini.local"),
|
|
781
|
-
* or undefined if not running on macOS or the hostname cannot be determined.
|
|
782
|
-
*/
|
|
783
|
-
export function getMacLocalHostname(): string | undefined {
|
|
784
|
-
const host = hostname();
|
|
785
|
-
if (!host) return undefined;
|
|
786
|
-
// macOS hostnames already end with .local when Bonjour is active
|
|
787
|
-
if (host.endsWith(".local")) return host;
|
|
788
|
-
// Otherwise, append .local — macOS resolves <ComputerName>.local via mDNS
|
|
789
|
-
return `${host}.local`;
|
|
790
|
-
}
|
|
791
|
-
|
|
792
760
|
/**
|
|
793
761
|
* Returns the local IPv4 address most likely to be reachable from other
|
|
794
762
|
* devices on the same LAN.
|
|
@@ -988,8 +956,8 @@ export async function startLocalDaemon(
|
|
|
988
956
|
for (const key of [
|
|
989
957
|
"ANTHROPIC_API_KEY",
|
|
990
958
|
"APP_VERSION",
|
|
991
|
-
"BASE_DATA_DIR",
|
|
992
959
|
"GATEWAY_SECURITY_DIR",
|
|
960
|
+
"CREDENTIAL_SECURITY_DIR",
|
|
993
961
|
"VELLUM_ENVIRONMENT",
|
|
994
962
|
"VELLUM_PLATFORM_URL",
|
|
995
963
|
"QDRANT_HTTP_PORT",
|
|
@@ -1015,7 +983,6 @@ export async function startLocalDaemon(
|
|
|
1015
983
|
// When running a named instance, override env so the daemon resolves
|
|
1016
984
|
// all paths under the instance directory and listens on its own port.
|
|
1017
985
|
if (resources) {
|
|
1018
|
-
daemonEnv.BASE_DATA_DIR = resources.instanceDir;
|
|
1019
986
|
daemonEnv.VELLUM_WORKSPACE_DIR = join(
|
|
1020
987
|
resources.instanceDir,
|
|
1021
988
|
".vellum",
|
|
@@ -1026,6 +993,11 @@ export async function startLocalDaemon(
|
|
|
1026
993
|
".vellum",
|
|
1027
994
|
"protected",
|
|
1028
995
|
);
|
|
996
|
+
daemonEnv.CREDENTIAL_SECURITY_DIR = join(
|
|
997
|
+
resources.instanceDir,
|
|
998
|
+
".vellum",
|
|
999
|
+
"protected",
|
|
1000
|
+
);
|
|
1029
1001
|
daemonEnv.RUNTIME_HTTP_PORT = String(resources.daemonPort);
|
|
1030
1002
|
daemonEnv.GATEWAY_PORT = String(resources.gatewayPort);
|
|
1031
1003
|
daemonEnv.QDRANT_HTTP_PORT = String(resources.qdrantPort);
|
|
@@ -1165,7 +1137,7 @@ export async function startGateway(
|
|
|
1165
1137
|
|
|
1166
1138
|
const publicUrl = await discoverPublicUrl(effectiveGatewayPort);
|
|
1167
1139
|
if (publicUrl) {
|
|
1168
|
-
console.log(`
|
|
1140
|
+
console.log(` HTTP URL: ${publicUrl}`);
|
|
1169
1141
|
}
|
|
1170
1142
|
|
|
1171
1143
|
console.log("🌐 Starting gateway...");
|
|
@@ -1179,7 +1151,6 @@ export async function startGateway(
|
|
|
1179
1151
|
GATEWAY_PORT: String(effectiveGatewayPort),
|
|
1180
1152
|
// Pass gateway operational settings via env vars so the CLI does not
|
|
1181
1153
|
// need direct access to the workspace config file.
|
|
1182
|
-
RUNTIME_PROXY_ENABLED: "true",
|
|
1183
1154
|
RUNTIME_PROXY_REQUIRE_AUTH: "true",
|
|
1184
1155
|
UNMAPPED_POLICY: "default",
|
|
1185
1156
|
DEFAULT_ASSISTANT_ID: "self",
|
|
@@ -1197,7 +1168,6 @@ export async function startGateway(
|
|
|
1197
1168
|
// assistant DB directly for guardian bootstrap.
|
|
1198
1169
|
...(resources
|
|
1199
1170
|
? {
|
|
1200
|
-
BASE_DATA_DIR: resources.instanceDir,
|
|
1201
1171
|
VELLUM_WORKSPACE_DIR: join(
|
|
1202
1172
|
resources.instanceDir,
|
|
1203
1173
|
".vellum",
|
|
@@ -1208,16 +1178,17 @@ export async function startGateway(
|
|
|
1208
1178
|
".vellum",
|
|
1209
1179
|
"protected",
|
|
1210
1180
|
),
|
|
1181
|
+
CREDENTIAL_SECURITY_DIR: join(
|
|
1182
|
+
resources.instanceDir,
|
|
1183
|
+
".vellum",
|
|
1184
|
+
"protected",
|
|
1185
|
+
),
|
|
1211
1186
|
}
|
|
1212
1187
|
: {}),
|
|
1213
1188
|
};
|
|
1214
1189
|
|
|
1215
1190
|
applyIpcSocketDirOverride(gatewayEnv);
|
|
1216
1191
|
|
|
1217
|
-
if (publicUrl) {
|
|
1218
|
-
console.log(` Ingress URL: ${publicUrl}`);
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
1192
|
let gateway;
|
|
1222
1193
|
|
|
1223
1194
|
const gatewayBinary = join(dirname(process.execPath), "vellum-gateway");
|
|
@@ -1263,8 +1234,8 @@ export async function startGateway(
|
|
|
1263
1234
|
const gatewayUrl = publicUrl || `http://localhost:${effectiveGatewayPort}`;
|
|
1264
1235
|
|
|
1265
1236
|
// Wait for the gateway to be responsive before returning. Without this,
|
|
1266
|
-
// callers
|
|
1267
|
-
//
|
|
1237
|
+
// callers may try to connect before the HTTP server is listening and get
|
|
1238
|
+
// connection-refused errors.
|
|
1268
1239
|
const start = Date.now();
|
|
1269
1240
|
const timeoutMs = 30000;
|
|
1270
1241
|
let ready = false;
|
package/src/lib/ngrok.ts
CHANGED
|
@@ -12,13 +12,19 @@ import { dirname, join } from "node:path";
|
|
|
12
12
|
|
|
13
13
|
import { GATEWAY_PORT } from "./constants";
|
|
14
14
|
|
|
15
|
-
function
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
function getDefaultWorkspaceDir(): string {
|
|
16
|
+
return (
|
|
17
|
+
process.env.VELLUM_WORKSPACE_DIR?.trim() ||
|
|
18
|
+
join(homedir(), ".vellum", "workspace")
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getConfigPath(workspaceDir: string): string {
|
|
23
|
+
return join(workspaceDir, "config.json");
|
|
18
24
|
}
|
|
19
25
|
|
|
20
|
-
function loadRawConfig(): Record<string, unknown> {
|
|
21
|
-
const configPath = getConfigPath();
|
|
26
|
+
function loadRawConfig(workspaceDir: string): Record<string, unknown> {
|
|
27
|
+
const configPath = getConfigPath(workspaceDir);
|
|
22
28
|
if (!existsSync(configPath)) return {};
|
|
23
29
|
return JSON.parse(readFileSync(configPath, "utf-8")) as Record<
|
|
24
30
|
string,
|
|
@@ -26,8 +32,11 @@ function loadRawConfig(): Record<string, unknown> {
|
|
|
26
32
|
>;
|
|
27
33
|
}
|
|
28
34
|
|
|
29
|
-
function saveRawConfig(
|
|
30
|
-
|
|
35
|
+
function saveRawConfig(
|
|
36
|
+
workspaceDir: string,
|
|
37
|
+
config: Record<string, unknown>,
|
|
38
|
+
): void {
|
|
39
|
+
const configPath = getConfigPath(workspaceDir);
|
|
31
40
|
const dir = dirname(configPath);
|
|
32
41
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
33
42
|
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
@@ -182,33 +191,33 @@ export async function waitForNgrokUrl(
|
|
|
182
191
|
/**
|
|
183
192
|
* Persist a public ingress URL to the workspace config and enable ingress.
|
|
184
193
|
*/
|
|
185
|
-
function saveIngressUrl(publicUrl: string): void {
|
|
186
|
-
const config = loadRawConfig();
|
|
194
|
+
function saveIngressUrl(workspaceDir: string, publicUrl: string): void {
|
|
195
|
+
const config = loadRawConfig(workspaceDir);
|
|
187
196
|
const ingress = (config.ingress ?? {}) as Record<string, unknown>;
|
|
188
197
|
ingress.publicBaseUrl = publicUrl;
|
|
189
198
|
ingress.enabled = true;
|
|
190
199
|
config.ingress = ingress;
|
|
191
|
-
saveRawConfig(config);
|
|
200
|
+
saveRawConfig(workspaceDir, config);
|
|
192
201
|
}
|
|
193
202
|
|
|
194
203
|
/**
|
|
195
204
|
* Clear the ingress public base URL from the workspace config.
|
|
196
205
|
*/
|
|
197
|
-
function clearIngressUrl(): void {
|
|
198
|
-
const config = loadRawConfig();
|
|
206
|
+
function clearIngressUrl(workspaceDir: string): void {
|
|
207
|
+
const config = loadRawConfig(workspaceDir);
|
|
199
208
|
const ingress = (config.ingress ?? {}) as Record<string, unknown>;
|
|
200
209
|
delete ingress.publicBaseUrl;
|
|
201
210
|
config.ingress = ingress;
|
|
202
|
-
saveRawConfig(config);
|
|
211
|
+
saveRawConfig(workspaceDir, config);
|
|
203
212
|
}
|
|
204
213
|
|
|
205
214
|
/**
|
|
206
215
|
* Check whether any webhook-based integrations (e.g. Telegram, Twilio) are
|
|
207
216
|
* configured that require a public ingress URL.
|
|
208
217
|
*/
|
|
209
|
-
function hasWebhookIntegrationsConfigured(): boolean {
|
|
218
|
+
function hasWebhookIntegrationsConfigured(workspaceDir: string): boolean {
|
|
210
219
|
try {
|
|
211
|
-
const config = loadRawConfig();
|
|
220
|
+
const config = loadRawConfig(workspaceDir);
|
|
212
221
|
const telegram = config.telegram as Record<string, unknown> | undefined;
|
|
213
222
|
if (telegram?.botUsername) return true;
|
|
214
223
|
const twilio = config.twilio as Record<string, unknown> | undefined;
|
|
@@ -223,9 +232,9 @@ function hasWebhookIntegrationsConfigured(): boolean {
|
|
|
223
232
|
* Check whether a non-ngrok ingress URL is already configured (e.g. custom
|
|
224
233
|
* domain or cloud deployment), meaning ngrok is not needed.
|
|
225
234
|
*/
|
|
226
|
-
function hasNonNgrokIngressUrl(): boolean {
|
|
235
|
+
function hasNonNgrokIngressUrl(workspaceDir: string): boolean {
|
|
227
236
|
try {
|
|
228
|
-
const config = loadRawConfig();
|
|
237
|
+
const config = loadRawConfig(workspaceDir);
|
|
229
238
|
const ingress = config.ingress as Record<string, unknown> | undefined;
|
|
230
239
|
const publicBaseUrl = ingress?.publicBaseUrl;
|
|
231
240
|
if (!publicBaseUrl || typeof publicBaseUrl !== "string") return false;
|
|
@@ -244,6 +253,7 @@ function hasNonNgrokIngressUrl(): boolean {
|
|
|
244
253
|
*/
|
|
245
254
|
export async function maybeStartNgrokTunnel(
|
|
246
255
|
targetPort: number,
|
|
256
|
+
workspaceDir: string,
|
|
247
257
|
): Promise<ChildProcess | null> {
|
|
248
258
|
// Managed/containerized deployments route webhooks through the platform's
|
|
249
259
|
// callback proxy. ngrok is not needed and would not be reachable from the
|
|
@@ -252,8 +262,8 @@ export async function maybeStartNgrokTunnel(
|
|
|
252
262
|
process.env.IS_CONTAINERIZED === "true" ||
|
|
253
263
|
process.env.IS_CONTAINERIZED === "1";
|
|
254
264
|
if (isContainerized) return null;
|
|
255
|
-
if (!hasWebhookIntegrationsConfigured()) return null;
|
|
256
|
-
if (hasNonNgrokIngressUrl()) return null;
|
|
265
|
+
if (!hasWebhookIntegrationsConfigured(workspaceDir)) return null;
|
|
266
|
+
if (hasNonNgrokIngressUrl(workspaceDir)) return null;
|
|
257
267
|
|
|
258
268
|
const version = getNgrokVersion();
|
|
259
269
|
if (!version) return null;
|
|
@@ -262,7 +272,7 @@ export async function maybeStartNgrokTunnel(
|
|
|
262
272
|
const existingUrl = await findExistingTunnel(targetPort);
|
|
263
273
|
if (existingUrl) {
|
|
264
274
|
console.log(` Found existing ngrok tunnel: ${existingUrl}`);
|
|
265
|
-
saveIngressUrl(existingUrl);
|
|
275
|
+
saveIngressUrl(workspaceDir, existingUrl);
|
|
266
276
|
return null;
|
|
267
277
|
}
|
|
268
278
|
|
|
@@ -274,14 +284,13 @@ export async function maybeStartNgrokTunnel(
|
|
|
274
284
|
// 2. If pipe handles are destroyed, SIGPIPE kills ngrok on its next write.
|
|
275
285
|
// Writing to a log file sidesteps both issues — the file descriptor is
|
|
276
286
|
// inherited by the detached ngrok process and remains valid after CLI exit.
|
|
277
|
-
const
|
|
278
|
-
const ngrokLogPath = join(root, "workspace", "data", "logs", "ngrok.log");
|
|
287
|
+
const ngrokLogPath = join(workspaceDir, "data", "logs", "ngrok.log");
|
|
279
288
|
const ngrokProcess = startNgrokProcess(targetPort, ngrokLogPath);
|
|
280
289
|
ngrokProcess.unref();
|
|
281
290
|
|
|
282
291
|
try {
|
|
283
292
|
const publicUrl = await waitForNgrokUrl();
|
|
284
|
-
saveIngressUrl(publicUrl);
|
|
293
|
+
saveIngressUrl(workspaceDir, publicUrl);
|
|
285
294
|
console.log(` Tunnel established: ${publicUrl}`);
|
|
286
295
|
|
|
287
296
|
return ngrokProcess;
|
|
@@ -317,12 +326,13 @@ export async function runNgrokTunnel(): Promise<void> {
|
|
|
317
326
|
console.log(`Using ${version}`);
|
|
318
327
|
|
|
319
328
|
const port = GATEWAY_PORT;
|
|
329
|
+
const workspaceDir = getDefaultWorkspaceDir();
|
|
320
330
|
|
|
321
331
|
// Check for an existing ngrok tunnel pointing at the gateway
|
|
322
332
|
const existingUrl = await findExistingTunnel(port);
|
|
323
333
|
if (existingUrl) {
|
|
324
334
|
console.log(`Found existing ngrok tunnel: ${existingUrl}`);
|
|
325
|
-
saveIngressUrl(existingUrl);
|
|
335
|
+
saveIngressUrl(workspaceDir, existingUrl);
|
|
326
336
|
console.log("Ingress URL saved to config.");
|
|
327
337
|
console.log("");
|
|
328
338
|
console.log(
|
|
@@ -349,7 +359,7 @@ export async function runNgrokTunnel(): Promise<void> {
|
|
|
349
359
|
}
|
|
350
360
|
if (publicUrl) {
|
|
351
361
|
console.log("\nClearing ingress URL from config...");
|
|
352
|
-
clearIngressUrl();
|
|
362
|
+
clearIngressUrl(workspaceDir);
|
|
353
363
|
}
|
|
354
364
|
};
|
|
355
365
|
|
|
@@ -398,7 +408,7 @@ export async function runNgrokTunnel(): Promise<void> {
|
|
|
398
408
|
console.log(`Tunnel established: ${publicUrl}`);
|
|
399
409
|
console.log(`Forwarding to: localhost:${port}`);
|
|
400
410
|
|
|
401
|
-
saveIngressUrl(publicUrl);
|
|
411
|
+
saveIngressUrl(workspaceDir, publicUrl);
|
|
402
412
|
console.log("Ingress URL saved to config.");
|
|
403
413
|
console.log("");
|
|
404
414
|
console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");
|