@vellumai/cli 0.11.3 → 0.11.4-dev.202608190019.b94dbf2

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 (41) hide show
  1. package/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +33 -0
  2. package/node_modules/@vellumai/local-mode/src/index.ts +3 -0
  3. package/node_modules/@vellumai/local-mode/src/lockfile-lock.test.ts +165 -0
  4. package/node_modules/@vellumai/local-mode/src/lockfile-lock.ts +156 -0
  5. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +249 -8
  6. package/node_modules/@vellumai/local-mode/src/lockfile.ts +186 -68
  7. package/node_modules/@vellumai/local-mode/src/unpair.ts +18 -0
  8. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  9. package/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
  10. package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  11. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  12. package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
  13. package/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
  14. package/package.json +1 -1
  15. package/src/__tests__/assistant-config.test.ts +35 -0
  16. package/src/__tests__/nginx-ingress-command.test.ts +4 -23
  17. package/src/__tests__/nginx-ingress.test.ts +59 -215
  18. package/src/__tests__/pair.test.ts +11 -197
  19. package/src/__tests__/retire-archive.test.ts +13 -1
  20. package/src/__tests__/retire-local.test.ts +58 -4
  21. package/src/__tests__/tunnel.test.ts +0 -28
  22. package/src/__tests__/wake.test.ts +91 -69
  23. package/src/__tests__/windows-lifecycle.test.ts +157 -0
  24. package/src/commands/client.ts +9 -31
  25. package/src/commands/nginx-ingress.ts +0 -15
  26. package/src/commands/pair.ts +0 -39
  27. package/src/commands/wake.ts +39 -12
  28. package/src/lib/__tests__/web-dist.test.ts +86 -0
  29. package/src/lib/assistant-config.ts +64 -27
  30. package/src/lib/local.ts +60 -20
  31. package/src/lib/nginx-ingress.ts +35 -108
  32. package/src/lib/orphan-detection.test.ts +3 -0
  33. package/src/lib/orphan-detection.ts +33 -11
  34. package/src/lib/pgrep.ts +20 -2
  35. package/src/lib/process.ts +191 -18
  36. package/src/lib/retire-archive.ts +38 -8
  37. package/src/lib/retire-local.ts +75 -9
  38. package/src/lib/tunnel-edge.ts +14 -22
  39. package/src/lib/web-dist.ts +48 -0
  40. package/src/lib/feature-flags.test.ts +0 -157
  41. package/src/lib/feature-flags.ts +0 -38
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Canonical URL normalization for web tool inputs, shared by the gateway's
3
+ * risk classifiers and the daemon's URL safety checks.
4
+ *
5
+ * A URL a model passes to `web_fetch` / `network_request` reaches more than
6
+ * one consumer, and they must agree on what counts as the same target: the
7
+ * gateway builds the trust-rule ladder from it, and anything that later
8
+ * compares a URL to a saved rule has to fold the same spellings together. Two
9
+ * normalizations would mean two answers, so it lives here rather than in
10
+ * either process.
11
+ *
12
+ * Data-only in spirit: no imports, no config, no I/O.
13
+ */
14
+
15
+ /** Whether a bare `host:port` (or `[v6]:port`) shorthand was written. */
16
+ export function looksLikeHostPortShorthand(value: string): boolean {
17
+ if (/^\[[0-9a-fA-F:.%]+\]:\d+(?:[/?#]|$)/.test(value)) {
18
+ return true;
19
+ }
20
+ return /^[^/?#@\s:]+:\d+(?:[/?#]|$)/.test(value);
21
+ }
22
+
23
+ /** Whether the input is a path, query, or fragment rather than a URL. */
24
+ export function looksLikePathOnlyInput(value: string): boolean {
25
+ return (
26
+ value.startsWith("/") ||
27
+ value.startsWith("./") ||
28
+ value.startsWith("../") ||
29
+ value.startsWith("?") ||
30
+ value.startsWith("#")
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Strip the parts of a URL that must not affect a trust decision, and fold
36
+ * the encodings that would otherwise let one URL wear two spellings.
37
+ *
38
+ * Percent-escaped path segments are decoded (`/%70rivate` and `/private` are
39
+ * one path, so a path-scoped rule cannot be bypassed by escaping), a trailing
40
+ * root dot is dropped from the hostname, and fragment and userinfo are
41
+ * removed: the fragment never reaches the server, and credentials in the
42
+ * authority must never end up in a saved rule.
43
+ */
44
+ export function canonicalizeWebUrl(parsed: URL): URL {
45
+ parsed.hash = "";
46
+ parsed.username = "";
47
+ parsed.password = "";
48
+
49
+ try {
50
+ parsed.pathname = decodeURI(parsed.pathname);
51
+ } catch {
52
+ // Keep the URL parser's canonical form when decoding fails.
53
+ }
54
+
55
+ if (parsed.hostname.endsWith(".")) {
56
+ parsed.hostname = parsed.hostname.replace(/\.+$/, "");
57
+ }
58
+
59
+ return parsed;
60
+ }
61
+
62
+ /**
63
+ * Parse and canonicalize a web tool's `url` input, or `null` when it is not a
64
+ * URL this system will fetch.
65
+ *
66
+ * Accepts `https://host/path`, `http://…`, bare `host/path`, and `host:port`
67
+ * shorthand. Rejects path-only input and any other scheme (`file:`, `data:`,
68
+ * `javascript:`), so a non-http target can never acquire a web trust rule.
69
+ */
70
+ export function normalizeWebUrl(rawUrl: string): URL | null {
71
+ const trimmed = rawUrl.trim();
72
+ if (!trimmed) {
73
+ return null;
74
+ }
75
+
76
+ if (looksLikeHostPortShorthand(trimmed)) {
77
+ try {
78
+ return canonicalizeWebUrl(new URL(`https://${trimmed}`));
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ try {
85
+ const parsed = new URL(trimmed);
86
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") {
87
+ return canonicalizeWebUrl(parsed);
88
+ }
89
+ return null;
90
+ } catch {
91
+ // Not an absolute URL; fall through to the shorthand forms.
92
+ }
93
+
94
+ if (looksLikePathOnlyInput(trimmed)) {
95
+ return null;
96
+ }
97
+
98
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) {
99
+ return null;
100
+ }
101
+
102
+ try {
103
+ return canonicalizeWebUrl(new URL(`https://${trimmed}`));
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.11.3",
3
+ "version": "0.11.4-dev.202608190019.b94dbf2",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,10 +8,12 @@ import {
8
8
  spyOn,
9
9
  } from "bun:test";
10
10
  import {
11
+ existsSync,
11
12
  mkdirSync,
12
13
  mkdtempSync,
13
14
  readFileSync,
14
15
  rmSync,
16
+ utimesSync,
15
17
  writeFileSync,
16
18
  } from "node:fs";
17
19
  import { tmpdir } from "node:os";
@@ -97,6 +99,39 @@ describe("assistant-config", () => {
97
99
  expect(all[0].assistantId).toBe("test-1");
98
100
  });
99
101
 
102
+ test("saveAssistantEntry writes under the advisory lock and cleans it up", () => {
103
+ saveAssistantEntry(makeEntry("locked-1"));
104
+ expect(loadAllAssistants()).toHaveLength(1);
105
+ // The shared `${lockfilePath}.lock` key hosts contend on is released.
106
+ expect(existsSync(join(testDir, ".vellum.lock.json.lock"))).toBe(false);
107
+ });
108
+
109
+ test("saveAssistantEntry breaks a stale lock and still lands the write", () => {
110
+ const lockDir = join(testDir, ".vellum.lock.json.lock");
111
+ mkdirSync(lockDir);
112
+ const past = new Date(Date.now() - 60_000);
113
+ utimesSync(lockDir, past, past);
114
+
115
+ saveAssistantEntry(makeEntry("stale-lock-1"));
116
+ expect(loadAllAssistants()).toHaveLength(1);
117
+ expect(existsSync(lockDir)).toBe(false);
118
+ });
119
+
120
+ test("saveAssistantEntry fails fast without writing when another holder is fresh", () => {
121
+ const lockDir = join(testDir, ".vellum.lock.json.lock");
122
+ mkdirSync(lockDir);
123
+ try {
124
+ const start = Date.now();
125
+ expect(() => saveAssistantEntry(makeEntry("contended-1"))).toThrow(
126
+ /Timed out acquiring lockfile lock/,
127
+ );
128
+ expect(Date.now() - start).toBeLessThan(2_000);
129
+ expect(loadAllAssistants()).toEqual([]);
130
+ } finally {
131
+ rmSync(lockDir, { recursive: true, force: true });
132
+ }
133
+ });
134
+
100
135
  test("findAssistantByName returns matching entry", () => {
101
136
  writeLockfile({
102
137
  assistants: [makeEntry("alpha"), makeEntry("beta")],
@@ -120,11 +120,11 @@ describe("up", () => {
120
120
  logSpy.mockRestore();
121
121
  });
122
122
 
123
- test("starts the webhooks-only edge when the flag is off and states the mode", async () => {
123
+ test("starts the edge and states the remote web mode", async () => {
124
124
  ensureTunnelEdgeMock.mockResolvedValue({
125
- port: 7845,
125
+ port: 7840,
126
126
  started: true,
127
- includesWebApp: false,
127
+ includesWebApp: true,
128
128
  });
129
129
 
130
130
  await up({
@@ -139,28 +139,9 @@ describe("up", () => {
139
139
  workspaceDir,
140
140
  gatewayPort: 7830,
141
141
  });
142
- const output = logs.join("\n");
143
- expect(output).toContain("http://127.0.0.1:7845 (webhooks only)");
144
- expect(output).toContain("web-remote-ingress");
145
- expect(output).toContain("vellum tunnel --provider ngrok");
146
- });
147
-
148
- test("states the remote web mode when the flag is on", async () => {
149
- ensureTunnelEdgeMock.mockResolvedValue({
150
- port: 7840,
151
- started: true,
152
- includesWebApp: true,
153
- });
154
-
155
- await up({
156
- assistantId: "assistant-1",
157
- workspaceDir,
158
- gatewayPort: 7830,
159
- });
160
-
161
142
  const output = logs.join("\n");
162
143
  expect(output).toContain("http://127.0.0.1:7840 (remote web + webhooks)");
163
- expect(output).not.toContain("Enable the web-remote-ingress feature flag");
144
+ expect(output).toContain("vellum tunnel --provider ngrok");
164
145
  });
165
146
  });
166
147
 
@@ -23,12 +23,10 @@ import {
23
23
  test,
24
24
  } from "bun:test";
25
25
 
26
- import * as featureFlags from "../lib/feature-flags.js";
27
26
  import * as httpClient from "../lib/http-client.js";
28
27
 
29
28
  const realChildProcess = { ...childProcess };
30
29
  const realFs = { ...fsModule };
31
- const realFeatureFlags = { ...featureFlags };
32
30
  const realHttpClient = { ...httpClient };
33
31
 
34
32
  const execFileSyncMock = mock(childProcess.execFileSync);
@@ -60,21 +58,11 @@ mock.module("../lib/http-client.js", () => ({
60
58
  waitForDaemonReady: waitForDaemonReadyMock,
61
59
  }));
62
60
 
63
- const isFeatureFlagEnabledMock = mock<
64
- typeof featureFlags.isAssistantFeatureFlagEnabled
65
- >(async () => true);
66
-
67
- mock.module("../lib/feature-flags.js", () => ({
68
- ...featureFlags,
69
- isAssistantFeatureFlagEnabled: isFeatureFlagEnabledMock,
70
- }));
71
-
72
61
  // Restore the real modules once this file finishes so the mocks do not leak
73
62
  // into sibling test files in the same `bun test` run.
74
63
  afterAll(() => {
75
64
  mock.module("node:child_process", () => realChildProcess);
76
65
  mock.module("node:fs", () => realFs);
77
- mock.module("../lib/feature-flags.js", () => realFeatureFlags);
78
66
  mock.module("../lib/http-client.js", () => realHttpClient);
79
67
  });
80
68
 
@@ -132,8 +120,6 @@ afterEach(() => {
132
120
  readFileSyncMock.mockImplementation(realFs.readFileSync);
133
121
  waitForDaemonReadyMock.mockReset();
134
122
  waitForDaemonReadyMock.mockImplementation(async () => true);
135
- isFeatureFlagEnabledMock.mockReset();
136
- isFeatureFlagEnabledMock.mockImplementation(async () => true);
137
123
  for (const dir of workspaces.splice(0)) {
138
124
  rmSync(dir, { recursive: true, force: true });
139
125
  }
@@ -201,6 +187,27 @@ describe("buildIngressNginxConfig", () => {
201
187
  expect(conf).toContain("location / {");
202
188
  expect(conf).toContain("proxy_pass http://127.0.0.1:7830;");
203
189
  expect(conf).toContain('proxy_set_header X-Vellum-Edge-Forwarded "1";');
190
+ expect(conf).toContain(
191
+ "proxy_set_header X-Vellum-Client-Ip $vellum_edge_client_ip;",
192
+ );
193
+ });
194
+
195
+ test("stamps the edge-observed client ip from the rightmost X-Forwarded-For entry", () => {
196
+ for (const config of [conf, remoteConf]) {
197
+ // Falls back to the raw peer when the TLS-terminating front sets no
198
+ // X-Forwarded-For; the client cannot smuggle a value because
199
+ // proxy_set_header overwrites inbound headers.
200
+ expect(config).toContain(
201
+ "map $http_x_forwarded_for $vellum_edge_client_ip {",
202
+ );
203
+ expect(config).toContain("default $remote_addr;");
204
+ expect(config).toContain(
205
+ '"~,?\\s*(?<vellum_last_xff>[^,\\s]+)\\s*$" $vellum_last_xff;',
206
+ );
207
+ expect(config).toContain(
208
+ "proxy_set_header X-Vellum-Client-Ip $vellum_edge_client_ip;",
209
+ );
210
+ }
204
211
  });
205
212
 
206
213
  test("blocks local-only bootstrap helpers before the catch-all proxy", () => {
@@ -326,6 +333,12 @@ describe("buildIngressNginxConfig", () => {
326
333
  "location = /v1/guardian/init/ { return 404; }",
327
334
  "location = /v1/guardian/reset-bootstrap { return 404; }",
328
335
  "location = /v1/guardian/reset-bootstrap/ { return 404; }",
336
+ "location = /v1/remote-web/pairing-requests { return 404; }",
337
+ "location = /v1/remote-web/pairing-requests/ { return 404; }",
338
+ "location = /v1/remote-web/pairing-requests/approve { return 404; }",
339
+ "location = /v1/remote-web/pairing-requests/approve/ { return 404; }",
340
+ "location = /v1/remote-web/pairing-requests/deny { return 404; }",
341
+ "location = /v1/remote-web/pairing-requests/deny/ { return 404; }",
329
342
  "location ^~ /assistant/__local/ { return 404; }",
330
343
  "location ^~ /assistant/__gateway/ { return 404; }",
331
344
  "location ^~ /assistant/__gateway-paired/ { return 404; }",
@@ -714,7 +727,7 @@ function spaConfigHash(assistantName?: string): string {
714
727
  return createHash("sha256")
715
728
  .update(
716
729
  JSON.stringify({
717
- template: 2,
730
+ template: 3,
718
731
  config: {
719
732
  mode: "remote-gateway",
720
733
  apiBaseUrl: "/v1",
@@ -1617,7 +1630,7 @@ describe("ensureTunnelEdge", () => {
1617
1630
  expect(indexHtml).toContain(`"hubUrl":"${PRODUCTION_HUB_URL}"`);
1618
1631
  });
1619
1632
 
1620
- test("reuses a running edge that matches the flag-resolved mode", async () => {
1633
+ test("reuses a running edge that already serves the SPA mode", async () => {
1621
1634
  const ws = makeWorkspace();
1622
1635
  mockNginxInstalled();
1623
1636
  mockNginxSpawn();
@@ -1644,34 +1657,6 @@ describe("ensureTunnelEdge", () => {
1644
1657
  expect(spawnMock).not.toHaveBeenCalled();
1645
1658
  });
1646
1659
 
1647
- test("reuses a running webhooks-only edge and reports the recorded mode", async () => {
1648
- const ws = makeWorkspace();
1649
- mockNginxInstalled();
1650
- mockNginxSpawn();
1651
- mockWebDistMissing();
1652
- isFeatureFlagEnabledMock.mockImplementation(async () => false);
1653
- const pid = mockRunningEdge(ws, {
1654
- listenPort: 7845,
1655
- includeWebApp: false,
1656
- gatewayPort: 7830,
1657
- });
1658
- const edge = mockKillableNginx(pid);
1659
-
1660
- const result = await ensureTunnelEdge({
1661
- assistantId: ASSISTANT_ID,
1662
- workspaceDir: ws,
1663
- gatewayPort: 7830,
1664
- });
1665
-
1666
- expect(result).toEqual({
1667
- port: 7845,
1668
- started: false,
1669
- includesWebApp: false,
1670
- });
1671
- expect(edge.killed()).toBe(false);
1672
- expect(spawnMock).not.toHaveBeenCalled();
1673
- });
1674
-
1675
1660
  test("restarts the edge when the lockfile display name changes", async () => {
1676
1661
  const ws = makeWorkspace();
1677
1662
  mockNginxInstalled();
@@ -1751,9 +1736,8 @@ describe("ensureTunnelEdge", () => {
1751
1736
  const ws = makeWorkspace();
1752
1737
  mockNginxInstalled();
1753
1738
  mockNginxSpawn();
1754
- mockWebDistMissing();
1755
- isFeatureFlagEnabledMock.mockImplementation(async () => false);
1756
- const pid = mockRunningEdge(ws, { listenPort: 7845, includeWebApp: true });
1739
+ mockWebDistPresent();
1740
+ const pid = mockRunningEdge(ws, { listenPort: 7845, includeWebApp: false });
1757
1741
  mockUnkillableNginx(pid);
1758
1742
 
1759
1743
  const promise = ensureTunnelEdge({
@@ -1763,7 +1747,7 @@ describe("ensureTunnelEdge", () => {
1763
1747
  });
1764
1748
 
1765
1749
  await expect(promise).rejects.toThrow(
1766
- "still running in web app mode and could not be restarted in webhooks-only mode",
1750
+ "still running in webhooks-only mode and could not be restarted in web app mode",
1767
1751
  );
1768
1752
  await expect(promise).rejects.toThrow("vellum nginx-ingress down");
1769
1753
  expect(spawnMock).not.toHaveBeenCalled();
@@ -1773,12 +1757,12 @@ describe("ensureTunnelEdge", () => {
1773
1757
  const ws = makeWorkspace();
1774
1758
  mockNginxInstalled();
1775
1759
  mockNginxSpawn();
1776
- mockWebDistMissing();
1777
- isFeatureFlagEnabledMock.mockImplementation(async () => false);
1760
+ mockWebDistPresent();
1778
1761
  const pid = mockRunningEdge(ws, {
1779
1762
  listenPort: 7845,
1780
- includeWebApp: false,
1763
+ includeWebApp: true,
1781
1764
  gatewayPort: 7900,
1765
+ remoteWebConfigHash: spaConfigHash(),
1782
1766
  });
1783
1767
  mockUnkillableNginx(pid);
1784
1768
 
@@ -1799,12 +1783,12 @@ describe("ensureTunnelEdge", () => {
1799
1783
  const ws = makeWorkspace();
1800
1784
  mockNginxInstalled();
1801
1785
  mockNginxSpawn();
1802
- mockWebDistMissing();
1803
- isFeatureFlagEnabledMock.mockImplementation(async () => false);
1786
+ mockWebDistPresent();
1804
1787
  const pid = mockRunningEdge(ws, {
1805
1788
  listenPort: 7845,
1806
- includeWebApp: false,
1789
+ includeWebApp: true,
1807
1790
  gatewayPort: 7900,
1791
+ remoteWebConfigHash: spaConfigHash(),
1808
1792
  });
1809
1793
  const edge = mockKillableNginx(pid);
1810
1794
 
@@ -1817,26 +1801,19 @@ describe("ensureTunnelEdge", () => {
1817
1801
  expect(result).toEqual({
1818
1802
  port: REQUESTED_PORT,
1819
1803
  started: true,
1820
- includesWebApp: false,
1804
+ includesWebApp: true,
1821
1805
  });
1822
1806
  expect(edge.killed()).toBe(true);
1823
1807
  const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
1824
- expect(conf).toBe(
1825
- buildIngressNginxConfig({
1826
- gatewayPort: 7830,
1827
- listenPort: REQUESTED_PORT,
1828
- ipv6Loopback: hasIpv6Loopback(),
1829
- }),
1830
- );
1808
+ expect(conf).toContain("location ^~ /assistant/ {");
1831
1809
  });
1832
1810
 
1833
- test("restarts a mode-drifted edge into the flag-resolved mode", async () => {
1811
+ test("restarts a webhooks-only edge into the SPA mode", async () => {
1834
1812
  const ws = makeWorkspace();
1835
1813
  mockNginxInstalled();
1836
1814
  mockNginxSpawn();
1837
- mockWebDistMissing();
1838
- isFeatureFlagEnabledMock.mockImplementation(async () => false);
1839
- const pid = mockRunningEdge(ws, { listenPort: 7845, includeWebApp: true });
1815
+ mockWebDistPresent();
1816
+ const pid = mockRunningEdge(ws, { listenPort: 7845, includeWebApp: false });
1840
1817
  const edge = mockKillableNginx(pid);
1841
1818
 
1842
1819
  const result = await ensureTunnelEdge({
@@ -1848,20 +1825,14 @@ describe("ensureTunnelEdge", () => {
1848
1825
  expect(result).toEqual({
1849
1826
  port: REQUESTED_PORT,
1850
1827
  started: true,
1851
- includesWebApp: false,
1828
+ includesWebApp: true,
1852
1829
  });
1853
1830
  expect(edge.killed()).toBe(true);
1854
1831
  const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
1855
- expect(conf).toBe(
1856
- buildIngressNginxConfig({
1857
- gatewayPort: 7830,
1858
- listenPort: REQUESTED_PORT,
1859
- ipv6Loopback: hasIpv6Loopback(),
1860
- }),
1861
- );
1832
+ expect(conf).toContain("location ^~ /assistant/ {");
1862
1833
  });
1863
1834
 
1864
- test("flag enabled starts the SPA edge", async () => {
1835
+ test("starts the SPA edge", async () => {
1865
1836
  const ws = makeWorkspace();
1866
1837
  mockNginxInstalled();
1867
1838
  mockNginxSpawn();
@@ -1878,11 +1849,6 @@ describe("ensureTunnelEdge", () => {
1878
1849
  started: true,
1879
1850
  includesWebApp: true,
1880
1851
  });
1881
- expect(isFeatureFlagEnabledMock).toHaveBeenCalledWith(
1882
- ASSISTANT_ID,
1883
- featureFlags.WEB_REMOTE_INGRESS_FLAG,
1884
- { runtimeUrl: "http://127.0.0.1:7830" },
1885
- );
1886
1852
  const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
1887
1853
  expect(conf).toContain("location ^~ /assistant/ {");
1888
1854
  expect(conf).toContain("location ^~ /webhooks/ {");
@@ -1894,40 +1860,11 @@ describe("ensureTunnelEdge", () => {
1894
1860
  });
1895
1861
  });
1896
1862
 
1897
- test("flag disabled starts the webhooks-only edge", async () => {
1898
- const ws = makeWorkspace();
1899
- mockNginxInstalled();
1900
- mockNginxSpawn();
1901
- mockWebDistMissing();
1902
- isFeatureFlagEnabledMock.mockImplementation(async () => false);
1903
-
1904
- const result = await ensureTunnelEdge({
1905
- assistantId: ASSISTANT_ID,
1906
- workspaceDir: ws,
1907
- gatewayPort: 7830,
1908
- });
1909
-
1910
- expect(result).toEqual({
1911
- port: REQUESTED_PORT,
1912
- started: true,
1913
- includesWebApp: false,
1914
- });
1915
- const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
1916
- expect(conf).toContain("location = /v1/pair { return 404; }");
1917
- expect(conf).not.toContain("location ^~ /assistant/ {");
1918
- expect((readConfig(ws).ingress as Record<string, unknown>).nginx).toEqual({
1919
- listenPort: REQUESTED_PORT,
1920
- includeWebApp: false,
1921
- gatewayPort: 7830,
1922
- });
1923
- });
1924
-
1925
1863
  test("forwards onStarting so callers can print progress", async () => {
1926
1864
  const ws = makeWorkspace();
1927
1865
  mockNginxInstalled();
1928
1866
  mockNginxSpawn();
1929
- mockWebDistMissing();
1930
- isFeatureFlagEnabledMock.mockImplementation(async () => false);
1867
+ mockWebDistPresent();
1931
1868
  const onStarting = mock(
1932
1869
  (_info: {
1933
1870
  version: string;
@@ -1943,18 +1880,18 @@ describe("ensureTunnelEdge", () => {
1943
1880
  onStarting,
1944
1881
  });
1945
1882
 
1946
- expect(onStarting).toHaveBeenCalledWith({
1947
- version: NGINX_VERSION,
1948
- webDistDir: null,
1949
- listenPort: REQUESTED_PORT,
1950
- });
1883
+ expect(onStarting).toHaveBeenCalledTimes(1);
1884
+ const info = onStarting.mock.calls[0][0];
1885
+ expect(info.version).toBe(NGINX_VERSION);
1886
+ expect(info.listenPort).toBe(REQUESTED_PORT);
1887
+ expect(typeof info.webDistDir).toBe("string");
1951
1888
  });
1952
1889
 
1953
- test("an entry without an assistant id gets the webhooks-only edge", async () => {
1890
+ test("an entry without an assistant id still gets the SPA edge", async () => {
1954
1891
  const ws = makeWorkspace();
1955
1892
  mockNginxInstalled();
1956
1893
  mockNginxSpawn();
1957
- mockWebDistMissing();
1894
+ mockWebDistPresent();
1958
1895
 
1959
1896
  const result = await ensureTunnelEdge({
1960
1897
  assistantId: undefined,
@@ -1965,11 +1902,10 @@ describe("ensureTunnelEdge", () => {
1965
1902
  expect(result).toEqual({
1966
1903
  port: REQUESTED_PORT,
1967
1904
  started: true,
1968
- includesWebApp: false,
1905
+ includesWebApp: true,
1969
1906
  });
1970
- expect(isFeatureFlagEnabledMock).not.toHaveBeenCalled();
1971
1907
  const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
1972
- expect(conf).not.toContain("location ^~ /assistant/ {");
1908
+ expect(conf).toContain("location ^~ /assistant/ {");
1973
1909
  });
1974
1910
 
1975
1911
  test("missing nginx throws with install instructions", async () => {
@@ -1988,97 +1924,7 @@ describe("ensureTunnelEdge", () => {
1988
1924
  expect(spawnMock).not.toHaveBeenCalled();
1989
1925
  });
1990
1926
 
1991
- test("flag lookup failure throws the wake hint", async () => {
1992
- const ws = makeWorkspace();
1993
- mockNginxInstalled();
1994
- isFeatureFlagEnabledMock.mockImplementation(async () => {
1995
- throw new Error("connect ECONNREFUSED");
1996
- });
1997
-
1998
- const promise = ensureTunnelEdge({
1999
- assistantId: ASSISTANT_ID,
2000
- workspaceDir: ws,
2001
- gatewayPort: 7830,
2002
- });
2003
-
2004
- await expect(promise).rejects.toThrow(
2005
- "Could not verify the `web-remote-ingress` feature flag",
2006
- );
2007
- await expect(promise).rejects.toThrow("Try `vellum wake` and retry");
2008
- await expect(promise).rejects.toThrow("connect ECONNREFUSED");
2009
- expect(spawnMock).not.toHaveBeenCalled();
2010
- });
2011
-
2012
- test("flagRetry retries a thrown flag lookup and then starts the edge", async () => {
2013
- const ws = makeWorkspace();
2014
- mockNginxInstalled();
2015
- mockNginxSpawn();
2016
- mockWebDistMissing();
2017
- isFeatureFlagEnabledMock
2018
- .mockImplementationOnce(async () => {
2019
- throw new Error('HTTP 503 {"status":"starting"}');
2020
- })
2021
- .mockImplementationOnce(async () => {
2022
- throw new Error('HTTP 503 {"status":"starting"}');
2023
- })
2024
- .mockImplementationOnce(async () => false);
2025
-
2026
- const result = await ensureTunnelEdge({
2027
- assistantId: ASSISTANT_ID,
2028
- workspaceDir: ws,
2029
- gatewayPort: 7830,
2030
- flagRetry: { attempts: 3, intervalMs: 1 },
2031
- });
2032
-
2033
- expect(isFeatureFlagEnabledMock).toHaveBeenCalledTimes(3);
2034
- expect(result).toEqual({
2035
- port: REQUESTED_PORT,
2036
- started: true,
2037
- includesWebApp: false,
2038
- });
2039
- });
2040
-
2041
- test("flagRetry does not retry a resolved false: it is a real answer", async () => {
2042
- const ws = makeWorkspace();
2043
- mockNginxInstalled();
2044
- mockNginxSpawn();
2045
- mockWebDistMissing();
2046
- isFeatureFlagEnabledMock.mockImplementation(async () => false);
2047
-
2048
- const result = await ensureTunnelEdge({
2049
- assistantId: ASSISTANT_ID,
2050
- workspaceDir: ws,
2051
- gatewayPort: 7830,
2052
- flagRetry: { attempts: 3, intervalMs: 1 },
2053
- });
2054
-
2055
- expect(isFeatureFlagEnabledMock).toHaveBeenCalledTimes(1);
2056
- expect(result.includesWebApp).toBe(false);
2057
- });
2058
-
2059
- test("an exhausted flagRetry throws the wake hint with the last error", async () => {
2060
- const ws = makeWorkspace();
2061
- mockNginxInstalled();
2062
- isFeatureFlagEnabledMock.mockImplementation(async () => {
2063
- throw new Error("connect ECONNREFUSED");
2064
- });
2065
-
2066
- const promise = ensureTunnelEdge({
2067
- assistantId: ASSISTANT_ID,
2068
- workspaceDir: ws,
2069
- gatewayPort: 7830,
2070
- flagRetry: { attempts: 3, intervalMs: 1 },
2071
- });
2072
-
2073
- await expect(promise).rejects.toThrow(
2074
- "Could not verify the `web-remote-ingress` feature flag",
2075
- );
2076
- await expect(promise).rejects.toThrow("connect ECONNREFUSED");
2077
- expect(isFeatureFlagEnabledMock).toHaveBeenCalledTimes(3);
2078
- expect(spawnMock).not.toHaveBeenCalled();
2079
- });
2080
-
2081
- test("missing web dist with the flag enabled throws build guidance", async () => {
1927
+ test("missing web dist throws build guidance", async () => {
2082
1928
  const ws = makeWorkspace();
2083
1929
  mockNginxInstalled();
2084
1930
  mockWebDistMissing();
@@ -2096,8 +1942,7 @@ describe("ensureTunnelEdge", () => {
2096
1942
  const ws = makeWorkspace();
2097
1943
  mockNginxInstalled();
2098
1944
  mockNginxSpawn();
2099
- mockWebDistMissing();
2100
- isFeatureFlagEnabledMock.mockImplementation(async () => false);
1945
+ mockWebDistPresent();
2101
1946
  waitForDaemonReadyMock.mockImplementation(async () => false);
2102
1947
 
2103
1948
  await expect(
@@ -2113,8 +1958,7 @@ describe("ensureTunnelEdge", () => {
2113
1958
  const ws = makeWorkspace();
2114
1959
  mockNginxInstalled();
2115
1960
  mockNginxSpawnExitsOnStartup();
2116
- mockWebDistMissing();
2117
- isFeatureFlagEnabledMock.mockImplementation(async () => false);
1961
+ mockWebDistPresent();
2118
1962
  waitForDaemonReadyMock.mockImplementation(async () => true);
2119
1963
 
2120
1964
  const promise = ensureTunnelEdge({