@vellumai/cli 0.8.12-staging.2 → 0.9.0-dev.202606162156.4bad3e5
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/README.md +1 -1
- package/bun.lock +49 -56
- package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +224 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +19 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +8 -1
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +0 -15
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +8 -4
- package/node_modules/@vellumai/local-mode/src/sleep.ts +80 -0
- package/node_modules/@vellumai/local-mode/src/status.ts +342 -0
- package/node_modules/@vellumai/local-mode/src/wake.ts +12 -1
- package/package.json +3 -3
- package/src/__tests__/assistant-config.test.ts +1 -2
- package/src/__tests__/device-id.test.ts +6 -14
- package/src/__tests__/helpers/os-mock.ts +27 -0
- package/src/__tests__/login-loopback.test.ts +71 -0
- package/src/__tests__/multi-local.test.ts +2 -10
- package/src/__tests__/nginx-ingress-command.test.ts +69 -0
- package/src/__tests__/nginx-ingress.test.ts +403 -0
- package/src/__tests__/sleep.test.ts +4 -0
- package/src/__tests__/teleport.test.ts +6 -9
- package/src/__tests__/tunnel.test.ts +164 -0
- package/src/__tests__/wake.test.ts +15 -4
- package/src/__tests__/workos-pkce.test.ts +314 -0
- package/src/commands/flags.ts +1 -22
- package/src/commands/hatch.ts +90 -9
- package/src/commands/login.ts +123 -59
- package/src/commands/nginx-ingress.ts +291 -0
- package/src/commands/rollback.ts +0 -6
- package/src/commands/sleep.ts +17 -0
- package/src/commands/teleport.ts +23 -36
- package/src/commands/tunnel.ts +69 -11
- package/src/commands/upgrade.ts +0 -2
- package/src/commands/wake.ts +7 -5
- package/src/commands/workflows.ts +301 -0
- package/src/index.ts +8 -0
- package/src/lib/arg-utils.ts +48 -0
- package/src/lib/assistant-client.ts +2 -0
- package/src/lib/assistant-config.ts +0 -7
- package/src/lib/cloudflare-tunnel.ts +15 -2
- package/src/lib/docker.ts +103 -49
- package/src/lib/feature-flags.test.ts +157 -0
- package/src/lib/feature-flags.ts +38 -0
- package/src/lib/hatch-local.ts +0 -1
- package/src/lib/local.ts +5 -0
- package/src/lib/nginx-ingress.ts +576 -0
- package/src/lib/ngrok.ts +26 -4
- package/src/lib/platform-client.ts +0 -1
- package/src/lib/retire-local.ts +5 -0
- package/src/lib/statefulset.ts +73 -21
- package/src/lib/sync-cloud-assistants.ts +4 -17
- package/src/lib/upgrade-lifecycle.ts +1 -2
- package/src/lib/workos-pkce.ts +160 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { startLoopbackListener } from "../commands/login.js";
|
|
4
|
+
|
|
5
|
+
/** Resolve "settled"/"pending" — proves whether `waitForCode` resolved. */
|
|
6
|
+
async function settleState(p: Promise<unknown>): Promise<"settled" | "pending"> {
|
|
7
|
+
return Promise.race([
|
|
8
|
+
p.then(
|
|
9
|
+
() => "settled" as const,
|
|
10
|
+
() => "settled" as const,
|
|
11
|
+
),
|
|
12
|
+
new Promise<"pending">((r) => setTimeout(() => r("pending"), 50)),
|
|
13
|
+
]);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("startLoopbackListener", () => {
|
|
17
|
+
test("rejects a state-mismatched callback (CSRF) without settling", async () => {
|
|
18
|
+
const listener = await startLoopbackListener("expected-state");
|
|
19
|
+
try {
|
|
20
|
+
// Wrong state — the load-bearing CSRF check. Any local process can
|
|
21
|
+
// hit the loopback port, so a mismatched state must NOT deliver a code.
|
|
22
|
+
const res = await fetch(`${listener.redirectUri}?code=evil&state=wrong`);
|
|
23
|
+
expect(res.status).toBe(404);
|
|
24
|
+
expect(await settleState(listener.waitForCode)).toBe("pending");
|
|
25
|
+
|
|
26
|
+
// Wrong path on the right port is also ignored.
|
|
27
|
+
const noise = await fetch(
|
|
28
|
+
`${listener.redirectUri.replace("/auth/callback", "/evil")}?state=expected-state&code=c`,
|
|
29
|
+
);
|
|
30
|
+
expect(noise.status).toBe(404);
|
|
31
|
+
expect(await settleState(listener.waitForCode)).toBe("pending");
|
|
32
|
+
|
|
33
|
+
// A state-matched callback then settles it — the listener kept
|
|
34
|
+
// listening through the noise above.
|
|
35
|
+
const ok = await fetch(
|
|
36
|
+
`${listener.redirectUri}?code=good-code&state=expected-state`,
|
|
37
|
+
);
|
|
38
|
+
expect(ok.status).toBe(200);
|
|
39
|
+
expect(await listener.waitForCode).toBe("good-code");
|
|
40
|
+
} finally {
|
|
41
|
+
listener.close();
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("rejects on an error callback with the matching state", async () => {
|
|
46
|
+
const listener = await startLoopbackListener("st");
|
|
47
|
+
try {
|
|
48
|
+
const settled = listener.waitForCode.then(
|
|
49
|
+
() => null,
|
|
50
|
+
(e: Error) => e,
|
|
51
|
+
);
|
|
52
|
+
const res = await fetch(`${listener.redirectUri}?error=access_denied&state=st`);
|
|
53
|
+
expect(res.status).toBe(400);
|
|
54
|
+
const err = await settled;
|
|
55
|
+
expect(err?.message).toMatch(/access_denied/);
|
|
56
|
+
} finally {
|
|
57
|
+
listener.close();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("close rejects a pending waiter with the given reason", async () => {
|
|
62
|
+
const listener = await startLoopbackListener("st");
|
|
63
|
+
const settled = listener.waitForCode.then(
|
|
64
|
+
() => null,
|
|
65
|
+
(e: Error) => e,
|
|
66
|
+
);
|
|
67
|
+
listener.close("Login timed out. Please try again.");
|
|
68
|
+
const err = await settled;
|
|
69
|
+
expect(err?.message).toMatch(/timed out/);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -10,16 +10,7 @@ process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
|
10
10
|
|
|
11
11
|
// Mock homedir() to return testDir — this isolates allocateLocalResources()
|
|
12
12
|
// which uses homedir() directly for instance directory creation.
|
|
13
|
-
|
|
14
|
-
mock.module("node:os", () => ({
|
|
15
|
-
...realOs,
|
|
16
|
-
homedir: () => testDir,
|
|
17
|
-
}));
|
|
18
|
-
// Also mock the bare "os" specifier since assistant-config.ts uses `from "os"`
|
|
19
|
-
mock.module("os", () => ({
|
|
20
|
-
...realOs,
|
|
21
|
-
homedir: () => testDir,
|
|
22
|
-
}));
|
|
13
|
+
await mockOsHomedir(() => () => testDir);
|
|
23
14
|
|
|
24
15
|
// Mock probePort so we control which ports appear in-use without touching the network
|
|
25
16
|
const probePortMock = mock<(port: number, host?: string) => Promise<boolean>>(
|
|
@@ -43,6 +34,7 @@ import {
|
|
|
43
34
|
DEFAULT_GATEWAY_PORT,
|
|
44
35
|
DEFAULT_QDRANT_PORT,
|
|
45
36
|
} from "../lib/constants.js";
|
|
37
|
+
import { mockOsHomedir } from "./helpers/os-mock.js";
|
|
46
38
|
|
|
47
39
|
afterAll(() => {
|
|
48
40
|
rmSync(testDir, { recursive: true, force: true });
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { resolveNginxIngressTarget } from "../commands/nginx-ingress.js";
|
|
7
|
+
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
8
|
+
|
|
9
|
+
const testDir = mkdtempSync(join(tmpdir(), "cli-nginx-ingress-command-test-"));
|
|
10
|
+
const workspaceDir = join(testDir, "workspace");
|
|
11
|
+
const originalLockfileDir = process.env.VELLUM_LOCKFILE_DIR;
|
|
12
|
+
const originalWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR;
|
|
13
|
+
|
|
14
|
+
function writeLockfile(
|
|
15
|
+
entries: AssistantEntry[],
|
|
16
|
+
activeAssistant?: string,
|
|
17
|
+
): void {
|
|
18
|
+
mkdirSync(testDir, { recursive: true });
|
|
19
|
+
writeFileSync(
|
|
20
|
+
join(testDir, ".vellum.lock.json"),
|
|
21
|
+
JSON.stringify(
|
|
22
|
+
{
|
|
23
|
+
assistants: entries,
|
|
24
|
+
...(activeAssistant ? { activeAssistant } : {}),
|
|
25
|
+
},
|
|
26
|
+
null,
|
|
27
|
+
2,
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("resolveNginxIngressTarget", () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
35
|
+
process.env.VELLUM_WORKSPACE_DIR = workspaceDir;
|
|
36
|
+
rmSync(join(testDir, ".vellum.lock.json"), { force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(() => {
|
|
40
|
+
if (originalLockfileDir === undefined) {
|
|
41
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
42
|
+
} else {
|
|
43
|
+
process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
|
|
44
|
+
}
|
|
45
|
+
if (originalWorkspaceDir === undefined) {
|
|
46
|
+
delete process.env.VELLUM_WORKSPACE_DIR;
|
|
47
|
+
} else {
|
|
48
|
+
process.env.VELLUM_WORKSPACE_DIR = originalWorkspaceDir;
|
|
49
|
+
}
|
|
50
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("derives the gateway port from runtimeUrl when resources are absent", () => {
|
|
54
|
+
writeLockfile([
|
|
55
|
+
{
|
|
56
|
+
assistantId: "docker-assistant",
|
|
57
|
+
name: "Docker Assistant",
|
|
58
|
+
runtimeUrl: "http://localhost:9123",
|
|
59
|
+
cloud: "docker",
|
|
60
|
+
},
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
expect(resolveNginxIngressTarget("Docker Assistant")).toEqual({
|
|
64
|
+
assistantId: "docker-assistant",
|
|
65
|
+
workspaceDir,
|
|
66
|
+
gatewayPort: 9123,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import * as childProcess from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
14
|
+
|
|
15
|
+
const execFileSyncMock = mock(childProcess.execFileSync);
|
|
16
|
+
|
|
17
|
+
mock.module("node:child_process", () => ({
|
|
18
|
+
...childProcess,
|
|
19
|
+
execFileSync: execFileSyncMock,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
buildIngressNginxConfig,
|
|
24
|
+
buildRemoteWebIndexHtml,
|
|
25
|
+
resolveTunnelTargetPort,
|
|
26
|
+
stopIngressNginx,
|
|
27
|
+
} from "../lib/nginx-ingress.js";
|
|
28
|
+
|
|
29
|
+
const originalKill = process.kill;
|
|
30
|
+
|
|
31
|
+
describe("buildIngressNginxConfig", () => {
|
|
32
|
+
const conf = buildIngressNginxConfig({ gatewayPort: 7830, listenPort: 7840 });
|
|
33
|
+
const remoteConf = buildIngressNginxConfig({
|
|
34
|
+
gatewayPort: 7830,
|
|
35
|
+
listenPort: 7840,
|
|
36
|
+
remoteWebIngress: {
|
|
37
|
+
webDistDir: "/tmp/vellum web/dist",
|
|
38
|
+
config: {
|
|
39
|
+
mode: "remote-gateway",
|
|
40
|
+
apiBaseUrl: "/v1",
|
|
41
|
+
platformDisabled: true,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("listens on loopback only", () => {
|
|
47
|
+
expect(conf).toContain("listen 127.0.0.1:7840;");
|
|
48
|
+
const listens = conf.match(/listen [^;]+;/g) ?? [];
|
|
49
|
+
expect(listens.length).toBeGreaterThan(0);
|
|
50
|
+
for (const directive of listens) {
|
|
51
|
+
expect(directive).toContain("127.0.0.1");
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("proxies requests to the gateway", () => {
|
|
56
|
+
expect(conf).toContain("location / {");
|
|
57
|
+
expect(conf).toContain("proxy_pass http://127.0.0.1:7830;");
|
|
58
|
+
expect(conf).toContain('proxy_set_header X-Vellum-Edge-Forwarded "1";');
|
|
59
|
+
expect(conf).not.toContain("return 404;");
|
|
60
|
+
expect(conf).not.toContain("return 403;");
|
|
61
|
+
expect(conf).not.toContain("location =");
|
|
62
|
+
expect(conf).not.toContain("location ~");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("declares static MIME types needed by the SPA", () => {
|
|
66
|
+
expect(remoteConf).toContain("default_type application/octet-stream;");
|
|
67
|
+
expect(remoteConf).toContain("types {");
|
|
68
|
+
expect(remoteConf).toContain("application/javascript js mjs;");
|
|
69
|
+
expect(remoteConf).toContain("text/css css;");
|
|
70
|
+
expect(remoteConf).toContain("text/html html htm;");
|
|
71
|
+
expect(remoteConf).toContain("font/woff2 woff2;");
|
|
72
|
+
expect(remoteConf).toContain("image/svg+xml svg svgz;");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("serves the remote web SPA from /assistant when configured", () => {
|
|
76
|
+
expect(remoteConf).toContain("location = / {");
|
|
77
|
+
expect(remoteConf).toContain("return 302 /assistant/;");
|
|
78
|
+
expect(remoteConf.indexOf("location = / {")).toBeLessThan(
|
|
79
|
+
remoteConf.indexOf("location / {"),
|
|
80
|
+
);
|
|
81
|
+
expect(remoteConf).toContain("location = /assistant {");
|
|
82
|
+
expect(remoteConf).toContain("return 302 /assistant/;");
|
|
83
|
+
expect(remoteConf).toContain("location ^~ /assistant/assets/ {");
|
|
84
|
+
expect(remoteConf).toContain('alias "/tmp/vellum web/dist/assets/";');
|
|
85
|
+
expect(remoteConf).toContain("try_files $uri =404;");
|
|
86
|
+
expect(remoteConf).toContain("location = /assistant/ {");
|
|
87
|
+
expect(remoteConf).toContain(
|
|
88
|
+
"rewrite ^ /assistant/__remote-index.html last;",
|
|
89
|
+
);
|
|
90
|
+
expect(remoteConf).toContain("location = /assistant/index.html {");
|
|
91
|
+
expect(remoteConf).toContain("location = /assistant/__remote-index.html {");
|
|
92
|
+
expect(remoteConf).toContain("internal;");
|
|
93
|
+
expect(remoteConf).toContain('alias "/tmp/vellum web/dist/index.html";');
|
|
94
|
+
expect(remoteConf).toContain("location ^~ /assistant/ {");
|
|
95
|
+
expect(remoteConf).toContain('alias "/tmp/vellum web/dist/";');
|
|
96
|
+
expect(remoteConf).toContain(
|
|
97
|
+
"try_files $uri $uri/ /assistant/__remote-index.html;",
|
|
98
|
+
);
|
|
99
|
+
expect(remoteConf).toContain("location / {\n return 404;\n }");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("serves remote web config for the SPA", () => {
|
|
103
|
+
expect(remoteConf).toContain("location = /assistant/__config {");
|
|
104
|
+
expect(remoteConf).toContain("default_type application/json;");
|
|
105
|
+
expect(remoteConf).toContain('add_header Cache-Control "no-store";');
|
|
106
|
+
expect(remoteConf).toContain(
|
|
107
|
+
'return 200 "{\\"mode\\":\\"remote-gateway\\",\\"apiBaseUrl\\":\\"/v1\\",\\"platformDisabled\\":true,\\"disablePlatform\\":true}";',
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("proxies health and public API traffic to the gateway in remote web mode", () => {
|
|
112
|
+
expect(remoteConf).toContain("location = /healthz {");
|
|
113
|
+
expect(remoteConf).toContain("location ^~ /v1/ {");
|
|
114
|
+
expect(remoteConf).toContain("proxy_pass http://127.0.0.1:7830;");
|
|
115
|
+
expect(remoteConf).toContain("proxy_request_buffering off;");
|
|
116
|
+
expect(remoteConf).toContain("proxy_buffering off;");
|
|
117
|
+
expect(remoteConf).toContain(
|
|
118
|
+
'proxy_set_header X-Vellum-Edge-Forwarded "1";',
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("blocks local-only bootstrap helpers before generic API proxying", () => {
|
|
123
|
+
const deniedLocations = [
|
|
124
|
+
"location = /auth/token { return 404; }",
|
|
125
|
+
"location = /auth/token/ { return 404; }",
|
|
126
|
+
"location = /v1/pair { return 404; }",
|
|
127
|
+
"location = /v1/pair/ { return 404; }",
|
|
128
|
+
"location = /v1/pair/web-init { return 404; }",
|
|
129
|
+
"location = /v1/pair/web-init/ { return 404; }",
|
|
130
|
+
"location = /v1/remote-web/pairing-challenge { return 404; }",
|
|
131
|
+
"location = /v1/remote-web/pairing-challenge/ { return 404; }",
|
|
132
|
+
"location = /v1/devices { return 404; }",
|
|
133
|
+
"location = /v1/devices/ { return 404; }",
|
|
134
|
+
"location = /v1/devices/revoke { return 404; }",
|
|
135
|
+
"location = /v1/devices/revoke/ { return 404; }",
|
|
136
|
+
"location = /v1/guardian/init { return 404; }",
|
|
137
|
+
"location = /v1/guardian/init/ { return 404; }",
|
|
138
|
+
"location = /v1/guardian/reset-bootstrap { return 404; }",
|
|
139
|
+
"location = /v1/guardian/reset-bootstrap/ { return 404; }",
|
|
140
|
+
"location ^~ /assistant/__local/ { return 404; }",
|
|
141
|
+
"location ^~ /assistant/__gateway/ { return 404; }",
|
|
142
|
+
];
|
|
143
|
+
for (const location of deniedLocations) {
|
|
144
|
+
expect(remoteConf).toContain(location);
|
|
145
|
+
expect(remoteConf.indexOf(location)).toBeLessThan(
|
|
146
|
+
remoteConf.indexOf("location ^~ /v1/ {"),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("supports websockets and SSE streaming", () => {
|
|
152
|
+
expect(conf).toContain("map $http_upgrade $connection_upgrade");
|
|
153
|
+
expect(conf).toContain("proxy_http_version 1.1;");
|
|
154
|
+
expect(conf).toContain("proxy_set_header Upgrade $http_upgrade;");
|
|
155
|
+
expect(conf).toContain("proxy_set_header Connection $connection_upgrade;");
|
|
156
|
+
expect(conf).toContain("proxy_request_buffering off;");
|
|
157
|
+
expect(conf).toContain("proxy_buffering off;");
|
|
158
|
+
expect(conf).toContain("proxy_read_timeout 1h;");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("buildRemoteWebIndexHtml", () => {
|
|
163
|
+
test("injects the remote gateway config after any bundled local config", () => {
|
|
164
|
+
const html =
|
|
165
|
+
'<html><head><script>window.__VELLUM_CONFIG__={"webUrl":"https://www.vellum.ai"}</script></head><body></body></html>';
|
|
166
|
+
const result = buildRemoteWebIndexHtml(html, {
|
|
167
|
+
mode: "remote-gateway",
|
|
168
|
+
apiBaseUrl: "/v1",
|
|
169
|
+
disablePlatform: true,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(result).toContain(
|
|
173
|
+
'window.__VELLUM_CONFIG__={"webUrl":"https://www.vellum.ai"}',
|
|
174
|
+
);
|
|
175
|
+
expect(result).toContain(
|
|
176
|
+
'window.__VELLUM_CONFIG__={"mode":"remote-gateway","apiBaseUrl":"/v1","disablePlatform":true}',
|
|
177
|
+
);
|
|
178
|
+
expect(result.indexOf('"webUrl"')).toBeLessThan(
|
|
179
|
+
result.indexOf('"remote-gateway"'),
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("escapes config JSON before embedding it in a script tag", () => {
|
|
184
|
+
const result = buildRemoteWebIndexHtml("</head>", {
|
|
185
|
+
value: "</script><script>alert(1)</script>",
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(result).not.toContain("</script><script>alert(1)</script>");
|
|
189
|
+
expect(result).toContain("\\u003c/script\\u003e");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("nginx ingress process state", () => {
|
|
194
|
+
const workspaces: string[] = [];
|
|
195
|
+
|
|
196
|
+
afterEach(() => {
|
|
197
|
+
process.kill = originalKill;
|
|
198
|
+
execFileSyncMock.mockReset();
|
|
199
|
+
for (const dir of workspaces.splice(0)) {
|
|
200
|
+
rmSync(dir, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
function makeWorkspace(): string {
|
|
205
|
+
const dir = mkdtempSync(join(tmpdir(), "vellum-ingress-test-"));
|
|
206
|
+
workspaces.push(dir);
|
|
207
|
+
return dir;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function writeIngressState(workspaceDir: string, listenPort: number): void {
|
|
211
|
+
writeFileSync(
|
|
212
|
+
join(workspaceDir, "config.json"),
|
|
213
|
+
JSON.stringify({ ingress: { nginx: { listenPort } } }) + "\n",
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function writePidFile(workspaceDir: string, pid: number): void {
|
|
218
|
+
const dir = join(workspaceDir, "data", "ingress");
|
|
219
|
+
mkdirSync(dir, { recursive: true });
|
|
220
|
+
writeFileSync(join(dir, "nginx.pid"), `${pid}\n`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function readConfig(workspaceDir: string): Record<string, unknown> {
|
|
224
|
+
return JSON.parse(
|
|
225
|
+
readFileSync(join(workspaceDir, "config.json"), "utf-8"),
|
|
226
|
+
) as Record<string, unknown>;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function pidPath(workspaceDir: string): string {
|
|
230
|
+
return join(workspaceDir, "data", "ingress", "nginx.pid");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function nginxCommand(workspaceDir: string): string {
|
|
234
|
+
const dir = join(workspaceDir, "data", "ingress");
|
|
235
|
+
return `nginx: master process nginx -p ${dir} -c ${join(dir, "nginx.conf")} -g daemon off;`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A PID guaranteed dead: a short-lived child that has already exited. */
|
|
239
|
+
function deadPid(): number {
|
|
240
|
+
const result = childProcess.spawnSync("sh", ["-c", "exit 0"]);
|
|
241
|
+
if (!result.pid) throw new Error("failed to spawn probe process");
|
|
242
|
+
return result.pid;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
test("falls back to the gateway port when no ingress state exists", () => {
|
|
246
|
+
const ws = makeWorkspace();
|
|
247
|
+
expect(resolveTunnelTargetPort(ws, 7830)).toEqual({
|
|
248
|
+
port: 7830,
|
|
249
|
+
viaIngress: false,
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("falls back when ingress state exists but the process is dead", () => {
|
|
254
|
+
const ws = makeWorkspace();
|
|
255
|
+
writeIngressState(ws, 7841);
|
|
256
|
+
writePidFile(ws, deadPid());
|
|
257
|
+
expect(resolveTunnelTargetPort(ws, 7830)).toEqual({
|
|
258
|
+
port: 7830,
|
|
259
|
+
viaIngress: false,
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("falls back when the recorded PID belongs to a non-nginx process", () => {
|
|
264
|
+
const ws = makeWorkspace();
|
|
265
|
+
writeIngressState(ws, 7841);
|
|
266
|
+
writePidFile(ws, process.pid);
|
|
267
|
+
execFileSyncMock.mockReturnValue("bun test");
|
|
268
|
+
expect(resolveTunnelTargetPort(ws, 7830)).toEqual({
|
|
269
|
+
port: 7830,
|
|
270
|
+
viaIngress: false,
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("falls back when the recorded PID belongs to another nginx instance", () => {
|
|
275
|
+
const ws = makeWorkspace();
|
|
276
|
+
writeIngressState(ws, 7841);
|
|
277
|
+
writePidFile(ws, process.pid);
|
|
278
|
+
execFileSyncMock.mockReturnValue(
|
|
279
|
+
"nginx: master process nginx -p /tmp/other-ingress -c /tmp/other-ingress/nginx.conf",
|
|
280
|
+
);
|
|
281
|
+
expect(resolveTunnelTargetPort(ws, 7830)).toEqual({
|
|
282
|
+
port: 7830,
|
|
283
|
+
viaIngress: false,
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("targets the ingress when state exists and the PID is this nginx", () => {
|
|
288
|
+
const ws = makeWorkspace();
|
|
289
|
+
writeIngressState(ws, 7841);
|
|
290
|
+
writePidFile(ws, process.pid);
|
|
291
|
+
execFileSyncMock.mockReturnValue(nginxCommand(ws));
|
|
292
|
+
expect(resolveTunnelTargetPort(ws, 7830)).toEqual({
|
|
293
|
+
port: 7841,
|
|
294
|
+
viaIngress: true,
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("falls back when nginx ingress is not preferred", () => {
|
|
299
|
+
const ws = makeWorkspace();
|
|
300
|
+
writeIngressState(ws, 7841);
|
|
301
|
+
writePidFile(ws, process.pid);
|
|
302
|
+
execFileSyncMock.mockReturnValue(nginxCommand(ws));
|
|
303
|
+
expect(
|
|
304
|
+
resolveTunnelTargetPort(ws, 7830, { preferNginxIngress: false }),
|
|
305
|
+
).toEqual({
|
|
306
|
+
port: 7830,
|
|
307
|
+
viaIngress: false,
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("clears ingress state after nginx is confirmed stopped", async () => {
|
|
312
|
+
const ws = makeWorkspace();
|
|
313
|
+
const pid = 123_456;
|
|
314
|
+
let alive = true;
|
|
315
|
+
writeIngressState(ws, 7841);
|
|
316
|
+
writePidFile(ws, pid);
|
|
317
|
+
execFileSyncMock.mockReturnValue(nginxCommand(ws));
|
|
318
|
+
process.kill = mock((targetPid: number, signal?: string | number) => {
|
|
319
|
+
if (targetPid !== pid) return originalKill(targetPid, signal);
|
|
320
|
+
if (signal === 0) {
|
|
321
|
+
if (!alive) throw new Error("dead");
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
if (signal === "SIGTERM") {
|
|
325
|
+
alive = false;
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
return true;
|
|
329
|
+
}) as unknown as typeof process.kill;
|
|
330
|
+
|
|
331
|
+
await expect(stopIngressNginx(ws)).resolves.toBe(true);
|
|
332
|
+
|
|
333
|
+
const config = readConfig(ws);
|
|
334
|
+
expect((config.ingress as Record<string, unknown>).nginx).toBeUndefined();
|
|
335
|
+
expect(existsSync(pidPath(ws))).toBe(false);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("keeps ingress state when nginx kill fails", async () => {
|
|
339
|
+
const ws = makeWorkspace();
|
|
340
|
+
const pid = 123_457;
|
|
341
|
+
writeIngressState(ws, 7841);
|
|
342
|
+
writePidFile(ws, pid);
|
|
343
|
+
execFileSyncMock.mockReturnValue(nginxCommand(ws));
|
|
344
|
+
process.kill = mock((targetPid: number, signal?: string | number) => {
|
|
345
|
+
if (targetPid !== pid) return originalKill(targetPid, signal);
|
|
346
|
+
if (signal === 0) return true;
|
|
347
|
+
throw new Error("operation not permitted");
|
|
348
|
+
}) as unknown as typeof process.kill;
|
|
349
|
+
|
|
350
|
+
await expect(stopIngressNginx(ws)).resolves.toBe(false);
|
|
351
|
+
|
|
352
|
+
const config = readConfig(ws);
|
|
353
|
+
expect((config.ingress as Record<string, unknown>).nginx).toEqual({
|
|
354
|
+
listenPort: 7841,
|
|
355
|
+
});
|
|
356
|
+
expect(existsSync(pidPath(ws))).toBe(true);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("clears ingress state when nginx exits before SIGTERM", async () => {
|
|
360
|
+
const ws = makeWorkspace();
|
|
361
|
+
const pid = 123_458;
|
|
362
|
+
let aliveChecks = 0;
|
|
363
|
+
writeIngressState(ws, 7841);
|
|
364
|
+
writePidFile(ws, pid);
|
|
365
|
+
execFileSyncMock.mockReturnValue(nginxCommand(ws));
|
|
366
|
+
process.kill = mock((targetPid: number, signal?: string | number) => {
|
|
367
|
+
if (targetPid !== pid) return originalKill(targetPid, signal);
|
|
368
|
+
if (signal === 0) {
|
|
369
|
+
aliveChecks++;
|
|
370
|
+
if (aliveChecks === 1) return true;
|
|
371
|
+
throw new Error("dead");
|
|
372
|
+
}
|
|
373
|
+
throw new Error("no such process");
|
|
374
|
+
}) as unknown as typeof process.kill;
|
|
375
|
+
|
|
376
|
+
await expect(stopIngressNginx(ws)).resolves.toBe(true);
|
|
377
|
+
|
|
378
|
+
const config = readConfig(ws);
|
|
379
|
+
expect((config.ingress as Record<string, unknown>).nginx).toBeUndefined();
|
|
380
|
+
expect(existsSync(pidPath(ws))).toBe(false);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("does not kill another nginx instance when clearing stale state", async () => {
|
|
384
|
+
const ws = makeWorkspace();
|
|
385
|
+
const pid = 123_459;
|
|
386
|
+
writeIngressState(ws, 7841);
|
|
387
|
+
writePidFile(ws, pid);
|
|
388
|
+
execFileSyncMock.mockReturnValue(
|
|
389
|
+
"nginx: master process nginx -p /tmp/other-ingress -c /tmp/other-ingress/nginx.conf",
|
|
390
|
+
);
|
|
391
|
+
process.kill = mock((targetPid: number, signal?: string | number) => {
|
|
392
|
+
if (targetPid !== pid) return originalKill(targetPid, signal);
|
|
393
|
+
if (signal === 0) return true;
|
|
394
|
+
throw new Error("should not kill another nginx instance");
|
|
395
|
+
}) as unknown as typeof process.kill;
|
|
396
|
+
|
|
397
|
+
await expect(stopIngressNginx(ws)).resolves.toBe(false);
|
|
398
|
+
|
|
399
|
+
const config = readConfig(ws);
|
|
400
|
+
expect((config.ingress as Record<string, unknown>).nginx).toBeUndefined();
|
|
401
|
+
expect(existsSync(pidPath(ws))).toBe(false);
|
|
402
|
+
});
|
|
403
|
+
});
|
|
@@ -155,10 +155,14 @@ describe("sleep command", () => {
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(2);
|
|
158
|
+
// The assistant stop passes a generous 120s grace so the daemon's WAL
|
|
159
|
+
// checkpoint completes before any SIGKILL (default 2s would truncate it).
|
|
158
160
|
expect(stopProcessByPidFileMock).toHaveBeenNthCalledWith(
|
|
159
161
|
1,
|
|
160
162
|
join(assistantRootDir, "workspace", "vellum.pid"),
|
|
161
163
|
"assistant",
|
|
164
|
+
undefined,
|
|
165
|
+
120_000,
|
|
162
166
|
);
|
|
163
167
|
expect(stopProcessByPidFileMock).toHaveBeenNthCalledWith(
|
|
164
168
|
2,
|
|
@@ -773,15 +773,12 @@ describe("resolveOrHatchTarget", () => {
|
|
|
773
773
|
});
|
|
774
774
|
|
|
775
775
|
const result = await resolveOrHatchTarget("docker", "new-one");
|
|
776
|
-
expect(hatchDockerMock).toHaveBeenCalledWith(
|
|
777
|
-
"vellum",
|
|
778
|
-
false,
|
|
779
|
-
"new-one",
|
|
780
|
-
false,
|
|
781
|
-
|
|
782
|
-
{},
|
|
783
|
-
{ setupProviderCredentials: false },
|
|
784
|
-
);
|
|
776
|
+
expect(hatchDockerMock).toHaveBeenCalledWith({
|
|
777
|
+
species: "vellum",
|
|
778
|
+
detached: false,
|
|
779
|
+
name: "new-one",
|
|
780
|
+
setupProviderCredentials: false,
|
|
781
|
+
});
|
|
785
782
|
expect(result).toBe(newEntry);
|
|
786
783
|
});
|
|
787
784
|
|