@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
|
@@ -161,25 +161,25 @@ mock.module("../lib/ingress-config.js", () => ({
|
|
|
161
161
|
loadRawConfig: loadRawConfigMock,
|
|
162
162
|
}));
|
|
163
163
|
|
|
164
|
+
const ensureTunnelEdgeMock = mock<typeof nginxIngress.ensureTunnelEdge>(
|
|
165
|
+
async () => ({ port: 7840, started: true, includesWebApp: true }),
|
|
166
|
+
);
|
|
164
167
|
const isIngressRunningMock = mock<typeof nginxIngress.isIngressRunning>(
|
|
165
168
|
() => false,
|
|
166
169
|
);
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
status: "started",
|
|
171
|
-
listenPort: 7840,
|
|
172
|
-
webDistDir: "/tmp/web/dist",
|
|
173
|
-
version: "nginx/1.25.3",
|
|
174
|
-
}));
|
|
170
|
+
const readIngressStateMock = mock<typeof nginxIngress.readIngressState>(
|
|
171
|
+
() => null,
|
|
172
|
+
);
|
|
175
173
|
|
|
176
174
|
mock.module("../lib/nginx-ingress.js", () => ({
|
|
177
175
|
...realNginxIngress,
|
|
176
|
+
ensureTunnelEdge: ensureTunnelEdgeMock,
|
|
178
177
|
isIngressRunning: isIngressRunningMock,
|
|
179
|
-
|
|
178
|
+
readIngressState: readIngressStateMock,
|
|
180
179
|
}));
|
|
181
180
|
|
|
182
|
-
const { wake
|
|
181
|
+
const { wake } = await import("../commands/wake.js");
|
|
182
|
+
const { WEB_INGRESS_FLAG_RETRY } = await import("../lib/tunnel-edge.js");
|
|
183
183
|
|
|
184
184
|
let tempDir: string;
|
|
185
185
|
let originalArgv: string[];
|
|
@@ -260,15 +260,16 @@ beforeEach(() => {
|
|
|
260
260
|
isAssistantFeatureFlagEnabledMock.mockResolvedValue(true);
|
|
261
261
|
loadRawConfigMock.mockReset();
|
|
262
262
|
loadRawConfigMock.mockReturnValue({});
|
|
263
|
+
ensureTunnelEdgeMock.mockReset();
|
|
264
|
+
ensureTunnelEdgeMock.mockResolvedValue({
|
|
265
|
+
port: 7840,
|
|
266
|
+
started: true,
|
|
267
|
+
includesWebApp: true,
|
|
268
|
+
});
|
|
263
269
|
isIngressRunningMock.mockReset();
|
|
264
270
|
isIngressRunningMock.mockReturnValue(false);
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
status: "started",
|
|
268
|
-
listenPort: 7840,
|
|
269
|
-
webDistDir: "/tmp/web/dist",
|
|
270
|
-
version: "nginx/1.25.3",
|
|
271
|
-
});
|
|
271
|
+
readIngressStateMock.mockReset();
|
|
272
|
+
readIngressStateMock.mockReturnValue(null);
|
|
272
273
|
});
|
|
273
274
|
|
|
274
275
|
afterEach(() => {
|
|
@@ -403,157 +404,223 @@ describe("vellum wake", () => {
|
|
|
403
404
|
});
|
|
404
405
|
});
|
|
405
406
|
|
|
406
|
-
|
|
407
|
+
|
|
408
|
+
describe("vellum wake — tunnel edge restore", () => {
|
|
409
|
+
const webhookConfig = { telegram: { botUsername: "bot" } };
|
|
407
410
|
const enabledConfig = {
|
|
408
411
|
ingress: { enabled: true, publicBaseUrl: "https://assistant.example.com" },
|
|
409
412
|
};
|
|
410
|
-
|
|
411
|
-
const originalRetry = { ...WEB_INGRESS_FLAG_RETRY };
|
|
413
|
+
const workspaceDirOf = (dir: string) => join(dir, ".vellum", "workspace");
|
|
412
414
|
|
|
413
415
|
beforeEach(() => {
|
|
414
416
|
process.argv = ["bun", "vellum", "wake", "local-assistant"];
|
|
415
|
-
// Shrink the startup-window retry so failure-path tests stay fast.
|
|
416
|
-
WEB_INGRESS_FLAG_RETRY.attempts = 3;
|
|
417
|
-
WEB_INGRESS_FLAG_RETRY.intervalMs = 5;
|
|
418
417
|
});
|
|
419
418
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
419
|
+
test("webhook-configured wake ensures the edge with the startup flag retry and tunnels the edge port", async () => {
|
|
420
|
+
loadRawConfigMock.mockReturnValue(webhookConfig);
|
|
421
|
+
|
|
422
|
+
await wake();
|
|
423
|
+
|
|
424
|
+
expect(ensureTunnelEdgeMock).toHaveBeenCalledWith({
|
|
425
|
+
assistantId: "local-assistant",
|
|
426
|
+
workspaceDir: workspaceDirOf(tempDir),
|
|
427
|
+
gatewayPort: 7830,
|
|
428
|
+
flagRetry: WEB_INGRESS_FLAG_RETRY,
|
|
429
|
+
});
|
|
430
|
+
expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
|
|
431
|
+
7840,
|
|
432
|
+
workspaceDirOf(tempDir),
|
|
433
|
+
);
|
|
434
|
+
expect(logSpy).toHaveBeenCalledWith("Wake complete.");
|
|
423
435
|
});
|
|
424
436
|
|
|
425
|
-
test("
|
|
437
|
+
test("ingress-enabled wake ensures the edge even without webhook integrations", async () => {
|
|
426
438
|
loadRawConfigMock.mockReturnValue(enabledConfig);
|
|
427
439
|
|
|
428
440
|
await wake();
|
|
429
441
|
|
|
430
|
-
expect(
|
|
431
|
-
"local-assistant",
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
}),
|
|
442
|
+
expect(ensureTunnelEdgeMock).toHaveBeenCalledWith({
|
|
443
|
+
assistantId: "local-assistant",
|
|
444
|
+
workspaceDir: workspaceDirOf(tempDir),
|
|
445
|
+
gatewayPort: 7830,
|
|
446
|
+
flagRetry: WEB_INGRESS_FLAG_RETRY,
|
|
447
|
+
});
|
|
448
|
+
expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
|
|
449
|
+
7840,
|
|
450
|
+
workspaceDirOf(tempDir),
|
|
440
451
|
);
|
|
441
452
|
expect(logSpy).toHaveBeenCalledWith("Wake complete.");
|
|
442
453
|
});
|
|
443
454
|
|
|
444
|
-
test("
|
|
445
|
-
loadRawConfigMock.mockReturnValue({ ingress: { enabled: false } });
|
|
446
|
-
|
|
455
|
+
test("skips the edge and tunnels the gateway port when nothing wants an edge", async () => {
|
|
447
456
|
await wake();
|
|
448
457
|
|
|
449
458
|
expect(isAssistantFeatureFlagEnabledMock).not.toHaveBeenCalled();
|
|
450
|
-
expect(
|
|
459
|
+
expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
|
|
460
|
+
expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
|
|
461
|
+
7830,
|
|
462
|
+
workspaceDirOf(tempDir),
|
|
463
|
+
);
|
|
451
464
|
});
|
|
452
465
|
|
|
453
|
-
test("
|
|
454
|
-
loadRawConfigMock.mockReturnValue({});
|
|
466
|
+
test("skips the edge when ingress is disabled and no webhooks are configured", async () => {
|
|
467
|
+
loadRawConfigMock.mockReturnValue({ ingress: { enabled: false } });
|
|
455
468
|
|
|
456
469
|
await wake();
|
|
457
470
|
|
|
458
|
-
expect(
|
|
459
|
-
expect(
|
|
471
|
+
expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
|
|
472
|
+
expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
|
|
473
|
+
7830,
|
|
474
|
+
workspaceDirOf(tempDir),
|
|
475
|
+
);
|
|
460
476
|
});
|
|
461
477
|
|
|
462
|
-
test("
|
|
478
|
+
test("skips the edge when ingress is enabled but the public URL is missing", async () => {
|
|
463
479
|
loadRawConfigMock.mockReturnValue({ ingress: { enabled: true } });
|
|
464
480
|
|
|
465
481
|
await wake();
|
|
466
482
|
|
|
467
|
-
expect(
|
|
468
|
-
expect(startRemoteWebIngressMock).not.toHaveBeenCalled();
|
|
483
|
+
expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
|
|
469
484
|
});
|
|
470
485
|
|
|
471
|
-
test("
|
|
472
|
-
loadRawConfigMock.mockReturnValue(
|
|
486
|
+
test("a running edge recorded against this gateway port is reused with zero flag calls", async () => {
|
|
487
|
+
loadRawConfigMock.mockReturnValue(webhookConfig);
|
|
473
488
|
isIngressRunningMock.mockReturnValue(true);
|
|
489
|
+
readIngressStateMock.mockReturnValue({
|
|
490
|
+
listenPort: 7845,
|
|
491
|
+
includeWebApp: true,
|
|
492
|
+
gatewayPort: 7830,
|
|
493
|
+
});
|
|
474
494
|
|
|
475
495
|
await wake();
|
|
476
496
|
|
|
477
|
-
// Already up — skip the flag probe and the start entirely.
|
|
478
497
|
expect(isAssistantFeatureFlagEnabledMock).not.toHaveBeenCalled();
|
|
479
|
-
expect(
|
|
498
|
+
expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
|
|
499
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
500
|
+
" Tunnel edge already running on 127.0.0.1:7845 (remote web + webhooks).",
|
|
501
|
+
);
|
|
502
|
+
expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
|
|
503
|
+
7845,
|
|
504
|
+
workspaceDirOf(tempDir),
|
|
505
|
+
);
|
|
480
506
|
});
|
|
481
507
|
|
|
482
|
-
test("
|
|
483
|
-
loadRawConfigMock.mockReturnValue(
|
|
484
|
-
|
|
508
|
+
test("a running edge recorded against a different gateway port goes through ensureTunnelEdge", async () => {
|
|
509
|
+
loadRawConfigMock.mockReturnValue(webhookConfig);
|
|
510
|
+
isIngressRunningMock.mockReturnValue(true);
|
|
511
|
+
readIngressStateMock.mockReturnValue({
|
|
512
|
+
listenPort: 7845,
|
|
513
|
+
includeWebApp: true,
|
|
514
|
+
gatewayPort: 7900,
|
|
515
|
+
});
|
|
485
516
|
|
|
486
517
|
await wake();
|
|
487
518
|
|
|
488
|
-
expect(
|
|
489
|
-
|
|
490
|
-
|
|
519
|
+
expect(ensureTunnelEdgeMock).toHaveBeenCalledWith(
|
|
520
|
+
expect.objectContaining({ gatewayPort: 7830 }),
|
|
521
|
+
);
|
|
522
|
+
expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
|
|
523
|
+
7840,
|
|
524
|
+
workspaceDirOf(tempDir),
|
|
491
525
|
);
|
|
492
|
-
expect(logSpy).toHaveBeenCalledWith("Wake complete.");
|
|
493
526
|
});
|
|
494
527
|
|
|
495
|
-
test("a
|
|
496
|
-
|
|
497
|
-
|
|
528
|
+
test("a running edge without a recorded gateway port goes through ensureTunnelEdge", async () => {
|
|
529
|
+
// An unverified upstream must not be reused blindly; ensureTunnelEdge
|
|
530
|
+
// restarts it so the running config provably targets the requested port.
|
|
531
|
+
loadRawConfigMock.mockReturnValue(webhookConfig);
|
|
532
|
+
isIngressRunningMock.mockReturnValue(true);
|
|
533
|
+
readIngressStateMock.mockReturnValue({
|
|
534
|
+
listenPort: 7845,
|
|
535
|
+
includeWebApp: true,
|
|
536
|
+
});
|
|
498
537
|
|
|
499
538
|
await wake();
|
|
500
539
|
|
|
501
|
-
expect(
|
|
502
|
-
|
|
540
|
+
expect(ensureTunnelEdgeMock).toHaveBeenCalled();
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("nginx-missing falls back to the gateway-port tunnel with a warning", async () => {
|
|
544
|
+
loadRawConfigMock.mockReturnValue(webhookConfig);
|
|
545
|
+
ensureTunnelEdgeMock.mockRejectedValue(
|
|
546
|
+
new Error(
|
|
547
|
+
"nginx is not installed, so the tunnel edge cannot start. Install it (macOS: `brew install nginx`, Linux: `sudo apt install nginx`) or point NGINX_BIN at an existing binary.",
|
|
548
|
+
),
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
await wake();
|
|
552
|
+
|
|
553
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
554
|
+
expect.stringContaining("brew install nginx"),
|
|
555
|
+
);
|
|
556
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
557
|
+
expect.stringContaining("vellum nginx-ingress up"),
|
|
558
|
+
);
|
|
559
|
+
expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
|
|
560
|
+
7830,
|
|
561
|
+
workspaceDirOf(tempDir),
|
|
562
|
+
);
|
|
503
563
|
expect(logSpy).toHaveBeenCalledWith("Wake complete.");
|
|
504
564
|
});
|
|
505
565
|
|
|
506
|
-
test("
|
|
507
|
-
loadRawConfigMock.mockReturnValue(
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
566
|
+
test("flag-off + webhooks-on produces a webhooks-only edge fronting the tunnel", async () => {
|
|
567
|
+
loadRawConfigMock.mockReturnValue(webhookConfig);
|
|
568
|
+
ensureTunnelEdgeMock.mockResolvedValue({
|
|
569
|
+
port: 7840,
|
|
570
|
+
started: true,
|
|
571
|
+
includesWebApp: false,
|
|
512
572
|
});
|
|
513
573
|
|
|
514
574
|
await wake();
|
|
515
575
|
|
|
516
|
-
expect(
|
|
517
|
-
|
|
576
|
+
expect(ensureTunnelEdgeMock).toHaveBeenCalledWith(
|
|
577
|
+
expect.objectContaining({ assistantId: "local-assistant" }),
|
|
578
|
+
);
|
|
579
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
580
|
+
expect.stringContaining("webhooks only"),
|
|
581
|
+
);
|
|
582
|
+
expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
|
|
583
|
+
7840,
|
|
584
|
+
workspaceDirOf(tempDir),
|
|
585
|
+
);
|
|
518
586
|
});
|
|
519
587
|
|
|
520
|
-
test("a
|
|
588
|
+
test("a reused edge still points the tunnel at its listen port", async () => {
|
|
521
589
|
loadRawConfigMock.mockReturnValue(enabledConfig);
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
590
|
+
ensureTunnelEdgeMock.mockResolvedValue({
|
|
591
|
+
port: 7841,
|
|
592
|
+
started: false,
|
|
593
|
+
includesWebApp: true,
|
|
594
|
+
});
|
|
525
595
|
|
|
526
596
|
await wake();
|
|
527
597
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
expect(logSpy).toHaveBeenCalledWith("Wake complete.");
|
|
598
|
+
expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
|
|
599
|
+
7841,
|
|
600
|
+
workspaceDirOf(tempDir),
|
|
601
|
+
);
|
|
533
602
|
});
|
|
534
603
|
|
|
535
|
-
test("
|
|
536
|
-
loadRawConfigMock.mockReturnValue(
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
)
|
|
543
|
-
.mockRejectedValueOnce(
|
|
544
|
-
new Error(
|
|
545
|
-
'Failed to fetch feature flags: HTTP 503 {"status":"starting"}',
|
|
546
|
-
),
|
|
547
|
-
)
|
|
548
|
-
.mockResolvedValueOnce(true);
|
|
604
|
+
test("an exhausted flag lookup warns and falls back to the gateway-port tunnel", async () => {
|
|
605
|
+
loadRawConfigMock.mockReturnValue(webhookConfig);
|
|
606
|
+
ensureTunnelEdgeMock.mockRejectedValue(
|
|
607
|
+
new Error(
|
|
608
|
+
"Could not verify the `web-remote-ingress` feature flag before starting the edge. Is the assistant running? Try `vellum wake` and retry. gateway unreachable",
|
|
609
|
+
),
|
|
610
|
+
);
|
|
549
611
|
|
|
550
612
|
await wake();
|
|
551
613
|
|
|
552
|
-
expect(
|
|
553
|
-
|
|
554
|
-
|
|
614
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
615
|
+
expect.stringContaining("web-remote-ingress"),
|
|
616
|
+
);
|
|
617
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
618
|
+
expect.stringContaining("vellum nginx-ingress up"),
|
|
619
|
+
);
|
|
620
|
+
expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
|
|
621
|
+
7830,
|
|
622
|
+
workspaceDirOf(tempDir),
|
|
555
623
|
);
|
|
556
|
-
expect(startRemoteWebIngressMock).toHaveBeenCalled();
|
|
557
624
|
expect(logSpy).toHaveBeenCalledWith("Wake complete.");
|
|
558
625
|
});
|
|
559
626
|
});
|
package/src/commands/client.ts
CHANGED
|
@@ -29,18 +29,28 @@ import {
|
|
|
29
29
|
} from "../lib/client-identity";
|
|
30
30
|
import {
|
|
31
31
|
getLockfileData,
|
|
32
|
-
|
|
32
|
+
upsertRendererLockfileAssistant,
|
|
33
33
|
replacePlatformAssistants,
|
|
34
34
|
isActiveAssistant,
|
|
35
|
+
isPairedLockfileEntry,
|
|
36
|
+
PAIRED_GUARDIAN_TOKEN_HOST_ONLY_ERROR,
|
|
35
37
|
runHatch,
|
|
36
38
|
runRetire,
|
|
39
|
+
connectImport,
|
|
40
|
+
unpairAssistant,
|
|
37
41
|
getGuardianAccessToken,
|
|
42
|
+
getPairedGuardianAccessToken,
|
|
38
43
|
parseGatewayUrl,
|
|
44
|
+
parsePairedGatewayUrl,
|
|
39
45
|
resolveGatewayProxyTarget,
|
|
46
|
+
resolvePairedGatewayProxyTarget,
|
|
40
47
|
readAllowedGatewayPorts,
|
|
48
|
+
readPairedGatewayTargets,
|
|
49
|
+
authorizePairedForwardHeaders,
|
|
41
50
|
isLoopbackAddr,
|
|
42
51
|
headerHostIsLoopback,
|
|
43
52
|
originIsAllowed,
|
|
53
|
+
hasSameOriginCredentialProof,
|
|
44
54
|
resolveDevCliInvocation,
|
|
45
55
|
resolveLockfilePaths,
|
|
46
56
|
resolveConfigDir,
|
|
@@ -445,6 +455,8 @@ function findWebSourceDir(): string | null {
|
|
|
445
455
|
const LOCKFILE_PATTERN = /^(?:\/assistant)?\/__local\/lockfile$/;
|
|
446
456
|
const HATCH_PATTERN = /^(?:\/assistant)?\/__local\/hatch$/;
|
|
447
457
|
const RETIRE_PATTERN = /^(?:\/assistant)?\/__local\/retire$/;
|
|
458
|
+
const UNPAIR_PATTERN = /^(?:\/assistant)?\/__local\/unpair$/;
|
|
459
|
+
const CONNECT_IMPORT_PATTERN = /^(?:\/assistant)?\/__local\/connect-import$/;
|
|
448
460
|
const GUARDIAN_TOKEN_PATTERN =
|
|
449
461
|
/^(?:\/assistant)?\/__local\/guardian-token\/([^/]+)$/;
|
|
450
462
|
const PLATFORM_SESSION_PATTERN =
|
|
@@ -462,15 +474,14 @@ function currentPlatformToken(): string | null {
|
|
|
462
474
|
return platformSessionToken;
|
|
463
475
|
}
|
|
464
476
|
|
|
465
|
-
// Whether to attach
|
|
466
|
-
//
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
return !site || site === "same-origin" || site === "none";
|
|
477
|
+
// Whether to attach a host-owned credential to a proxied request. The browser
|
|
478
|
+
// must positively identify the request as coming from this server's origin.
|
|
479
|
+
export function isSameOriginRequest(req: Request): boolean {
|
|
480
|
+
return hasSameOriginCredentialProof(
|
|
481
|
+
req.headers.get("host") ?? undefined,
|
|
482
|
+
req.headers.get("origin") ?? undefined,
|
|
483
|
+
req.headers.get("sec-fetch-site") ?? undefined,
|
|
484
|
+
);
|
|
474
485
|
}
|
|
475
486
|
|
|
476
487
|
function getEnvRecord(): Record<string, string> {
|
|
@@ -500,9 +511,12 @@ async function handleLocalEndpoints(
|
|
|
500
511
|
LOCKFILE_PATTERN.test(pathname) ||
|
|
501
512
|
HATCH_PATTERN.test(pathname) ||
|
|
502
513
|
RETIRE_PATTERN.test(pathname) ||
|
|
514
|
+
UNPAIR_PATTERN.test(pathname) ||
|
|
515
|
+
CONNECT_IMPORT_PATTERN.test(pathname) ||
|
|
503
516
|
GUARDIAN_TOKEN_PATTERN.test(pathname) ||
|
|
504
517
|
PLATFORM_SESSION_PATTERN.test(pathname) ||
|
|
505
|
-
parseGatewayUrl(pathname).match
|
|
518
|
+
parseGatewayUrl(pathname).match ||
|
|
519
|
+
parsePairedGatewayUrl(pathname).match;
|
|
506
520
|
|
|
507
521
|
if (!isLocalRoute) return null;
|
|
508
522
|
|
|
@@ -573,7 +587,7 @@ async function handleLocalEndpoints(
|
|
|
573
587
|
body.organizationId as string | undefined,
|
|
574
588
|
);
|
|
575
589
|
} else {
|
|
576
|
-
result =
|
|
590
|
+
result = upsertRendererLockfileAssistant(
|
|
577
591
|
lockfilePaths,
|
|
578
592
|
body.assistant as Record<string, unknown>,
|
|
579
593
|
body.activeAssistant as string | undefined,
|
|
@@ -680,6 +694,74 @@ async function handleLocalEndpoints(
|
|
|
680
694
|
);
|
|
681
695
|
}
|
|
682
696
|
|
|
697
|
+
// Unpair: forget a paired assistant (lockfile entry + guardian token).
|
|
698
|
+
if (UNPAIR_PATTERN.test(pathname)) {
|
|
699
|
+
if (req.method !== "POST") {
|
|
700
|
+
return new Response(null, { status: 405 });
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
let assistantId: string | undefined;
|
|
704
|
+
try {
|
|
705
|
+
const body = (await req.json()) as { assistantId?: string };
|
|
706
|
+
assistantId = body.assistantId;
|
|
707
|
+
} catch {
|
|
708
|
+
return Response.json(
|
|
709
|
+
{ ok: false, error: "Invalid JSON body" },
|
|
710
|
+
{ status: 400 },
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (!assistantId) {
|
|
715
|
+
return Response.json(
|
|
716
|
+
{ ok: false, error: "Missing assistantId" },
|
|
717
|
+
{ status: 400 },
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const result = unpairAssistant(lockfilePaths, configDir, assistantId);
|
|
722
|
+
if (result.ok) {
|
|
723
|
+
return Response.json({ ok: true, lockfile: result.lockfile });
|
|
724
|
+
}
|
|
725
|
+
return Response.json(
|
|
726
|
+
{ ok: false, error: result.error },
|
|
727
|
+
{ status: result.status },
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Connect-import: register a pairing bundle from another machine (guardian
|
|
732
|
+
// token + paired lockfile entry), the write counterpart of unpair.
|
|
733
|
+
if (CONNECT_IMPORT_PATTERN.test(pathname)) {
|
|
734
|
+
if (req.method !== "POST") {
|
|
735
|
+
return new Response(null, { status: 405 });
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
let body: { bundle?: unknown; name?: unknown };
|
|
739
|
+
try {
|
|
740
|
+
body = (await req.json()) as { bundle?: unknown; name?: unknown };
|
|
741
|
+
} catch {
|
|
742
|
+
return Response.json(
|
|
743
|
+
{ ok: false, error: "Invalid JSON body" },
|
|
744
|
+
{ status: 400 },
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const result = connectImport(lockfilePaths, configDir, {
|
|
749
|
+
bundle: body.bundle,
|
|
750
|
+
name: body.name,
|
|
751
|
+
});
|
|
752
|
+
if (result.ok) {
|
|
753
|
+
return Response.json({
|
|
754
|
+
ok: true,
|
|
755
|
+
assistantId: result.assistantId,
|
|
756
|
+
accessOnly: result.accessOnly,
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
return Response.json(
|
|
760
|
+
{ ok: false, error: result.error },
|
|
761
|
+
{ status: result.status },
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
|
|
683
765
|
// Guardian token
|
|
684
766
|
const guardianMatch = pathname.match(GUARDIAN_TOKEN_PATTERN);
|
|
685
767
|
if (guardianMatch) {
|
|
@@ -687,6 +769,13 @@ async function handleLocalEndpoints(
|
|
|
687
769
|
|
|
688
770
|
const assistantId = decodeURIComponent(guardianMatch[1]!);
|
|
689
771
|
|
|
772
|
+
if (isPairedLockfileEntry(lockfilePaths, assistantId)) {
|
|
773
|
+
return Response.json(
|
|
774
|
+
{ error: PAIRED_GUARDIAN_TOKEN_HOST_ONLY_ERROR },
|
|
775
|
+
{ status: 403 },
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
|
|
690
779
|
let invocation: CliInvocation;
|
|
691
780
|
try {
|
|
692
781
|
invocation = resolveDevCliInvocation(_baseDir);
|
|
@@ -703,6 +792,7 @@ async function handleLocalEndpoints(
|
|
|
703
792
|
invocation,
|
|
704
793
|
true,
|
|
705
794
|
_localEnv,
|
|
795
|
+
{ paired: false },
|
|
706
796
|
);
|
|
707
797
|
if (result.ok) {
|
|
708
798
|
return Response.json({ accessToken: result.accessToken });
|
|
@@ -728,30 +818,95 @@ async function handleLocalEndpoints(
|
|
|
728
818
|
const targetUrl = `http://127.0.0.1:${gatewayTarget.port}${gatewayTarget.path}${url.search}`;
|
|
729
819
|
const headers = new Headers(req.headers);
|
|
730
820
|
headers.set("host", `127.0.0.1:${gatewayTarget.port}`);
|
|
821
|
+
return proxyGatewayFetch(req, targetUrl, headers, "Gateway proxy error");
|
|
822
|
+
}
|
|
731
823
|
|
|
824
|
+
// Paired-gateway proxy: same shared decision as the web (Vite middleware)
|
|
825
|
+
// and Electron (`app://` handler) hosts, forwarding to the remote gateway an
|
|
826
|
+
// imported pairing recorded as its `runtimeUrl`. The lockfile's paired
|
|
827
|
+
// entries are the allowlist. Renderer authorization and browser-ambient
|
|
828
|
+
// headers are stripped on the server-to-server hop. This CLI host reads the
|
|
829
|
+
// paired guardian bearer from disk and installs it after sanitization.
|
|
830
|
+
const pairedDecision = resolvePairedGatewayProxyTarget(
|
|
831
|
+
pathname + url.search,
|
|
832
|
+
() => readPairedGatewayTargets(lockfilePaths),
|
|
833
|
+
);
|
|
834
|
+
if (pairedDecision.kind === "reject") {
|
|
835
|
+
return new Response(pairedDecision.message, {
|
|
836
|
+
status: pairedDecision.status,
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
if (pairedDecision.kind === "forward") {
|
|
840
|
+
if (!isSameOriginRequest(req)) {
|
|
841
|
+
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
842
|
+
}
|
|
843
|
+
let invocation: CliInvocation;
|
|
732
844
|
try {
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
body: hasBody ? req.body : undefined,
|
|
738
|
-
redirect: "manual",
|
|
739
|
-
});
|
|
740
|
-
const resHeaders = new Headers(proxyRes.headers);
|
|
741
|
-
resHeaders.delete("transfer-encoding");
|
|
742
|
-
return new Response(proxyRes.body, {
|
|
743
|
-
status: proxyRes.status,
|
|
744
|
-
statusText: proxyRes.statusText,
|
|
745
|
-
headers: resHeaders,
|
|
845
|
+
invocation = resolveDevCliInvocation(_baseDir);
|
|
846
|
+
} catch (err) {
|
|
847
|
+
return new Response(err instanceof Error ? err.message : String(err), {
|
|
848
|
+
status: 500,
|
|
746
849
|
});
|
|
747
|
-
} catch {
|
|
748
|
-
return new Response("Gateway proxy error", { status: 502 });
|
|
749
850
|
}
|
|
851
|
+
const headers = new Headers(req.headers);
|
|
852
|
+
const tokenResult = await authorizePairedForwardHeaders(
|
|
853
|
+
pairedDecision.assistantId,
|
|
854
|
+
pairedDecision.runtimeUrl,
|
|
855
|
+
headers,
|
|
856
|
+
(assistantId, runtimeUrl) =>
|
|
857
|
+
getPairedGuardianAccessToken(
|
|
858
|
+
assistantId,
|
|
859
|
+
runtimeUrl,
|
|
860
|
+
configDir,
|
|
861
|
+
invocation,
|
|
862
|
+
true,
|
|
863
|
+
_localEnv,
|
|
864
|
+
),
|
|
865
|
+
);
|
|
866
|
+
if (!tokenResult.ok) {
|
|
867
|
+
return new Response(tokenResult.error, { status: tokenResult.status });
|
|
868
|
+
}
|
|
869
|
+
headers.set("host", new URL(pairedDecision.url).host);
|
|
870
|
+
return proxyGatewayFetch(
|
|
871
|
+
req,
|
|
872
|
+
pairedDecision.url,
|
|
873
|
+
headers,
|
|
874
|
+
"Paired gateway proxy error",
|
|
875
|
+
);
|
|
750
876
|
}
|
|
751
877
|
|
|
752
878
|
return null;
|
|
753
879
|
}
|
|
754
880
|
|
|
881
|
+
// One streamed hop for both gateway data-plane proxies. The upstream
|
|
882
|
+
// `transfer-encoding` is dropped so re-serving the streamed body doesn't emit
|
|
883
|
+
// a duplicate chunked header.
|
|
884
|
+
async function proxyGatewayFetch(
|
|
885
|
+
req: Request,
|
|
886
|
+
targetUrl: string,
|
|
887
|
+
headers: Headers,
|
|
888
|
+
errorMessage: string,
|
|
889
|
+
): Promise<Response> {
|
|
890
|
+
try {
|
|
891
|
+
const hasBody = req.method !== "GET" && req.method !== "HEAD";
|
|
892
|
+
const proxyRes = await loopbackSafeFetch(targetUrl, {
|
|
893
|
+
method: req.method,
|
|
894
|
+
headers,
|
|
895
|
+
body: hasBody ? req.body : undefined,
|
|
896
|
+
redirect: "manual",
|
|
897
|
+
});
|
|
898
|
+
const resHeaders = new Headers(proxyRes.headers);
|
|
899
|
+
resHeaders.delete("transfer-encoding");
|
|
900
|
+
return new Response(proxyRes.body, {
|
|
901
|
+
status: proxyRes.status,
|
|
902
|
+
statusText: proxyRes.statusText,
|
|
903
|
+
headers: resHeaders,
|
|
904
|
+
});
|
|
905
|
+
} catch {
|
|
906
|
+
return new Response(errorMessage, { status: 502 });
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
755
910
|
function getBaseDir(): string {
|
|
756
911
|
let dir = import.meta.dir;
|
|
757
912
|
for (let depth = 0; depth < 8; depth++) {
|