@vellumai/cli 0.11.2 → 0.11.3-staging.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/knip.json +1 -0
- package/node_modules/@vellumai/environments/package.json +2 -1
- package/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
- package/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
- package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
- package/node_modules/@vellumai/environments/src/index.ts +17 -6
- package/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
- package/node_modules/@vellumai/environments/src/seeds.ts +29 -0
- package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
- package/node_modules/@vellumai/ipc-server-utils/src/endpoint.test.ts +36 -0
- package/node_modules/@vellumai/ipc-server-utils/src/endpoint.ts +142 -0
- package/node_modules/@vellumai/ipc-server-utils/src/index.ts +18 -0
- package/node_modules/@vellumai/ipc-server-utils/src/ipc-framing.ts +295 -0
- package/node_modules/@vellumai/ipc-server-utils/src/listen-options.ts +3 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +444 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +236 -0
- package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/package.json +2 -1
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/index.ts +17 -6
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/seeds.ts +29 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/LICENSE +20 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/README.md +38 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/bin/nanoid.js +55 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.browser.js +29 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.d.ts +106 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.js +47 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/nanoid.js +1 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.d.ts +48 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.js +21 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/package.json +46 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/url-alphabet/index.js +2 -0
- package/node_modules/@vellumai/local-mode/package.json +1 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +63 -1
- package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +503 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/guardian-token.test.ts +203 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/loopback-auth.test.ts +46 -1
- package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +6 -4
- package/node_modules/@vellumai/local-mode/src/__tests__/pair.test.ts +621 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +5 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +232 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +126 -20
- package/node_modules/@vellumai/local-mode/src/environment.ts +36 -15
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +313 -25
- package/node_modules/@vellumai/local-mode/src/guardian-token.ts +222 -13
- package/node_modules/@vellumai/local-mode/src/index.ts +35 -3
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +18 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +72 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +130 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +134 -45
- package/node_modules/@vellumai/local-mode/src/pair.ts +354 -0
- package/node_modules/@vellumai/local-mode/src/paths.ts +49 -0
- package/node_modules/@vellumai/local-mode/src/status.ts +10 -8
- package/node_modules/@vellumai/local-mode/src/unpair.ts +101 -0
- package/node_modules/@vellumai/local-mode/src/util.ts +34 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +39 -0
- package/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
- package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +6 -0
- package/package.json +3 -1
- package/src/__tests__/client-token.test.ts +26 -1
- package/src/__tests__/guardian-token.test.ts +4 -0
- package/src/__tests__/nginx-ingress-command.test.ts +176 -16
- package/src/__tests__/nginx-ingress.test.ts +1821 -101
- package/src/__tests__/pair.test.ts +104 -2
- package/src/__tests__/sleep.test.ts +7 -3
- package/src/__tests__/tailscale-tunnel.test.ts +0 -1
- package/src/__tests__/tunnel.test.ts +1099 -55
- package/src/__tests__/wake.test.ts +166 -99
- package/src/commands/client.ts +182 -27
- package/src/commands/connect/import.ts +35 -156
- package/src/commands/nginx-ingress.ts +87 -110
- package/src/commands/pair.ts +84 -21
- package/src/commands/tunnel.ts +196 -85
- package/src/commands/upgrade.ts +3 -2
- package/src/commands/wake.ts +5 -163
- package/src/index.ts +1 -1
- package/src/lib/__tests__/local-ces.test.ts +10 -1
- package/src/lib/cloudflare-tunnel.ts +3 -16
- package/src/lib/environments/__tests__/paths.test.ts +20 -1
- package/src/lib/environments/paths.ts +29 -41
- package/src/lib/environments/resolve.ts +7 -5
- package/src/lib/guardian-token.ts +22 -62
- package/src/lib/ingress-config.ts +25 -0
- package/src/lib/local.ts +77 -20
- package/src/lib/nginx-ingress.ts +523 -76
- package/src/lib/ngrok.ts +154 -61
- package/src/lib/orphan-detection.test.ts +167 -0
- package/src/lib/orphan-detection.ts +40 -0
- package/src/lib/pair.test.ts +87 -0
- package/src/lib/pair.ts +55 -0
- package/src/lib/platform-client.ts +8 -5
- package/src/lib/tailscale-tunnel.ts +7 -18
- package/src/lib/tunnel-edge.ts +114 -0
- package/src/lib/xdg-log.ts +2 -2
package/src/commands/wake.ts
CHANGED
|
@@ -29,16 +29,7 @@ import {
|
|
|
29
29
|
startLocalDaemon,
|
|
30
30
|
startGateway,
|
|
31
31
|
} from "../lib/local";
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
isAssistantFeatureFlagEnabled,
|
|
35
|
-
WEB_REMOTE_INGRESS_FLAG,
|
|
36
|
-
} from "../lib/feature-flags.js";
|
|
37
|
-
import { loadRawConfig } from "../lib/ingress-config.js";
|
|
38
|
-
import {
|
|
39
|
-
isIngressRunning,
|
|
40
|
-
startRemoteWebIngress,
|
|
41
|
-
} from "../lib/nginx-ingress.js";
|
|
32
|
+
import { restoreTunnelEdgeAndAutoTunnel } from "../lib/tunnel-edge.js";
|
|
42
33
|
|
|
43
34
|
export async function wake(): Promise<void> {
|
|
44
35
|
const args = process.argv.slice(3);
|
|
@@ -377,9 +368,11 @@ export async function wake(): Promise<void> {
|
|
|
377
368
|
}
|
|
378
369
|
}
|
|
379
370
|
|
|
380
|
-
//
|
|
371
|
+
// Restore the nginx edge and point the webhook auto-tunnel at it. Non-fatal:
|
|
372
|
+
// a down edge is a degraded remote-web/webhook path, not a broken assistant.
|
|
381
373
|
const workspaceDir = join(resources.instanceDir, ".vellum", "workspace");
|
|
382
|
-
const ngrokChild = await
|
|
374
|
+
const ngrokChild = await restoreTunnelEdgeAndAutoTunnel(
|
|
375
|
+
entry.assistantId,
|
|
383
376
|
resources.gatewayPort,
|
|
384
377
|
workspaceDir,
|
|
385
378
|
);
|
|
@@ -388,17 +381,6 @@ export async function wake(): Promise<void> {
|
|
|
388
381
|
writeFileSync(ngrokPidFile, String(ngrokChild.pid));
|
|
389
382
|
}
|
|
390
383
|
|
|
391
|
-
// Restore the nginx web ingress edge when the workspace config still wants
|
|
392
|
-
// it. A TLS-terminating front (tailscale serve / tunnel) persists across
|
|
393
|
-
// restarts and keeps proxying to the edge's loopback port, but the edge has
|
|
394
|
-
// a manual lifecycle — so a routine restart otherwise leaves the self-hosted
|
|
395
|
-
// remote-web path dead (502 / blank page) until someone runs it back up.
|
|
396
|
-
await restoreWebIngressIfEnabled(
|
|
397
|
-
entry.assistantId,
|
|
398
|
-
resources.gatewayPort,
|
|
399
|
-
workspaceDir,
|
|
400
|
-
);
|
|
401
|
-
|
|
402
384
|
if (daemonMigrationsFailed) {
|
|
403
385
|
console.log(
|
|
404
386
|
"Assistant database migrations FAILED — DB-backed routes will return 503 until the assistant is restarted. Check the daemon logs.",
|
|
@@ -424,143 +406,3 @@ export async function wake(): Promise<void> {
|
|
|
424
406
|
});
|
|
425
407
|
}
|
|
426
408
|
}
|
|
427
|
-
|
|
428
|
-
/**
|
|
429
|
-
* Retry policy for the flag probe that gates the web-ingress restore. The
|
|
430
|
-
* gateway has typically been up for milliseconds at this point and answers
|
|
431
|
-
* `503 {"status":"starting"}` (or refuses connections) until its startup
|
|
432
|
-
* completes, so a single probe races it. Mutable so tests can shrink the
|
|
433
|
-
* window.
|
|
434
|
-
*/
|
|
435
|
-
export const WEB_INGRESS_FLAG_RETRY = {
|
|
436
|
-
attempts: 15,
|
|
437
|
-
intervalMs: 2_000,
|
|
438
|
-
};
|
|
439
|
-
|
|
440
|
-
/**
|
|
441
|
-
* Probe the `web-remote-ingress` flag, riding out the gateway's startup
|
|
442
|
-
* window: transient failures retry on an interval until the attempt budget is
|
|
443
|
-
* spent, then the last error propagates to the caller's warn path.
|
|
444
|
-
*/
|
|
445
|
-
async function verifyWebIngressFlagWithRetry(
|
|
446
|
-
assistantId: string,
|
|
447
|
-
gatewayPort: number,
|
|
448
|
-
): Promise<boolean> {
|
|
449
|
-
let lastError: unknown;
|
|
450
|
-
for (let attempt = 1; attempt <= WEB_INGRESS_FLAG_RETRY.attempts; attempt++) {
|
|
451
|
-
try {
|
|
452
|
-
return await isAssistantFeatureFlagEnabled(
|
|
453
|
-
assistantId,
|
|
454
|
-
WEB_REMOTE_INGRESS_FLAG,
|
|
455
|
-
{ runtimeUrl: `http://127.0.0.1:${gatewayPort}` },
|
|
456
|
-
);
|
|
457
|
-
} catch (err) {
|
|
458
|
-
lastError = err;
|
|
459
|
-
if (attempt === 1) {
|
|
460
|
-
console.log(
|
|
461
|
-
" Waiting for the gateway before restoring the web ingress edge...",
|
|
462
|
-
);
|
|
463
|
-
}
|
|
464
|
-
if (attempt < WEB_INGRESS_FLAG_RETRY.attempts) {
|
|
465
|
-
await new Promise((resolve) =>
|
|
466
|
-
setTimeout(resolve, WEB_INGRESS_FLAG_RETRY.intervalMs),
|
|
467
|
-
);
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
throw lastError;
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
/**
|
|
475
|
-
* Bring the nginx web ingress edge back up after a wake when the workspace
|
|
476
|
-
* config still wants it. Only restores when ingress is explicitly enabled with
|
|
477
|
-
* a saved public URL and the `web-remote-ingress` flag is on — the edge is
|
|
478
|
-
* pointless without the flag, so a disabled flag skips quietly with a hint.
|
|
479
|
-
*
|
|
480
|
-
* Reads the same workspace config the edge serves. Any failure to restore
|
|
481
|
-
* warns with the manual `vellum nginx-ingress up` command and never fails the
|
|
482
|
-
* wake — a down edge is a degraded remote-web path, not a broken assistant.
|
|
483
|
-
*/
|
|
484
|
-
async function restoreWebIngressIfEnabled(
|
|
485
|
-
assistantId: string,
|
|
486
|
-
gatewayPort: number,
|
|
487
|
-
workspaceDir: string,
|
|
488
|
-
): Promise<void> {
|
|
489
|
-
const config = loadRawConfig(workspaceDir);
|
|
490
|
-
const ingress = config.ingress as
|
|
491
|
-
| { enabled?: unknown; publicBaseUrl?: unknown }
|
|
492
|
-
| undefined;
|
|
493
|
-
const enabled = ingress?.enabled === true;
|
|
494
|
-
const publicBaseUrl =
|
|
495
|
-
typeof ingress?.publicBaseUrl === "string"
|
|
496
|
-
? ingress.publicBaseUrl.trim()
|
|
497
|
-
: "";
|
|
498
|
-
if (!enabled || !publicBaseUrl) {
|
|
499
|
-
return;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
// The edge already survived (or was manually brought back) — nothing to do.
|
|
503
|
-
if (isIngressRunning(workspaceDir)) {
|
|
504
|
-
return;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
let flagEnabled: boolean;
|
|
508
|
-
try {
|
|
509
|
-
flagEnabled = await verifyWebIngressFlagWithRetry(assistantId, gatewayPort);
|
|
510
|
-
} catch (err) {
|
|
511
|
-
console.warn(
|
|
512
|
-
` Could not verify the \`${WEB_REMOTE_INGRESS_FLAG}\` flag to restore the web ingress edge; leaving it down. Bring it up manually with \`vellum nginx-ingress up\`. ${
|
|
513
|
-
err instanceof Error ? err.message : String(err)
|
|
514
|
-
}`,
|
|
515
|
-
);
|
|
516
|
-
return;
|
|
517
|
-
}
|
|
518
|
-
if (!flagEnabled) {
|
|
519
|
-
console.log(
|
|
520
|
-
` Web ingress edge not restored: the \`${WEB_REMOTE_INGRESS_FLAG}\` flag is off. Enable it and run \`vellum nginx-ingress up\` to serve remote web access.`,
|
|
521
|
-
);
|
|
522
|
-
return;
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
try {
|
|
526
|
-
const result = await startRemoteWebIngress({
|
|
527
|
-
workspaceDir,
|
|
528
|
-
gatewayPort,
|
|
529
|
-
onStarting: ({ listenPort }) => {
|
|
530
|
-
console.log(
|
|
531
|
-
`Restoring web ingress edge on 127.0.0.1:${listenPort} (ingress.enabled)...`,
|
|
532
|
-
);
|
|
533
|
-
},
|
|
534
|
-
});
|
|
535
|
-
switch (result.status) {
|
|
536
|
-
case "started":
|
|
537
|
-
console.log(
|
|
538
|
-
` Web ingress edge running: http://127.0.0.1:${result.listenPort}`,
|
|
539
|
-
);
|
|
540
|
-
break;
|
|
541
|
-
case "already-running":
|
|
542
|
-
break;
|
|
543
|
-
case "nginx-missing":
|
|
544
|
-
console.warn(
|
|
545
|
-
" Could not restore the web ingress edge: nginx is not installed. Bring it up manually with `vellum nginx-ingress up`.",
|
|
546
|
-
);
|
|
547
|
-
break;
|
|
548
|
-
case "web-dist-missing":
|
|
549
|
-
console.warn(
|
|
550
|
-
" Could not restore the web ingress edge: built web assets were not found. Bring it up manually with `vellum nginx-ingress up`.",
|
|
551
|
-
);
|
|
552
|
-
break;
|
|
553
|
-
case "unreachable":
|
|
554
|
-
console.warn(
|
|
555
|
-
` Web ingress edge did not become reachable on 127.0.0.1:${result.listenPort}; check ${result.logPath}. Bring it up manually with \`vellum nginx-ingress up\`.`,
|
|
556
|
-
);
|
|
557
|
-
break;
|
|
558
|
-
}
|
|
559
|
-
} catch (err) {
|
|
560
|
-
console.warn(
|
|
561
|
-
` Failed to restore the web ingress edge: ${
|
|
562
|
-
err instanceof Error ? err.message : String(err)
|
|
563
|
-
}. Bring it up manually with \`vellum nginx-ingress up\`.`,
|
|
564
|
-
);
|
|
565
|
-
}
|
|
566
|
-
}
|
package/src/index.ts
CHANGED
|
@@ -106,7 +106,7 @@ function printHelp(): void {
|
|
|
106
106
|
console.log(" gateway Gateway management commands");
|
|
107
107
|
console.log(" hatch Create a new assistant instance");
|
|
108
108
|
console.log(
|
|
109
|
-
" nginx-ingress Manage the nginx
|
|
109
|
+
" nginx-ingress Manage the nginx edge that `vellum tunnel` fronts [beta]",
|
|
110
110
|
);
|
|
111
111
|
console.log(" logs View logs from an assistant instance");
|
|
112
112
|
console.log(" login Log in to the Vellum platform");
|
|
@@ -9,7 +9,7 @@ import { tmpdir } from "node:os";
|
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
|
|
11
11
|
|
|
12
|
-
import { startCes } from "../local.js";
|
|
12
|
+
import { resolveCesSocketPath, startCes } from "../local.js";
|
|
13
13
|
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
// Mocks
|
|
@@ -134,4 +134,13 @@ describe("startCes", () => {
|
|
|
134
134
|
const cesPidFile = join(vellumDir, "ces.pid");
|
|
135
135
|
expect(existsSync(cesPidFile)).toBe(true);
|
|
136
136
|
}, 15_000);
|
|
137
|
+
test("uses a Windows named pipe for the local CES seam", () => {
|
|
138
|
+
const resources = {
|
|
139
|
+
instanceDir: "C:\\Users\\Example\\Vellum\\assistant-123",
|
|
140
|
+
} as Parameters<typeof resolveCesSocketPath>[0];
|
|
141
|
+
|
|
142
|
+
const socketPath = resolveCesSocketPath(resources, "win32");
|
|
143
|
+
expect(socketPath.startsWith("\\\\.\\pipe\\vellum-ces-")).toBe(true);
|
|
144
|
+
expect(socketPath).not.toContain("Example");
|
|
145
|
+
});
|
|
137
146
|
});
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
getDefaultWorkspaceDir,
|
|
7
7
|
saveIngressUrl,
|
|
8
8
|
} from "./ingress-config.js";
|
|
9
|
-
import { resolveTunnelTargetPort } from "./nginx-ingress.js";
|
|
10
9
|
|
|
11
10
|
// ── Cloudflare Tunnel ─────────────────────────────────────────────────────────
|
|
12
11
|
|
|
@@ -116,7 +115,7 @@ export function waitForCloudflareTunnelUrl(
|
|
|
116
115
|
/**
|
|
117
116
|
* Run the cloudflared quick-tunnel workflow:
|
|
118
117
|
* 1. Verify cloudflared is installed.
|
|
119
|
-
* 2. Start a quick tunnel pointing at the
|
|
118
|
+
* 2. Start a quick tunnel pointing at the local edge port.
|
|
120
119
|
* 3. Parse the public URL from cloudflared output.
|
|
121
120
|
* 4. Persist the URL to the workspace config as the ingress base URL.
|
|
122
121
|
* 5. Block until the process exits or the user presses Ctrl+C.
|
|
@@ -125,12 +124,10 @@ export function waitForCloudflareTunnelUrl(
|
|
|
125
124
|
* No Cloudflare account is required — quick tunnels are free and ephemeral.
|
|
126
125
|
*/
|
|
127
126
|
export interface RunCloudflareTunnelOptions {
|
|
128
|
-
/**
|
|
127
|
+
/** Local edge port to forward. Defaults to the global GATEWAY_PORT. */
|
|
129
128
|
port?: number;
|
|
130
129
|
/** Workspace directory for config read/write. Defaults to ~/.vellum/workspace. */
|
|
131
130
|
workspaceDir?: string;
|
|
132
|
-
/** Prefer nginx ingress over the gateway port when it is running. */
|
|
133
|
-
preferNginxIngress?: boolean;
|
|
134
131
|
/** Lockfile entry to mirror the ingress URL onto (`ingressUrl`). */
|
|
135
132
|
assistantId?: string;
|
|
136
133
|
}
|
|
@@ -156,17 +153,7 @@ export async function runCloudflareTunnel(
|
|
|
156
153
|
console.log(`Using ${version}`);
|
|
157
154
|
|
|
158
155
|
const workspaceDir = opts.workspaceDir ?? getDefaultWorkspaceDir();
|
|
159
|
-
const
|
|
160
|
-
const { port, viaIngress } = resolveTunnelTargetPort(
|
|
161
|
-
workspaceDir,
|
|
162
|
-
gatewayPort,
|
|
163
|
-
{ preferNginxIngress: opts.preferNginxIngress === true },
|
|
164
|
-
);
|
|
165
|
-
if (viaIngress) {
|
|
166
|
-
console.log(
|
|
167
|
-
`nginx ingress detected — tunneling to it on 127.0.0.1:${port}.`,
|
|
168
|
-
);
|
|
169
|
-
}
|
|
156
|
+
const port = opts.port ?? GATEWAY_PORT;
|
|
170
157
|
|
|
171
158
|
console.log(`Starting cloudflared quick tunnel to localhost:${port}...`);
|
|
172
159
|
console.log("No Cloudflare account required — quick tunnels are free.");
|
|
@@ -37,6 +37,7 @@ afterAll(() => {
|
|
|
37
37
|
// mock.module() calls above.
|
|
38
38
|
const {
|
|
39
39
|
getConfigDir,
|
|
40
|
+
getConfigDirs,
|
|
40
41
|
getDefaultPorts,
|
|
41
42
|
getLockfilePath,
|
|
42
43
|
getLockfilePaths,
|
|
@@ -57,7 +58,7 @@ const dev: EnvironmentDefinition = {
|
|
|
57
58
|
webUrl: "https://dev-assistant.vellum.ai",
|
|
58
59
|
};
|
|
59
60
|
|
|
60
|
-
const XDG_ENV_VARS = ["XDG_DATA_HOME", "XDG_CONFIG_HOME"] as const;
|
|
61
|
+
const XDG_ENV_VARS = ["XDG_DATA_HOME", "XDG_CONFIG_HOME", "APPDATA"] as const;
|
|
61
62
|
|
|
62
63
|
describe("path helpers", () => {
|
|
63
64
|
let savedEnv: Record<string, string | undefined>;
|
|
@@ -81,6 +82,24 @@ describe("path helpers", () => {
|
|
|
81
82
|
});
|
|
82
83
|
|
|
83
84
|
describe("getConfigDir", () => {
|
|
85
|
+
test("Windows keeps XDG config as a legacy read location", () => {
|
|
86
|
+
const descriptor = Object.getOwnPropertyDescriptor(process, "platform")!;
|
|
87
|
+
try {
|
|
88
|
+
Object.defineProperty(process, "platform", {
|
|
89
|
+
...descriptor,
|
|
90
|
+
value: "win32",
|
|
91
|
+
});
|
|
92
|
+
process.env.APPDATA = "C:\\AppData";
|
|
93
|
+
process.env.XDG_CONFIG_HOME = "D:\\Legacy";
|
|
94
|
+
expect(getConfigDirs(dev)).toEqual([
|
|
95
|
+
"C:\\AppData\\vellum-dev",
|
|
96
|
+
"D:\\Legacy\\vellum-dev",
|
|
97
|
+
]);
|
|
98
|
+
} finally {
|
|
99
|
+
Object.defineProperty(process, "platform", descriptor);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
84
103
|
test("production returns ~/.config/vellum/", () => {
|
|
85
104
|
expect(getConfigDir(prod)).toBe(join(TEST_HOME, ".config", "vellum"));
|
|
86
105
|
});
|
|
@@ -2,18 +2,14 @@ import { homedir } from "os";
|
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
|
|
4
4
|
import type { EnvironmentDefinition, PortMap } from "@vellumai/environments";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const PRODUCTION_LOCKFILE_NAMES = [
|
|
14
|
-
".vellum.lock.json",
|
|
15
|
-
".vellum.lockfile.json",
|
|
16
|
-
] as const;
|
|
5
|
+
import {
|
|
6
|
+
resolveAssistantsDir,
|
|
7
|
+
resolveConfigDirPaths,
|
|
8
|
+
resolveLockfilePaths,
|
|
9
|
+
resolveLogDir,
|
|
10
|
+
resolveRuntimeDir,
|
|
11
|
+
type LocalPathOptions,
|
|
12
|
+
} from "@vellumai/local-mode";
|
|
17
13
|
|
|
18
14
|
const DEFAULT_PORTS: Readonly<PortMap> = {
|
|
19
15
|
daemon: 7821,
|
|
@@ -30,11 +26,11 @@ const DEFAULT_PORTS: Readonly<PortMap> = {
|
|
|
30
26
|
* non-production environments use `$XDG_CONFIG_HOME/vellum-<env>/`.
|
|
31
27
|
*/
|
|
32
28
|
export function getConfigDir(env: EnvironmentDefinition): string {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
return
|
|
29
|
+
return getConfigDirs(env)[0]!;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getConfigDirs(env: EnvironmentDefinition): string[] {
|
|
33
|
+
return resolveConfigDirPaths(process.env, localPathOptions(env));
|
|
38
34
|
}
|
|
39
35
|
|
|
40
36
|
/**
|
|
@@ -56,12 +52,7 @@ export function getConfigDir(env: EnvironmentDefinition): string {
|
|
|
56
52
|
* both production and non-production environments.
|
|
57
53
|
*/
|
|
58
54
|
export function getLockfilePaths(env: EnvironmentDefinition): string[] {
|
|
59
|
-
|
|
60
|
-
const dir = env.lockfileDirOverride ?? homedir();
|
|
61
|
-
return PRODUCTION_LOCKFILE_NAMES.map((name) => join(dir, name));
|
|
62
|
-
}
|
|
63
|
-
const dir = env.lockfileDirOverride ?? getConfigDir(env);
|
|
64
|
-
return [join(dir, "lockfile.json")];
|
|
55
|
+
return resolveLockfilePaths(process.env, localPathOptions(env));
|
|
65
56
|
}
|
|
66
57
|
|
|
67
58
|
/**
|
|
@@ -79,10 +70,7 @@ export function getLockfilePath(env: EnvironmentDefinition): string {
|
|
|
79
70
|
* `~/.local/share/vellum-<env>/assistants/`.
|
|
80
71
|
*/
|
|
81
72
|
export function getMultiInstanceDir(env: EnvironmentDefinition): string {
|
|
82
|
-
|
|
83
|
-
return join(xdgDataHome(), "vellum", "assistants");
|
|
84
|
-
}
|
|
85
|
-
return join(xdgDataHome(), `vellum-${env.name}`, "assistants");
|
|
73
|
+
return resolveAssistantsDir(process.env, localPathOptions(env));
|
|
86
74
|
}
|
|
87
75
|
|
|
88
76
|
/**
|
|
@@ -104,10 +92,11 @@ export function getDefaultPorts(env: EnvironmentDefinition): PortMap {
|
|
|
104
92
|
* use `~/.local/share/vellum-<env>/`.
|
|
105
93
|
*/
|
|
106
94
|
export function getStateDir(env: EnvironmentDefinition): string {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
95
|
+
return resolveRuntimeDir(process.env, localPathOptions(env));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function getLogDir(env: EnvironmentDefinition): string {
|
|
99
|
+
return resolveLogDir(process.env, localPathOptions(env));
|
|
111
100
|
}
|
|
112
101
|
|
|
113
102
|
/**
|
|
@@ -133,18 +122,17 @@ export function getInputHistoryPath(): string {
|
|
|
133
122
|
export const ASSISTANT_INTERNAL_PORT = DEFAULT_PORTS.daemon;
|
|
134
123
|
export const GATEWAY_INTERNAL_PORT = DEFAULT_PORTS.gateway;
|
|
135
124
|
|
|
136
|
-
function xdgDataHome(): string {
|
|
137
|
-
return (
|
|
138
|
-
process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share")
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function xdgConfigHome(): string {
|
|
143
|
-
return process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
|
|
144
|
-
}
|
|
145
|
-
|
|
146
125
|
function xdgStateHome(): string {
|
|
147
126
|
return (
|
|
148
127
|
process.env.XDG_STATE_HOME?.trim() || join(homedir(), ".local", "state")
|
|
149
128
|
);
|
|
150
129
|
}
|
|
130
|
+
|
|
131
|
+
function localPathOptions(env: EnvironmentDefinition): LocalPathOptions {
|
|
132
|
+
return {
|
|
133
|
+
homeDir: homedir(),
|
|
134
|
+
environmentName: env.name,
|
|
135
|
+
configDirOverride: env.configDirOverride,
|
|
136
|
+
lockfileDirOverride: env.lockfileDirOverride,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -4,6 +4,7 @@ import { dirname } from "path";
|
|
|
4
4
|
import { SEEDS, type EnvironmentDefinition } from "@vellumai/environments";
|
|
5
5
|
import {
|
|
6
6
|
defaultEnvironmentFilePath,
|
|
7
|
+
defaultEnvironmentFilePaths,
|
|
7
8
|
readDefaultEnvironment as readPersistedDefaultEnvironment,
|
|
8
9
|
} from "@vellumai/local-mode";
|
|
9
10
|
|
|
@@ -30,11 +31,12 @@ export function writeDefaultEnvironment(name: string): void {
|
|
|
30
31
|
* Remove the persisted default environment file, falling back to production.
|
|
31
32
|
*/
|
|
32
33
|
export function clearDefaultEnvironment(): void {
|
|
33
|
-
const filePath
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
for (const filePath of defaultEnvironmentFilePaths(process.env)) {
|
|
35
|
+
try {
|
|
36
|
+
unlinkSync(filePath);
|
|
37
|
+
} catch {
|
|
38
|
+
// Already absent.
|
|
39
|
+
}
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
|
|
@@ -17,27 +17,21 @@ import { platform } from "os";
|
|
|
17
17
|
import { dirname, join } from "path";
|
|
18
18
|
|
|
19
19
|
import { SEEDS } from "@vellumai/environments";
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
import {
|
|
21
|
+
guardianTokenPath,
|
|
22
|
+
isConfidentialRefreshUrl,
|
|
23
|
+
resolveConfigDir,
|
|
24
|
+
saveGuardianToken as writeGuardianToken,
|
|
25
|
+
type GuardianTokenData,
|
|
26
|
+
} from "@vellumai/local-mode";
|
|
27
|
+
|
|
28
|
+
import { getConfigDir, getConfigDirs } from "./environments/paths.js";
|
|
23
29
|
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
24
30
|
import { loopbackSafeFetch } from "./loopback-fetch.js";
|
|
25
31
|
|
|
26
32
|
const DEVICE_ID_SALT = "vellum-assistant-host-id";
|
|
27
33
|
|
|
28
|
-
export
|
|
29
|
-
guardianPrincipalId: string;
|
|
30
|
-
accessToken: string;
|
|
31
|
-
/** ISO date string or epoch-ms number as returned by the gateway. */
|
|
32
|
-
accessTokenExpiresAt: string | number;
|
|
33
|
-
refreshToken: string;
|
|
34
|
-
/** ISO date string or epoch-ms number as returned by the gateway. */
|
|
35
|
-
refreshTokenExpiresAt: string | number;
|
|
36
|
-
refreshAfter: string;
|
|
37
|
-
isNew: boolean;
|
|
38
|
-
deviceId: string;
|
|
39
|
-
leasedAt: string;
|
|
40
|
-
}
|
|
34
|
+
export type { GuardianTokenData };
|
|
41
35
|
|
|
42
36
|
function getGuardianTokenPath(assistantId: string): string {
|
|
43
37
|
// Resolve via the shared @vellumai/local-mode resolver — the same one every
|
|
@@ -165,28 +159,24 @@ export function computeDeviceId(): string {
|
|
|
165
159
|
export function loadGuardianToken(
|
|
166
160
|
assistantId: string,
|
|
167
161
|
): GuardianTokenData | null {
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
162
|
+
for (const dir of getConfigDirs(getCurrentEnvironment())) {
|
|
163
|
+
try {
|
|
164
|
+
const raw = readFileSync(guardianTokenPath(dir, assistantId), "utf-8");
|
|
165
|
+
return JSON.parse(raw) as GuardianTokenData;
|
|
166
|
+
} catch {
|
|
167
|
+
// Try the next compatible location.
|
|
168
|
+
}
|
|
174
169
|
}
|
|
170
|
+
return null;
|
|
175
171
|
}
|
|
176
172
|
|
|
177
173
|
export function saveGuardianToken(
|
|
178
174
|
assistantId: string,
|
|
179
175
|
data: GuardianTokenData,
|
|
180
176
|
): void {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
185
|
-
}
|
|
186
|
-
writeFileSync(tokenPath, JSON.stringify(data, null, 2) + "\n", {
|
|
187
|
-
mode: 0o600,
|
|
188
|
-
});
|
|
189
|
-
chmodSync(tokenPath, 0o600);
|
|
177
|
+
// Delegates to the shared @vellumai/local-mode writer (0700 dir, 0600 file)
|
|
178
|
+
// with the same env-resolved config dir the path resolver above uses.
|
|
179
|
+
writeGuardianToken(resolveConfigDir(process.env), assistantId, data);
|
|
190
180
|
}
|
|
191
181
|
|
|
192
182
|
/** Abort the refresh POST if the gateway is slow/unreachable (it's now on the
|
|
@@ -256,37 +246,6 @@ function releaseRefreshLock(lockPath: string): void {
|
|
|
256
246
|
* process already rotated it while we waited, we return that fresh token
|
|
257
247
|
* instead of replaying our now-stale refresh token.
|
|
258
248
|
*/
|
|
259
|
-
/**
|
|
260
|
-
* The guardian refresh token is long-lived and replayable, so we only transmit
|
|
261
|
-
* it over a confidential channel: HTTPS, or a loopback host (local dev, or a
|
|
262
|
-
* same-host reverse proxy / tunnel agent). Refreshing against a non-loopback
|
|
263
|
-
* plaintext `http://` URL is refused — an on-path attacker could otherwise
|
|
264
|
-
* capture the refresh token and rotate it into fresh credentials.
|
|
265
|
-
*
|
|
266
|
-
* A user-chosen malicious `https://` destination is intentionally out of scope:
|
|
267
|
-
* HTTPS protects the channel, and the access token already goes wherever the
|
|
268
|
-
* configured URL points. This guard targets the plaintext-interception vector.
|
|
269
|
-
*/
|
|
270
|
-
function isLoopbackHostname(hostname: string): boolean {
|
|
271
|
-
const h = hostname.toLowerCase();
|
|
272
|
-
return (
|
|
273
|
-
h === "localhost" ||
|
|
274
|
-
h === "::1" ||
|
|
275
|
-
h === "[::1]" ||
|
|
276
|
-
h === "0:0:0:0:0:0:0:1" ||
|
|
277
|
-
/^127(?:\.\d{1,3}){3}$/.test(h)
|
|
278
|
-
);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function isConfidentialRefreshUrl(gatewayUrl: string): boolean {
|
|
282
|
-
try {
|
|
283
|
-
const url = new URL(gatewayUrl);
|
|
284
|
-
return url.protocol === "https:" || isLoopbackHostname(url.hostname);
|
|
285
|
-
} catch {
|
|
286
|
-
return false;
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
249
|
/**
|
|
291
250
|
* True when a stored guardian token has reached its renewal point — now is
|
|
292
251
|
* at/after `refreshAfter` (preferred) or `accessTokenExpiresAt`. Used to gate
|
|
@@ -380,6 +339,7 @@ export async function refreshGuardianToken(
|
|
|
380
339
|
isNew: false,
|
|
381
340
|
deviceId: tokenData.deviceId,
|
|
382
341
|
leasedAt: new Date().toISOString(),
|
|
342
|
+
pairedGatewayUrl: tokenData.pairedGatewayUrl,
|
|
383
343
|
};
|
|
384
344
|
saveGuardianToken(assistantId, refreshed);
|
|
385
345
|
return refreshed;
|
|
@@ -85,6 +85,31 @@ export function saveIngressUrl(
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/** Persist a reserved ngrok domain under `ingress.ngrok.domain`; null clears it. */
|
|
89
|
+
export function saveNgrokDomain(
|
|
90
|
+
workspaceDir: string,
|
|
91
|
+
domain: string | null,
|
|
92
|
+
): void {
|
|
93
|
+
const config = loadRawConfig(workspaceDir);
|
|
94
|
+
const ingress = (config.ingress ?? {}) as Record<string, unknown>;
|
|
95
|
+
if (domain) {
|
|
96
|
+
ingress.ngrok = { domain };
|
|
97
|
+
} else {
|
|
98
|
+
delete ingress.ngrok;
|
|
99
|
+
}
|
|
100
|
+
config.ingress = ingress;
|
|
101
|
+
saveRawConfig(workspaceDir, config);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Read the reserved ngrok domain from the workspace config, if saved. */
|
|
105
|
+
export function loadNgrokDomain(workspaceDir: string): string | null {
|
|
106
|
+
const config = loadRawConfig(workspaceDir);
|
|
107
|
+
const ingress = config.ingress as Record<string, unknown> | undefined;
|
|
108
|
+
const ngrok = ingress?.ngrok as Record<string, unknown> | undefined;
|
|
109
|
+
const domain = ngrok?.domain;
|
|
110
|
+
return typeof domain === "string" && domain.trim() ? domain : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
88
113
|
/** Clear the ingress public base URL from the workspace config. */
|
|
89
114
|
export function clearIngressUrl(
|
|
90
115
|
workspaceDir: string,
|