@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.
Files changed (97) hide show
  1. package/knip.json +1 -0
  2. package/node_modules/@vellumai/environments/package.json +2 -1
  3. package/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
  4. package/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
  5. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
  6. package/node_modules/@vellumai/environments/src/index.ts +17 -6
  7. package/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
  8. package/node_modules/@vellumai/environments/src/seeds.ts +29 -0
  9. package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
  10. package/node_modules/@vellumai/ipc-server-utils/src/endpoint.test.ts +36 -0
  11. package/node_modules/@vellumai/ipc-server-utils/src/endpoint.ts +142 -0
  12. package/node_modules/@vellumai/ipc-server-utils/src/index.ts +18 -0
  13. package/node_modules/@vellumai/ipc-server-utils/src/ipc-framing.ts +295 -0
  14. package/node_modules/@vellumai/ipc-server-utils/src/listen-options.ts +3 -0
  15. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +444 -0
  16. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +236 -0
  17. package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
  18. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/package.json +2 -1
  19. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
  20. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
  21. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
  22. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/index.ts +17 -6
  23. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
  24. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/seeds.ts +29 -0
  25. package/node_modules/@vellumai/local-mode/node_modules/nanoid/LICENSE +20 -0
  26. package/node_modules/@vellumai/local-mode/node_modules/nanoid/README.md +38 -0
  27. package/node_modules/@vellumai/local-mode/node_modules/nanoid/bin/nanoid.js +55 -0
  28. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.browser.js +29 -0
  29. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.d.ts +106 -0
  30. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.js +47 -0
  31. package/node_modules/@vellumai/local-mode/node_modules/nanoid/nanoid.js +1 -0
  32. package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.d.ts +48 -0
  33. package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.js +21 -0
  34. package/node_modules/@vellumai/local-mode/node_modules/nanoid/package.json +46 -0
  35. package/node_modules/@vellumai/local-mode/node_modules/nanoid/url-alphabet/index.js +2 -0
  36. package/node_modules/@vellumai/local-mode/package.json +1 -0
  37. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +63 -1
  38. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +503 -0
  39. package/node_modules/@vellumai/local-mode/src/__tests__/guardian-token.test.ts +203 -0
  40. package/node_modules/@vellumai/local-mode/src/__tests__/loopback-auth.test.ts +46 -1
  41. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +6 -4
  42. package/node_modules/@vellumai/local-mode/src/__tests__/pair.test.ts +621 -0
  43. package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +5 -0
  44. package/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +232 -0
  45. package/node_modules/@vellumai/local-mode/src/config.ts +126 -20
  46. package/node_modules/@vellumai/local-mode/src/environment.ts +36 -15
  47. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +313 -25
  48. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +222 -13
  49. package/node_modules/@vellumai/local-mode/src/index.ts +35 -3
  50. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +18 -0
  51. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +72 -0
  52. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +130 -0
  53. package/node_modules/@vellumai/local-mode/src/lockfile.ts +134 -45
  54. package/node_modules/@vellumai/local-mode/src/pair.ts +354 -0
  55. package/node_modules/@vellumai/local-mode/src/paths.ts +49 -0
  56. package/node_modules/@vellumai/local-mode/src/status.ts +10 -8
  57. package/node_modules/@vellumai/local-mode/src/unpair.ts +101 -0
  58. package/node_modules/@vellumai/local-mode/src/util.ts +34 -0
  59. package/node_modules/@vellumai/service-contracts/src/channels.ts +39 -0
  60. package/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
  61. package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +6 -0
  62. package/package.json +3 -1
  63. package/src/__tests__/client-token.test.ts +26 -1
  64. package/src/__tests__/guardian-token.test.ts +4 -0
  65. package/src/__tests__/nginx-ingress-command.test.ts +176 -16
  66. package/src/__tests__/nginx-ingress.test.ts +1821 -101
  67. package/src/__tests__/pair.test.ts +104 -2
  68. package/src/__tests__/sleep.test.ts +7 -3
  69. package/src/__tests__/tailscale-tunnel.test.ts +0 -1
  70. package/src/__tests__/tunnel.test.ts +1099 -55
  71. package/src/__tests__/wake.test.ts +166 -99
  72. package/src/commands/client.ts +182 -27
  73. package/src/commands/connect/import.ts +35 -156
  74. package/src/commands/nginx-ingress.ts +87 -110
  75. package/src/commands/pair.ts +84 -21
  76. package/src/commands/tunnel.ts +196 -85
  77. package/src/commands/upgrade.ts +3 -2
  78. package/src/commands/wake.ts +5 -163
  79. package/src/index.ts +1 -1
  80. package/src/lib/__tests__/local-ces.test.ts +10 -1
  81. package/src/lib/cloudflare-tunnel.ts +3 -16
  82. package/src/lib/environments/__tests__/paths.test.ts +20 -1
  83. package/src/lib/environments/paths.ts +29 -41
  84. package/src/lib/environments/resolve.ts +7 -5
  85. package/src/lib/guardian-token.ts +22 -62
  86. package/src/lib/ingress-config.ts +25 -0
  87. package/src/lib/local.ts +77 -20
  88. package/src/lib/nginx-ingress.ts +523 -76
  89. package/src/lib/ngrok.ts +154 -61
  90. package/src/lib/orphan-detection.test.ts +167 -0
  91. package/src/lib/orphan-detection.ts +40 -0
  92. package/src/lib/pair.test.ts +87 -0
  93. package/src/lib/pair.ts +55 -0
  94. package/src/lib/platform-client.ts +8 -5
  95. package/src/lib/tailscale-tunnel.ts +7 -18
  96. package/src/lib/tunnel-edge.ts +114 -0
  97. 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 startRemoteWebIngressMock = mock<
168
- typeof nginxIngress.startRemoteWebIngress
169
- >(async () => ({
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
- startRemoteWebIngress: startRemoteWebIngressMock,
178
+ readIngressState: readIngressStateMock,
180
179
  }));
181
180
 
182
- const { wake, WEB_INGRESS_FLAG_RETRY } = await import("../commands/wake.js");
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
- startRemoteWebIngressMock.mockReset();
266
- startRemoteWebIngressMock.mockResolvedValue({
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
- describe("vellum wake — web ingress restore", () => {
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
- afterEach(() => {
421
- WEB_INGRESS_FLAG_RETRY.attempts = originalRetry.attempts;
422
- WEB_INGRESS_FLAG_RETRY.intervalMs = originalRetry.intervalMs;
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("restores the edge when ingress is enabled, the flag is on, and it is not already running", async () => {
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(isAssistantFeatureFlagEnabledMock).toHaveBeenCalledWith(
431
- "local-assistant",
432
- "web-remote-ingress",
433
- { runtimeUrl: "http://127.0.0.1:7830" },
434
- );
435
- expect(startRemoteWebIngressMock).toHaveBeenCalledWith(
436
- expect.objectContaining({
437
- workspaceDir: join(tempDir, ".vellum", "workspace"),
438
- gatewayPort: 7830,
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("does not attempt a restore when ingress is disabled", async () => {
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(startRemoteWebIngressMock).not.toHaveBeenCalled();
459
+ expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
460
+ expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
461
+ 7830,
462
+ workspaceDirOf(tempDir),
463
+ );
451
464
  });
452
465
 
453
- test("does not attempt a restore when ingress config is absent", async () => {
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(isAssistantFeatureFlagEnabledMock).not.toHaveBeenCalled();
459
- expect(startRemoteWebIngressMock).not.toHaveBeenCalled();
471
+ expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
472
+ expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
473
+ 7830,
474
+ workspaceDirOf(tempDir),
475
+ );
460
476
  });
461
477
 
462
- test("does not attempt a restore when enabled but the public URL is missing", async () => {
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(isAssistantFeatureFlagEnabledMock).not.toHaveBeenCalled();
468
- expect(startRemoteWebIngressMock).not.toHaveBeenCalled();
483
+ expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
469
484
  });
470
485
 
471
- test("does not start a second edge when one is already running", async () => {
472
- loadRawConfigMock.mockReturnValue(enabledConfig);
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(startRemoteWebIngressMock).not.toHaveBeenCalled();
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("skips the restore quietly with a hint when the flag is off", async () => {
483
- loadRawConfigMock.mockReturnValue(enabledConfig);
484
- isAssistantFeatureFlagEnabledMock.mockResolvedValue(false);
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(startRemoteWebIngressMock).not.toHaveBeenCalled();
489
- expect(logSpy).toHaveBeenCalledWith(
490
- expect.stringContaining("web-remote-ingress"),
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 thrown restore failure warns but does not fail the wake", async () => {
496
- loadRawConfigMock.mockReturnValue(enabledConfig);
497
- startRemoteWebIngressMock.mockRejectedValue(new Error("nginx boom"));
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(startRemoteWebIngressMock).toHaveBeenCalled();
502
- expect(warnSpy).toHaveBeenCalled();
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("an unreachable edge warns but does not fail the wake", async () => {
507
- loadRawConfigMock.mockReturnValue(enabledConfig);
508
- startRemoteWebIngressMock.mockResolvedValue({
509
- status: "unreachable",
510
- listenPort: 7840,
511
- logPath: "/tmp/nginx-ingress.log",
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(warnSpy).toHaveBeenCalled();
517
- expect(logSpy).toHaveBeenCalledWith("Wake complete.");
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 failed flag probe warns and leaves the edge down without failing the wake", async () => {
588
+ test("a reused edge still points the tunnel at its listen port", async () => {
521
589
  loadRawConfigMock.mockReturnValue(enabledConfig);
522
- isAssistantFeatureFlagEnabledMock.mockRejectedValue(
523
- new Error("gateway unreachable"),
524
- );
590
+ ensureTunnelEdgeMock.mockResolvedValue({
591
+ port: 7841,
592
+ started: false,
593
+ includesWebApp: true,
594
+ });
525
595
 
526
596
  await wake();
527
597
 
528
- // The probe retries through the startup window before giving up.
529
- expect(isAssistantFeatureFlagEnabledMock.mock.calls.length).toBe(3);
530
- expect(startRemoteWebIngressMock).not.toHaveBeenCalled();
531
- expect(warnSpy).toHaveBeenCalled();
532
- expect(logSpy).toHaveBeenCalledWith("Wake complete.");
598
+ expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
599
+ 7841,
600
+ workspaceDirOf(tempDir),
601
+ );
533
602
  });
534
603
 
535
- test("a flag probe that fails while the gateway is starting retries and then restores", async () => {
536
- loadRawConfigMock.mockReturnValue(enabledConfig);
537
- isAssistantFeatureFlagEnabledMock
538
- .mockRejectedValueOnce(
539
- new Error(
540
- 'Failed to fetch feature flags: HTTP 503 {"status":"starting"}',
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(isAssistantFeatureFlagEnabledMock.mock.calls.length).toBe(3);
553
- expect(logSpy).toHaveBeenCalledWith(
554
- " Waiting for the gateway before restoring the web ingress edge...",
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
  });
@@ -29,18 +29,28 @@ import {
29
29
  } from "../lib/client-identity";
30
30
  import {
31
31
  getLockfileData,
32
- upsertLockfileAssistant,
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 the platform credential to a proxied request. Only
466
- // same-origin (SPA) traffic qualifies a cross-site page must not be able to
467
- // use the local proxy as a confused deputy for authenticated platform calls.
468
- // Cross-origin fetches always send an Origin; `Sec-Fetch-Site` is a belt-and-
469
- // braces check for browsers that send it.
470
- function isSameOriginRequest(req: Request): boolean {
471
- if (!originIsAllowed(req.headers.get("origin") ?? undefined)) return false;
472
- const site = req.headers.get("sec-fetch-site");
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 = upsertLockfileAssistant(
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
- const hasBody = req.method !== "GET" && req.method !== "HEAD";
734
- const proxyRes = await loopbackSafeFetch(targetUrl, {
735
- method: req.method,
736
- headers,
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++) {