@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.
- package/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +33 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +3 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-lock.test.ts +165 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-lock.ts +156 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +249 -8
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +186 -68
- package/node_modules/@vellumai/local-mode/src/unpair.ts +18 -0
- package/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
- package/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +35 -0
- package/src/__tests__/nginx-ingress-command.test.ts +4 -23
- package/src/__tests__/nginx-ingress.test.ts +59 -215
- package/src/__tests__/pair.test.ts +11 -197
- package/src/__tests__/retire-archive.test.ts +13 -1
- package/src/__tests__/retire-local.test.ts +58 -4
- package/src/__tests__/tunnel.test.ts +0 -28
- package/src/__tests__/wake.test.ts +91 -69
- package/src/__tests__/windows-lifecycle.test.ts +157 -0
- package/src/commands/client.ts +9 -31
- package/src/commands/nginx-ingress.ts +0 -15
- package/src/commands/pair.ts +0 -39
- package/src/commands/wake.ts +39 -12
- package/src/lib/__tests__/web-dist.test.ts +86 -0
- package/src/lib/assistant-config.ts +64 -27
- package/src/lib/local.ts +60 -20
- package/src/lib/nginx-ingress.ts +35 -108
- package/src/lib/orphan-detection.test.ts +3 -0
- package/src/lib/orphan-detection.ts +33 -11
- package/src/lib/pgrep.ts +20 -2
- package/src/lib/process.ts +191 -18
- package/src/lib/retire-archive.ts +38 -8
- package/src/lib/retire-local.ts +75 -9
- package/src/lib/tunnel-edge.ts +14 -22
- package/src/lib/web-dist.ts +48 -0
- package/src/lib/feature-flags.test.ts +0 -157
- 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
|
@@ -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
|
|
123
|
+
test("starts the edge and states the remote web mode", async () => {
|
|
124
124
|
ensureTunnelEdgeMock.mockResolvedValue({
|
|
125
|
-
port:
|
|
125
|
+
port: 7840,
|
|
126
126
|
started: true,
|
|
127
|
-
includesWebApp:
|
|
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).
|
|
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:
|
|
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
|
|
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
|
-
|
|
1755
|
-
|
|
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
|
|
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
|
-
|
|
1777
|
-
isFeatureFlagEnabledMock.mockImplementation(async () => false);
|
|
1760
|
+
mockWebDistPresent();
|
|
1778
1761
|
const pid = mockRunningEdge(ws, {
|
|
1779
1762
|
listenPort: 7845,
|
|
1780
|
-
includeWebApp:
|
|
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
|
-
|
|
1803
|
-
isFeatureFlagEnabledMock.mockImplementation(async () => false);
|
|
1786
|
+
mockWebDistPresent();
|
|
1804
1787
|
const pid = mockRunningEdge(ws, {
|
|
1805
1788
|
listenPort: 7845,
|
|
1806
|
-
includeWebApp:
|
|
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:
|
|
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).
|
|
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
|
|
1811
|
+
test("restarts a webhooks-only edge into the SPA mode", async () => {
|
|
1834
1812
|
const ws = makeWorkspace();
|
|
1835
1813
|
mockNginxInstalled();
|
|
1836
1814
|
mockNginxSpawn();
|
|
1837
|
-
|
|
1838
|
-
|
|
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:
|
|
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).
|
|
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("
|
|
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
|
-
|
|
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).
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
1905
|
+
includesWebApp: true,
|
|
1969
1906
|
});
|
|
1970
|
-
expect(isFeatureFlagEnabledMock).not.toHaveBeenCalled();
|
|
1971
1907
|
const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
|
|
1972
|
-
expect(conf).
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
2117
|
-
isFeatureFlagEnabledMock.mockImplementation(async () => false);
|
|
1961
|
+
mockWebDistPresent();
|
|
2118
1962
|
waitForDaemonReadyMock.mockImplementation(async () => true);
|
|
2119
1963
|
|
|
2120
1964
|
const promise = ensureTunnelEdge({
|