@vellumai/cli 0.11.7 → 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.
- package/AGENTS.md +1 -1
- package/node_modules/@vellumai/environments/package.json +2 -1
- package/node_modules/@vellumai/environments/src/__tests__/test-path-guard.test.ts +89 -0
- package/node_modules/@vellumai/environments/src/test-path-guard.ts +113 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/package.json +2 -1
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/test-path-guard.test.ts +89 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/test-path-guard.ts +113 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
- package/node_modules/@vellumai/local-mode/src/hatch.ts +17 -2
- package/node_modules/@vellumai/local-mode/src/index.ts +5 -0
- package/node_modules/@vellumai/local-mode/src/lifecycle-budgets.ts +47 -0
- package/node_modules/@vellumai/local-mode/src/retire.ts +9 -3
- package/node_modules/@vellumai/local-mode/src/sleep.ts +5 -5
- package/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
- package/package.json +4 -3
- package/src/__tests__/assistant-client-platform-auth.test.ts +219 -0
- package/src/__tests__/clean.test.ts +1 -0
- package/src/__tests__/nginx-ingress.test.ts +123 -8
- package/src/__tests__/retire-local.test.ts +32 -1
- package/src/__tests__/sleep.test.ts +5 -3
- package/src/__tests__/teleport.test.ts +1 -0
- package/src/__tests__/upgrade-local.test.ts +116 -0
- package/src/__tests__/windows-lifecycle.test.ts +77 -5
- package/src/adapters/openclaw-http-server.ts +5 -0
- package/src/commands/client.ts +35 -7
- package/src/commands/exec.ts +8 -2
- package/src/commands/logs.ts +11 -2
- package/src/commands/sleep.ts +6 -9
- package/src/commands/ssh.ts +6 -3
- package/src/commands/teleport.ts +6 -1
- package/src/commands/upgrade.ts +22 -12
- package/src/commands/voice.test.ts +81 -0
- package/src/commands/voice.ts +83 -6
- package/src/commands/wake.ts +7 -1
- package/src/lib/assistant-client.ts +145 -31
- package/src/lib/cloudflare-tunnel.ts +2 -1
- package/src/lib/detached-process.ts +1 -0
- package/src/lib/guardian-token.ts +4 -2
- package/src/lib/hatch-local.ts +5 -1
- package/src/lib/live-voice/client.ts +23 -2
- package/src/lib/live-voice/connection.ts +87 -10
- package/src/lib/live-voice/pcm-player.ts +16 -3
- package/src/lib/live-voice/velay.test.ts +99 -0
- package/src/lib/live-voice/velay.ts +199 -0
- package/src/lib/local-hatch-lock.ts +16 -3
- package/src/lib/local.ts +48 -13
- package/src/lib/nginx-ingress.ts +70 -10
- package/src/lib/ngrok.ts +2 -0
- package/src/lib/open-browser.ts +5 -1
- package/src/lib/process.ts +72 -10
- package/src/lib/provider-secrets.ts +5 -0
- package/src/lib/retire-local.ts +12 -5
- package/src/lib/step-runner.ts +3 -0
- package/src/lib/tailscale-tunnel.ts +6 -1
- 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 =
|
|
9
|
+
const RETIRE_TIMEOUT_MS =
|
|
10
|
+
DAEMON_STOP_TIMEOUT_MS + HOST_WRAPPER_LONG_HEADROOM_MS;
|
|
6
11
|
|
|
7
12
|
export type RetireResult =
|
|
8
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
+
});
|
|
@@ -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("
|
|
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="
|
|
420
|
-
'<
|
|
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/
|
|
428
|
-
expect(result).toContain('href="/assistant/assets/main-
|
|
429
|
-
expect(result).toContain('src="/assistant/assets/index-
|
|
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.
|
|
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:
|
|
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 (
|
|
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.
|