@vellumai/cli 0.11.7-staging.2 → 0.11.8-staging.1

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 (61) hide show
  1. package/AGENTS.md +1 -1
  2. package/node_modules/@vellumai/environments/package.json +2 -1
  3. package/node_modules/@vellumai/environments/src/__tests__/test-path-guard.test.ts +89 -0
  4. package/node_modules/@vellumai/environments/src/test-path-guard.ts +113 -0
  5. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/package.json +2 -1
  6. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/test-path-guard.test.ts +89 -0
  7. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/test-path-guard.ts +113 -0
  8. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/package.json +1 -0
  9. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  10. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  11. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
  12. package/node_modules/@vellumai/local-mode/src/hatch.ts +17 -2
  13. package/node_modules/@vellumai/local-mode/src/index.ts +5 -0
  14. package/node_modules/@vellumai/local-mode/src/lifecycle-budgets.ts +47 -0
  15. package/node_modules/@vellumai/local-mode/src/retire.ts +9 -3
  16. package/node_modules/@vellumai/local-mode/src/sleep.ts +5 -5
  17. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  18. package/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  19. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  20. package/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
  21. package/package.json +4 -3
  22. package/src/__tests__/assistant-client-platform-auth.test.ts +219 -0
  23. package/src/__tests__/clean.test.ts +1 -0
  24. package/src/__tests__/nginx-ingress.test.ts +123 -8
  25. package/src/__tests__/retire-local.test.ts +32 -1
  26. package/src/__tests__/sleep.test.ts +5 -3
  27. package/src/__tests__/teleport.test.ts +1 -0
  28. package/src/__tests__/upgrade-local.test.ts +116 -0
  29. package/src/__tests__/windows-lifecycle.test.ts +77 -5
  30. package/src/adapters/openclaw-http-server.ts +5 -0
  31. package/src/commands/client.ts +35 -7
  32. package/src/commands/exec.ts +8 -2
  33. package/src/commands/logs.ts +11 -2
  34. package/src/commands/sleep.ts +6 -9
  35. package/src/commands/ssh.ts +6 -3
  36. package/src/commands/teleport.ts +6 -1
  37. package/src/commands/upgrade.ts +22 -12
  38. package/src/commands/voice.test.ts +81 -0
  39. package/src/commands/voice.ts +83 -6
  40. package/src/commands/wake.ts +7 -1
  41. package/src/lib/assistant-client.ts +145 -31
  42. package/src/lib/cloudflare-tunnel.ts +2 -1
  43. package/src/lib/detached-process.ts +1 -0
  44. package/src/lib/guardian-token.ts +4 -2
  45. package/src/lib/hatch-local.ts +5 -1
  46. package/src/lib/live-voice/client.ts +23 -2
  47. package/src/lib/live-voice/connection.ts +87 -10
  48. package/src/lib/live-voice/pcm-player.ts +16 -3
  49. package/src/lib/live-voice/velay.test.ts +99 -0
  50. package/src/lib/live-voice/velay.ts +199 -0
  51. package/src/lib/local-hatch-lock.ts +16 -3
  52. package/src/lib/local.ts +48 -13
  53. package/src/lib/nginx-ingress.ts +70 -10
  54. package/src/lib/ngrok.ts +2 -0
  55. package/src/lib/open-browser.ts +5 -1
  56. package/src/lib/process.ts +72 -10
  57. package/src/lib/provider-secrets.ts +5 -0
  58. package/src/lib/retire-local.ts +12 -5
  59. package/src/lib/step-runner.ts +3 -0
  60. package/src/lib/tailscale-tunnel.ts +6 -1
  61. package/src/lib/upgrade-lifecycle.ts +1 -0
@@ -1,11 +1,17 @@
1
1
  import { spawn } from "node:child_process";
2
2
 
3
+ import {
4
+ DAEMON_STOP_TIMEOUT_MS,
5
+ HOST_WRAPPER_LONG_HEADROOM_MS,
6
+ } from "./lifecycle-budgets";
3
7
  import type { CliInvocation } from "./util";
4
8
 
5
- const RETIRE_TIMEOUT_MS = 60_000;
9
+ const RETIRE_TIMEOUT_MS =
10
+ DAEMON_STOP_TIMEOUT_MS + HOST_WRAPPER_LONG_HEADROOM_MS;
6
11
 
7
12
  export type RetireResult =
8
- { ok: true } | { ok: false; status: number; error: string };
13
+ | { ok: true }
14
+ | { ok: false; status: number; error: string };
9
15
 
10
16
  export interface RetireOptions {
11
17
  platformToken?: string;
@@ -50,7 +56,7 @@ export function runRetire(
50
56
  finish({
51
57
  ok: false,
52
58
  status: 500,
53
- error: "Retire timed out after 60 seconds",
59
+ error: `Retire timed out after ${RETIRE_TIMEOUT_MS / 1000} seconds`,
54
60
  });
55
61
  }, RETIRE_TIMEOUT_MS);
56
62
 
@@ -1,12 +1,12 @@
1
1
  import { spawn } from "node:child_process";
2
2
 
3
+ import {
4
+ DAEMON_STOP_TIMEOUT_MS,
5
+ HOST_WRAPPER_HEADROOM_MS,
6
+ } from "./lifecycle-budgets";
3
7
  import type { CliInvocation } from "./util";
4
8
 
5
- // The CLI's `sleep` command uses a 120s SIGKILL ceiling for the assistant
6
- // daemon (WAL checkpoint can be slow on large databases) plus 7s for the
7
- // gateway drain window. The wrapper timeout must sit above that total so a
8
- // slow-but-succeeding sleep isn't killed and misreported as a timeout.
9
- const SLEEP_TIMEOUT_MS = 150_000;
9
+ const SLEEP_TIMEOUT_MS = DAEMON_STOP_TIMEOUT_MS + HOST_WRAPPER_HEADROOM_MS;
10
10
 
11
11
  export type SleepResult =
12
12
  | { ok: true }
@@ -11,6 +11,7 @@
11
11
  "./client-metadata": "./src/client-metadata.ts",
12
12
  "./credential-rpc": "./src/credential-rpc.ts",
13
13
  "./ingress": "./src/ingress.ts",
14
+ "./no-response": "./src/no-response.ts",
14
15
  "./remote-web-pairing": "./src/remote-web-pairing.ts",
15
16
  "./twilio-ingress": "./src/twilio-ingress.ts",
16
17
  "./trust-rules": "./src/trust-rules.ts",
@@ -66,6 +66,13 @@ export function isChannelId(value: unknown): value is ChannelId {
66
66
  * That irregularity is why this is stated rather than derived from the key,
67
67
  * and it is stated here because this file already owns what a channel is.
68
68
  *
69
+ * Both senses can hold a user token, which is the sharpest edge. The `slack`
70
+ * integration's persisted token *is* the installer's user token, held on its
71
+ * OAuth connection. `slack_channel` holds an optional `user_token` in the
72
+ * credential store, beside its bot and app tokens. Same words, different
73
+ * homes, and only the second is a credential-store key: a pasted token is
74
+ * always the channel's, because the integration's never leaves the exchange.
75
+ *
69
76
  * Deliberately only the key. What fields each credential requires is declared
70
77
  * once already, per service, in the gateway's credential specs; restating it
71
78
  * here would be a second copy of a different fact.
@@ -27,6 +27,7 @@ export * from "./handles.js";
27
27
  export * from "./rpc.js";
28
28
  export * from "./trust-rules.js";
29
29
  export * from "./ingress.js";
30
+ export * from "./no-response.js";
30
31
  export * from "./remote-web-pairing.js";
31
32
  export * from "./twilio-ingress.js";
32
33
  export * from "./url-normalization.js";
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The `<no_response/>` sentinel: the cross-service convention for a turn
3
+ * that deliberately produces no user-visible reply.
4
+ *
5
+ * The model emits it, the daemon stamps and strips it, channel delivery
6
+ * suppresses it, and clients hold live-streamed prefixes of it back from
7
+ * display. One definition here keeps every consumer's parsing identical;
8
+ * a hand-rolled copy in any one of them is how the case-sensitivity of a
9
+ * regex drifts.
10
+ */
11
+
12
+ /** Matches a message whose entire content is the sentinel. */
13
+ const NO_RESPONSE_ONLY_RE = /^\s*<no_response\s*\/?>\s*$/i;
14
+
15
+ /**
16
+ * Whether `text` is nothing but the sentinel: the whole reply is a
17
+ * deliberate non-response, as opposed to real content with an inline
18
+ * sentinel mixed in.
19
+ */
20
+ export function isNoResponseOnlyText(text: string): boolean {
21
+ return NO_RESPONSE_ONLY_RE.test(text);
22
+ }
23
+
24
+ /** Matches every sentinel occurrence for stripping it out of mixed content. */
25
+ export const NO_RESPONSE_INLINE_RE = /<no_response\s*\/?>/gi;
26
+
27
+ /**
28
+ * Detection variant without the `g` flag: a `g`-flagged regex is stateful
29
+ * under `.test()` (it resumes from `lastIndex`), so reusing
30
+ * {@link NO_RESPONSE_INLINE_RE} for detection would alternate between
31
+ * matches and misses across calls.
32
+ */
33
+ const NO_RESPONSE_MARKER_RE = new RegExp(NO_RESPONSE_INLINE_RE.source, "i");
34
+
35
+ /** Whether `text` contains the sentinel anywhere. */
36
+ export function containsNoResponseMarker(text: string): boolean {
37
+ return NO_RESPONSE_MARKER_RE.test(text);
38
+ }
39
+
40
+ /** Removes every sentinel occurrence, trimming the leftover whitespace. */
41
+ export function stripNoResponseMarkers(text: string): string {
42
+ return text.replace(NO_RESPONSE_INLINE_RE, "").trim();
43
+ }
44
+
45
+ const NO_RESPONSE_SENTINEL_FORMS = [
46
+ "<no_response/>",
47
+ "<no_response />",
48
+ "<no_response>",
49
+ ] as const;
50
+
51
+ /**
52
+ * Whether `text` could still grow into the sentinel, i.e. it is a leading
53
+ * substring of one of its forms. Holding on these keeps a slowly-streamed
54
+ * `<no_response/>` from surfacing as visible partial content.
55
+ */
56
+ export function isPotentialNoResponsePrefix(text: string): boolean {
57
+ const lower = text.trim().toLowerCase();
58
+ if (lower.length === 0) {
59
+ return false;
60
+ }
61
+ return NO_RESPONSE_SENTINEL_FORMS.some((sentinel) =>
62
+ sentinel.startsWith(lower),
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Whether `text` carries user-visible content worth acting on now. Returns
68
+ * `false` for empty text, the standalone sentinel, and any prefix that could
69
+ * still complete into one; returns `true` once real content remains after
70
+ * stripping inline sentinels.
71
+ */
72
+ export function hasDeliverableAssistantText(text: string): boolean {
73
+ const trimmed = text.trim();
74
+ if (trimmed.length === 0) {
75
+ return false;
76
+ }
77
+ if (NO_RESPONSE_ONLY_RE.test(trimmed)) {
78
+ return false;
79
+ }
80
+ if (isPotentialNoResponsePrefix(trimmed)) {
81
+ return false;
82
+ }
83
+ return stripNoResponseMarkers(trimmed).length > 0;
84
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.11.7-staging.2",
3
+ "version": "0.11.8-staging.1",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -22,7 +22,7 @@
22
22
  "format:check": "prettier --check .",
23
23
  "lint": "eslint",
24
24
  "lint:unused": "knip --include files,dependencies,unlisted",
25
- "compile:check": "bun build --compile --external react-devtools-core src/index.ts --outfile /dev/null",
25
+ "compile:check": "bun build --compile src/index.ts --outfile /dev/null",
26
26
  "test": "bun test",
27
27
  "typecheck": "bunx tsc --noEmit"
28
28
  },
@@ -38,7 +38,8 @@
38
38
  "ink": "6.8.0",
39
39
  "nanoid": "5.1.7",
40
40
  "qrcode-terminal": "0.12.0",
41
- "react": "19.2.6"
41
+ "react": "19.2.6",
42
+ "react-devtools-core": "7.0.1"
42
43
  },
43
44
  "bundledDependencies": [
44
45
  "@vellumai/avatar-manifest",
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Tests for AssistantClient's platform-managed (`cloud: "vellum"`) auth path.
3
+ * These entries have no guardian token on disk, so the client must authenticate
4
+ * with the stored platform credential instead of sending an anonymous request
5
+ * (which the platform answers with a bare 403).
6
+ */
7
+ import {
8
+ afterAll,
9
+ afterEach,
10
+ beforeEach,
11
+ describe,
12
+ expect,
13
+ test,
14
+ } from "bun:test";
15
+ import { mkdtempSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ const testDir = mkdtempSync(join(tmpdir(), "client-platform-auth-test-"));
20
+ const ORIGINAL_LOCKFILE_DIR = process.env.VELLUM_LOCKFILE_DIR;
21
+ const ORIGINAL_CONFIG_HOME = process.env.XDG_CONFIG_HOME;
22
+ const ORIGINAL_FETCH = globalThis.fetch;
23
+
24
+ import { AssistantClient } from "../lib/assistant-client.js";
25
+ import { saveAssistantEntry } from "../lib/assistant-config.js";
26
+ import {
27
+ clearPlatformToken,
28
+ savePlatformToken,
29
+ } from "../lib/platform-client.js";
30
+
31
+ const PLATFORM_URL = "https://platform.example.com";
32
+ const ASSISTANT_ID = "019d1e04-cb20-719a-b04b-310072904444";
33
+ const ORG_ID = "org-abc";
34
+
35
+ /** Seed a platform-managed lockfile entry, optionally with a stored token. */
36
+ function seedCloud(token?: string): void {
37
+ saveAssistantEntry({
38
+ assistantId: ASSISTANT_ID,
39
+ name: "Credence",
40
+ runtimeUrl: PLATFORM_URL,
41
+ cloud: "vellum",
42
+ species: "vellum",
43
+ });
44
+ if (token) {
45
+ savePlatformToken(token);
46
+ }
47
+ }
48
+
49
+ interface Call {
50
+ url: string;
51
+ headers: Record<string, string>;
52
+ }
53
+
54
+ /** Replace global fetch with a URL-routed stub; returns the call log. */
55
+ function stubFetch(handler: (url: string, calls: Call[]) => Response): Call[] {
56
+ const calls: Call[] = [];
57
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
58
+ const url = typeof input === "string" ? input : String(input);
59
+ calls.push({
60
+ url,
61
+ headers: (init?.headers ?? {}) as Record<string, string>,
62
+ });
63
+ return handler(url, calls);
64
+ }) as typeof fetch;
65
+ return calls;
66
+ }
67
+
68
+ const isOrgFetch = (url: string) => url.includes("/v1/organizations/");
69
+ const isRefresh = (url: string) => url.includes("/v1/guardian/refresh");
70
+
71
+ function orgResponse(): Response {
72
+ return new Response(JSON.stringify({ results: [{ id: ORG_ID }] }), {
73
+ status: 200,
74
+ headers: { "content-type": "application/json" },
75
+ });
76
+ }
77
+
78
+ describe("AssistantClient platform-managed auth", () => {
79
+ beforeEach(() => {
80
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
81
+ process.env.XDG_CONFIG_HOME = testDir;
82
+ // Tests share testDir, so a token saved by an earlier one must not leak.
83
+ clearPlatformToken();
84
+ });
85
+
86
+ afterEach(() => {
87
+ globalThis.fetch = ORIGINAL_FETCH;
88
+ if (ORIGINAL_LOCKFILE_DIR === undefined) {
89
+ delete process.env.VELLUM_LOCKFILE_DIR;
90
+ } else {
91
+ process.env.VELLUM_LOCKFILE_DIR = ORIGINAL_LOCKFILE_DIR;
92
+ }
93
+ if (ORIGINAL_CONFIG_HOME === undefined) {
94
+ delete process.env.XDG_CONFIG_HOME;
95
+ } else {
96
+ process.env.XDG_CONFIG_HOME = ORIGINAL_CONFIG_HOME;
97
+ }
98
+ });
99
+
100
+ afterAll(() => {
101
+ rmSync(testDir, { recursive: true, force: true });
102
+ });
103
+
104
+ test("sends X-Session-Token + Vellum-Organization-Id for a session token", async () => {
105
+ seedCloud("sess-tok-1");
106
+ const calls = stubFetch((url) =>
107
+ isOrgFetch(url) ? orgResponse() : new Response("", { status: 200 }),
108
+ );
109
+
110
+ const client = new AssistantClient({ assistantId: ASSISTANT_ID });
111
+ const res = await client.post("/messages/", { content: "hi" });
112
+
113
+ expect(res.status).toBe(200);
114
+ const assistantCall = calls.find((c) => !isOrgFetch(c.url))!;
115
+ expect(assistantCall.url).toBe(
116
+ `${PLATFORM_URL}/v1/assistants/${ASSISTANT_ID}/messages/`,
117
+ );
118
+ expect(assistantCall.headers["X-Session-Token"]).toBe("sess-tok-1");
119
+ expect(assistantCall.headers["Vellum-Organization-Id"]).toBe(ORG_ID);
120
+ expect(assistantCall.headers["Authorization"]).toBeUndefined();
121
+ expect(assistantCall.headers["Content-Type"]).toBe("application/json");
122
+ });
123
+
124
+ test("resolves the auth headers once across multiple requests", async () => {
125
+ seedCloud("sess-tok-2");
126
+ const calls = stubFetch((url) =>
127
+ isOrgFetch(url) ? orgResponse() : new Response("", { status: 200 }),
128
+ );
129
+
130
+ const client = new AssistantClient({ assistantId: ASSISTANT_ID });
131
+ await client.get("/healthz/");
132
+ await client.get("/healthz/");
133
+
134
+ expect(calls.filter((c) => isOrgFetch(c.url))).toHaveLength(1);
135
+ // A bodyless request must not claim a JSON body.
136
+ const assistantCalls = calls.filter((c) => !isOrgFetch(c.url));
137
+ expect(assistantCalls).toHaveLength(2);
138
+ expect(assistantCalls[0].headers["Content-Type"]).toBeUndefined();
139
+ });
140
+
141
+ test("sends a bearer Authorization and no org header for a vak_ API key", async () => {
142
+ seedCloud("vak_test_key");
143
+ const calls = stubFetch((url) =>
144
+ isOrgFetch(url) ? orgResponse() : new Response("", { status: 200 }),
145
+ );
146
+
147
+ const client = new AssistantClient({ assistantId: ASSISTANT_ID });
148
+ await client.get("/healthz/");
149
+
150
+ // API keys are already org-scoped, so no org lookup happens.
151
+ expect(calls.filter((c) => isOrgFetch(c.url))).toHaveLength(0);
152
+ const assistantCall = calls[0];
153
+ expect(assistantCall.headers["Authorization"]).toBe("Bearer vak_test_key");
154
+ expect(assistantCall.headers["Vellum-Organization-Id"]).toBeUndefined();
155
+ expect(assistantCall.headers["X-Session-Token"]).toBeUndefined();
156
+ });
157
+
158
+ test("throws the login hint instead of sending an anonymous request", async () => {
159
+ seedCloud(); // no platform token stored
160
+ const calls = stubFetch(() => new Response("", { status: 200 }));
161
+
162
+ const client = new AssistantClient({ assistantId: ASSISTANT_ID });
163
+
164
+ await expect(client.post("/messages/", { content: "hi" })).rejects.toThrow(
165
+ "Not logged in. Run `vellum login` first to authenticate with the platform.",
166
+ );
167
+ expect(calls).toHaveLength(0);
168
+ });
169
+
170
+ test("refuses to send platform credentials to a runtimeUrl override", async () => {
171
+ seedCloud("sess-tok-4");
172
+ const calls = stubFetch(() => new Response("", { status: 200 }));
173
+
174
+ expect(
175
+ () =>
176
+ new AssistantClient({
177
+ assistantId: ASSISTANT_ID,
178
+ runtimeUrl: "https://attacker.example.com",
179
+ }),
180
+ ).toThrow("Refusing to send platform credentials");
181
+ expect(calls).toHaveLength(0);
182
+ });
183
+
184
+ test("allows a runtimeUrl override that matches the entry's platform host", async () => {
185
+ seedCloud("sess-tok-5");
186
+ stubFetch((url) =>
187
+ isOrgFetch(url) ? orgResponse() : new Response("", { status: 200 }),
188
+ );
189
+
190
+ const client = new AssistantClient({
191
+ assistantId: ASSISTANT_ID,
192
+ runtimeUrl: `${PLATFORM_URL}/`, // trailing slash must not count as a mismatch
193
+ });
194
+
195
+ expect(client.runtimeUrl).toBe(PLATFORM_URL);
196
+ expect((await client.get("/healthz/")).status).toBe(200);
197
+ });
198
+
199
+ test("re-resolves the org id once on a 401 and never refreshes a guardian token", async () => {
200
+ seedCloud("sess-tok-3");
201
+ let assistantAttempts = 0;
202
+ const calls = stubFetch((url) => {
203
+ if (isOrgFetch(url)) {
204
+ return orgResponse();
205
+ }
206
+ assistantAttempts++;
207
+ return new Response("", { status: 401 }); // always 401
208
+ });
209
+
210
+ const client = new AssistantClient({ assistantId: ASSISTANT_ID });
211
+ const res = await client.get("/messages/");
212
+
213
+ expect(res.status).toBe(401);
214
+ expect(assistantAttempts).toBe(2); // original + one retry, no more
215
+ // The cached org id was invalidated, so the retry refetched it.
216
+ expect(calls.filter((c) => isOrgFetch(c.url))).toHaveLength(2);
217
+ expect(calls.filter((c) => isRefresh(c.url))).toHaveLength(0);
218
+ });
219
+ });
@@ -31,6 +31,7 @@ beforeAll(() => {
31
31
  detectOrphanedProcesses: detectOrphansMock,
32
32
  }));
33
33
  mock.module("../lib/process.js", () => ({
34
+ ...realProcess,
34
35
  stopProcess: stopProcessMock,
35
36
  }));
36
37
  });
@@ -72,7 +72,9 @@ import {
72
72
  hasIpv6Loopback,
73
73
  buildRemoteWebIndexHtml,
74
74
  cloudWebHubUrl,
75
+ MAX_PRESERVED_MODULE_PRELOADS,
75
76
  ensureTunnelEdge,
77
+ EDGE_TEMPLATE_VERSION,
76
78
  startRemoteWebIngress,
77
79
  stopContainerTunnelEdge,
78
80
  stopIngressNginx,
@@ -264,6 +266,56 @@ describe("buildIngressNginxConfig", () => {
264
266
  expect(remoteConf).toContain("location / {\n return 404;\n }");
265
267
  });
266
268
 
269
+ test("compresses text asset types", () => {
270
+ expect(remoteConf).toContain(
271
+ "gzip_types application/javascript application/json application/wasm image/svg+xml text/css text/plain;",
272
+ );
273
+ });
274
+
275
+ test("compresses only the static SPA surface, never a proxied response", () => {
276
+ // Compression is opt-in per location: this server also proxies
277
+ // authenticated /v1 and /webhooks traffic, and a compressed response
278
+ // carrying both a secret and attacker-influenced content over TLS is the
279
+ // BREACH side channel. The tuning at http scope stays inert.
280
+ expect(remoteConf).not.toMatch(/^ {2}gzip on;$/m);
281
+ const blocks = locationBlocks(remoteConf);
282
+ expect(
283
+ blocks
284
+ .filter((b) => b.body.includes("gzip on;"))
285
+ .map((b) => b.matcher)
286
+ .sort(),
287
+ ).toEqual([
288
+ "= /assistant/__remote-index.html",
289
+ "^~ /assistant/",
290
+ "^~ /assistant/assets/",
291
+ ]);
292
+ expect(
293
+ blocks
294
+ .filter(
295
+ (b) => b.body.includes("proxy_pass") && b.body.includes("gzip on;"),
296
+ )
297
+ .map((b) => b.matcher),
298
+ ).toEqual([]);
299
+ });
300
+
301
+ test("revalidates the shell and forbids storing only the inline config", () => {
302
+ const blocks = locationBlocks(remoteConf);
303
+ const cacheControlOf = (matcher: string) =>
304
+ blocks
305
+ .find((b) => b.matcher === matcher)
306
+ ?.body.match(/add_header Cache-Control "([^"]+)"/)?.[1];
307
+ expect(cacheControlOf("= /assistant/__remote-index.html")).toBe("no-cache");
308
+ expect(cacheControlOf("^~ /assistant/")).toBe("no-cache");
309
+ expect(cacheControlOf("^~ /assistant/assets/")).toBe(
310
+ "public, max-age=31536000, immutable",
311
+ );
312
+ // Only the inline __config return, which nginx gives no validator, still
313
+ // refuses storage. A second no-store means a revalidating location
314
+ // regressed to re-sending its whole body on every load.
315
+ expect(cacheControlOf("= /assistant/__config")).toBe("no-store");
316
+ expect(remoteConf.match(/no-store/g)).toHaveLength(1);
317
+ });
318
+
267
319
  test("serves remote web config for the SPA", () => {
268
320
  expect(remoteConf).toContain("location = /assistant/__config {");
269
321
  expect(remoteConf).toContain("default_type application/json;");
@@ -411,22 +463,61 @@ describe("buildRemoteWebIndexHtml", () => {
411
463
  expect(result).toContain("\\u003c/script\\u003e");
412
464
  });
413
465
 
414
- test("drops modulepreload hints but keeps the entry script and stylesheet", () => {
466
+ test("keeps the consolidated boot graph's modulepreload hints", () => {
415
467
  const html = [
416
468
  "<html><head>",
417
469
  '<link rel="modulepreload" crossorigin href="/assistant/assets/a-1.js">',
418
470
  '<link rel="modulepreload" crossorigin href="/assistant/assets/b-2.js">',
419
- '<link rel="stylesheet" crossorigin href="/assistant/assets/main-3.css">',
420
- '<script type="module" crossorigin src="/assistant/assets/index-4.js"></script>',
471
+ '<link rel="modulepreload" crossorigin href="/assistant/assets/c-3.js">',
472
+ '<link rel="modulepreload" crossorigin href="/assistant/assets/d-4.js">',
473
+ '<link rel="stylesheet" crossorigin href="/assistant/assets/main-5.css">',
474
+ '<script type="module" crossorigin src="/assistant/assets/index-6.js"></script>',
475
+ "</head><body></body></html>",
476
+ ].join("\n");
477
+
478
+ const result = buildRemoteWebIndexHtml(html, { mode: "remote-gateway" });
479
+
480
+ expect(result.match(/rel="modulepreload"/g)).toHaveLength(4);
481
+ expect(result).toContain('href="/assistant/assets/a-1.js"');
482
+ expect(result).toContain('href="/assistant/assets/main-5.css"');
483
+ expect(result).toContain('src="/assistant/assets/index-6.js"');
484
+ });
485
+
486
+ test("strips a whole-graph preload set past the threshold, keeping entry and stylesheet", () => {
487
+ const preloads = Array.from(
488
+ { length: MAX_PRESERVED_MODULE_PRELOADS + 1 },
489
+ (_, i) =>
490
+ `<link rel="modulepreload" crossorigin href="/assistant/assets/chunk-${i}.js">`,
491
+ );
492
+ const html = [
493
+ "<html><head>",
494
+ ...preloads,
495
+ '<link rel="stylesheet" crossorigin href="/assistant/assets/main-x.css">',
496
+ '<script type="module" crossorigin src="/assistant/assets/index-y.js"></script>',
421
497
  "</head><body></body></html>",
422
498
  ].join("\n");
423
499
 
424
500
  const result = buildRemoteWebIndexHtml(html, { mode: "remote-gateway" });
425
501
 
426
502
  expect(result).not.toContain("modulepreload");
427
- expect(result).not.toContain("/assistant/assets/a-1.js");
428
- expect(result).toContain('href="/assistant/assets/main-3.css"');
429
- expect(result).toContain('src="/assistant/assets/index-4.js"');
503
+ expect(result).not.toContain("/assistant/assets/chunk-0.js");
504
+ expect(result).toContain('href="/assistant/assets/main-x.css"');
505
+ expect(result).toContain('src="/assistant/assets/index-y.js"');
506
+ });
507
+
508
+ test("keeps exactly the threshold count of hints", () => {
509
+ const preloads = Array.from(
510
+ { length: MAX_PRESERVED_MODULE_PRELOADS },
511
+ (_, i) =>
512
+ `<link rel="modulepreload" crossorigin href="/assistant/assets/chunk-${i}.js">`,
513
+ );
514
+ const html = `<html><head>${preloads.join("\n")}</head><body></body></html>`;
515
+
516
+ const result = buildRemoteWebIndexHtml(html, { mode: "remote-gateway" });
517
+
518
+ expect(result.match(/rel="modulepreload"/g)).toHaveLength(
519
+ MAX_PRESERVED_MODULE_PRELOADS,
520
+ );
430
521
  });
431
522
  });
432
523
 
@@ -734,13 +825,37 @@ function mockUnkillableNginx(pid: number): void {
734
825
  installKillMock();
735
826
  }
736
827
 
828
+ /**
829
+ * The multi-line `location <matcher> { ... }` blocks of a generated config,
830
+ * so a test can assert what a location does rather than how its lines are
831
+ * ordered. Single-line denylist locations carry no directives worth reading
832
+ * and are skipped by the trailing-brace match.
833
+ */
834
+ function locationBlocks(
835
+ conf: string,
836
+ ): Array<{ matcher: string; body: string }> {
837
+ const blocks: Array<{ matcher: string; body: string }> = [];
838
+ const opener = /^ {4}location ([^{]+?) \{$/gm;
839
+ let match: RegExpExecArray | null;
840
+ while ((match = opener.exec(conf)) !== null) {
841
+ const end = conf.indexOf("\n }", opener.lastIndex);
842
+ blocks.push({
843
+ matcher: match[1],
844
+ body: conf.slice(opener.lastIndex, end === -1 ? undefined : end),
845
+ });
846
+ }
847
+ return blocks;
848
+ }
849
+
737
850
  const PRODUCTION_HUB_URL = "https://www.vellum.ai/assistant";
738
851
 
739
852
  /**
740
853
  * Mirror of the SPA config fingerprint the edge records in its ingress state
741
854
  * (sha256 over the edge template version and the injected config JSON). Pins
742
855
  * both the injected config shape and the hash format; assumes the
743
- * production-pinned environment. `template` tracks `EDGE_TEMPLATE_VERSION`.
856
+ * production-pinned environment. The version itself is imported rather than
857
+ * mirrored: bumping it is the correct response to any template edit, so a
858
+ * copy here would fail every such edit without naming a defect.
744
859
  */
745
860
  function spaConfigHash(
746
861
  opts: { assistantName?: string; assistantId?: string } = {},
@@ -748,7 +863,7 @@ function spaConfigHash(
748
863
  return createHash("sha256")
749
864
  .update(
750
865
  JSON.stringify({
751
- template: 5,
866
+ template: EDGE_TEMPLATE_VERSION,
752
867
  config: {
753
868
  mode: "remote-gateway",
754
869
  apiBaseUrl: "/v1",
@@ -33,14 +33,22 @@ const realNginxIngress = { ...(await import("../lib/nginx-ingress.js")) };
33
33
  const realRetireArchive = { ...(await import("../lib/retire-archive.js")) };
34
34
 
35
35
  const stopProcessByPidFileMock = mock(
36
- async (_pidFile: string, _label: string): Promise<boolean> => true,
36
+ async (
37
+ _pidFile: string,
38
+ _label: string,
39
+ _extraCleanupFiles?: string[],
40
+ _timeoutMs?: number,
41
+ ): Promise<boolean> => true,
37
42
  );
43
+
44
+ const { DAEMON_STOP_TIMEOUT_MS } = realProcess;
38
45
  const stopOrphanedDaemonProcessesMock = mock(
39
46
  async (_excludePids?: ReadonlySet<string>): Promise<boolean> => false,
40
47
  );
41
48
  const stopIngressNginxMock = mock(async (): Promise<boolean> => false);
42
49
 
43
50
  mock.module("../lib/process.js", () => ({
51
+ ...realProcess,
44
52
  stopProcessByPidFile: stopProcessByPidFileMock,
45
53
  stopOrphanedDaemonProcesses: stopOrphanedDaemonProcessesMock,
46
54
  }));
@@ -153,6 +161,29 @@ describe("retireLocal — CES sibling stop", () => {
153
161
  expect(gatewayStopCall).toBeDefined();
154
162
  });
155
163
 
164
+ // The daemon stops its own worker processes (schedule, route host, resource
165
+ // monitor, memory jobs) near the end of a long shutdown sequence. The 2s
166
+ // default in stopProcess would SIGKILL it first, stranding those workers as
167
+ // orphans that outlive the runtime that spawned them.
168
+ test("gives the daemon its full stop budget, not the default grace", async () => {
169
+ const entry = makeEntry("test-assistant");
170
+ await retireLocal("test-assistant", entry, {
171
+ progress: () => {},
172
+ log: () => {},
173
+ warn: () => {},
174
+ error: () => {},
175
+ });
176
+
177
+ const daemonStopCall = stopProcessByPidFileMock.mock.calls.find(
178
+ ([, label]) => label === "daemon",
179
+ );
180
+ expect(daemonStopCall?.[3]).toBe(DAEMON_STOP_TIMEOUT_MS);
181
+ // The budget is only meaningful if it outlasts the daemon's own 30s
182
+ // force-exit, which is what it is derived from. Anything at or below that
183
+ // kills the daemon before it can fold the WAL and hand off on its way out.
184
+ expect(DAEMON_STOP_TIMEOUT_MS).toBeGreaterThan(30_000);
185
+ });
186
+
156
187
  test("CES stop is a no-op when ces.pid is absent", async () => {
157
188
  // stopProcessByPidFile returns false when the PID file doesn't exist.
158
189
  // retireLocal should still complete successfully — the CES stop is best-effort.