@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
@@ -0,0 +1,81 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { CliLiveVoiceClient } from "../lib/live-voice/client.js";
4
+
5
+ import { runSession } from "./voice.js";
6
+
7
+ /**
8
+ * Minimal stand-in for the WebSocket the client drives, matching the fake in
9
+ * `lib/live-voice/client.test.ts`. `runSession` is exercised through a real
10
+ * `CliLiveVoiceClient` rather than a hand-rolled fake client, because the
11
+ * client has private fields and cannot be satisfied structurally.
12
+ */
13
+ class FakeSocket {
14
+ static readonly OPEN = 1;
15
+ binaryType = "arraybuffer";
16
+ readyState = 0;
17
+ readonly sent: string[] = [];
18
+ closed = false;
19
+
20
+ onopen: (() => void) | null = null;
21
+ onmessage: ((event: { data: unknown }) => void) | null = null;
22
+ onerror: (() => void) | null = null;
23
+ onclose: ((event: { code: number; reason: string }) => void) | null = null;
24
+
25
+ send(data: string): void {
26
+ this.sent.push(data);
27
+ }
28
+
29
+ close(): void {
30
+ this.closed = true;
31
+ }
32
+
33
+ open(): void {
34
+ this.readyState = FakeSocket.OPEN;
35
+ this.onopen?.();
36
+ }
37
+
38
+ deliver(frame: unknown): void {
39
+ this.onmessage?.({ data: JSON.stringify(frame) });
40
+ }
41
+ }
42
+
43
+ const READY = {
44
+ type: "ready",
45
+ seq: 1,
46
+ sessionId: "sess_1",
47
+ conversationId: "conv_1",
48
+ textInput: true,
49
+ };
50
+
51
+ describe("runSession --say", () => {
52
+ test("ends the session after turnDone even with no player (--no-audio)", async () => {
53
+ const socket = new FakeSocket();
54
+ const client = new CliLiveVoiceClient({
55
+ url: "ws://127.0.0.1:7830/v1/live-voice",
56
+ token: "guardian-token",
57
+ webSocketFactory: () => socket as unknown as WebSocket,
58
+ });
59
+
60
+ // player: null is what `--no-audio` passes. Before the fix, `turnDone`'s
61
+ // `player?.finish().then(...)` short-circuited entirely on a null player,
62
+ // so `endTurn()` (and therefore `client.end()`) never ran and the session
63
+ // hung forever with the socket still open. This test hangs on that
64
+ // regression rather than failing fast, which is exactly the bug: the
65
+ // suite's own timeout is what would catch it.
66
+ const sessionDone = runSession({
67
+ client,
68
+ player: null,
69
+ reference: "test-assistant (asst_1)",
70
+ sayText: "hello",
71
+ });
72
+
73
+ socket.open();
74
+ socket.deliver(READY);
75
+ socket.deliver({ type: "tts_done", seq: 2, turnId: "turn_1" });
76
+
77
+ await sessionDone;
78
+
79
+ expect(socket.closed).toBe(true);
80
+ });
81
+ });
@@ -36,6 +36,8 @@ ARGUMENTS:
36
36
 
37
37
  OPTIONS:
38
38
  --conversation <id> Continue an existing conversation instead of starting one
39
+ --say <message> Take this one turn, speak the reply, then exit. For
40
+ scripts and triggers: no prompt, no terminal needed.
39
41
  --no-audio Print replies without speaking them
40
42
  --help Show this help
41
43
 
@@ -51,11 +53,13 @@ DESCRIPTION:
51
53
  Ctrl+C interrupts the assistant mid-reply. Ctrl+D, or Ctrl+C when nothing
52
54
  is being spoken, ends the session.
53
55
 
54
- Local and self-hosted assistants only. Vellum cloud assistants authenticate
55
- with a session token, which the voice endpoint does not accept.
56
+ Works with local, self-hosted, and Vellum cloud assistants. A cloud
57
+ assistant needs a platform login ('vellum login'); the session is
58
+ authorized per-connection from it.
56
59
 
57
60
  EXAMPLES:
58
61
  vellum voice
62
+ vellum voice my-assistant --say "i just got back to my desk"
59
63
  vellum voice my-assistant
60
64
  vellum voice my-assistant --no-audio
61
65
  vellum voice --conversation conv_01H8XK3
@@ -73,9 +77,23 @@ export async function voice(): Promise<void> {
73
77
  // the daemon accepts any non-empty conversation id, so a swallowed option
74
78
  // would open a conversation named after the flag it ate.
75
79
  const conversationId = extractValueFlag(args, "conversation");
80
+ // One turn, then exit. Extracted before the assistant target is parsed so a
81
+ // multi-word message is never mistaken for a display name.
82
+ const sayText = extractValueFlag(args, "say");
76
83
  const assistantArg = parseAssistantTargetArg(args);
77
84
  const audioEnabled = !args.includes("--no-audio");
78
85
 
86
+ if (sayText !== undefined && sayText.trim().length === 0) {
87
+ console.error("Error: --say needs a message.");
88
+ process.exit(1);
89
+ }
90
+ if (sayText !== undefined && sayText.length > MAX_TEXT_TURN_CHARS) {
91
+ console.error(
92
+ `Error: --say is ${sayText.length} characters, limit is ${MAX_TEXT_TURN_CHARS}.`,
93
+ );
94
+ process.exit(1);
95
+ }
96
+
79
97
  let connection;
80
98
  try {
81
99
  connection = await resolveLiveVoiceConnection(assistantArg);
@@ -91,16 +109,33 @@ export async function voice(): Promise<void> {
91
109
  const client = new CliLiveVoiceClient({
92
110
  url: connection.url,
93
111
  token: connection.token,
112
+ tokenTransport: connection.tokenTransport,
94
113
  ...(conversationId ? { conversationId } : {}),
95
114
  });
96
115
 
97
- await runSession({ client, player, reference: connection.reference });
116
+ await runSession({
117
+ client,
118
+ player,
119
+ reference: connection.reference,
120
+ ...(sayText !== undefined ? { sayText } : {}),
121
+ });
98
122
  }
99
123
 
100
- interface SessionDeps {
124
+ /** Exported for tests. Not part of the command's public surface. */
125
+ export interface SessionDeps {
101
126
  client: CliLiveVoiceClient;
102
127
  player: PcmPlayer | null;
103
128
  reference: string;
129
+ /**
130
+ * Take this one turn and exit, instead of opening a prompt.
131
+ *
132
+ * The session ends only once the reply has finished *speaking*, which is why
133
+ * this cannot be approximated by piping a line into the interactive prompt:
134
+ * stdin reaches EOF the instant the line is read, readline closes, and the
135
+ * session is torn down while the turn is still in flight. That race is
136
+ * winnable on a fast local assistant and reliably lost over velay.
137
+ */
138
+ sayText?: string;
104
139
  }
105
140
 
106
141
  /**
@@ -110,7 +145,12 @@ interface SessionDeps {
110
145
  * failure. The exit code is set on the way out rather than thrown, so a failed
111
146
  * session prints one line instead of a stack.
112
147
  */
113
- function runSession({ client, player, reference }: SessionDeps): Promise<void> {
148
+ export function runSession({
149
+ client,
150
+ player,
151
+ reference,
152
+ sayText,
153
+ }: SessionDeps): Promise<void> {
114
154
  return new Promise<void>((resolve) => {
115
155
  let rl: Interface | null = null;
116
156
  // True from the moment a turn is sent until its reply has been spoken.
@@ -162,6 +202,14 @@ function runSession({ client, player, reference }: SessionDeps): Promise<void> {
162
202
  process.stdout.write(chalk.dim(note));
163
203
  }
164
204
  process.stdout.write("\n");
205
+ if (sayText !== undefined) {
206
+ // The reply has been spoken to the end (`turnDone` waits on the
207
+ // player before calling this), so there is nothing left to stay open
208
+ // for. Ending here also frees the assistant's single live-voice slot
209
+ // rather than holding it for a timeout to reclaim.
210
+ client.end();
211
+ return;
212
+ }
165
213
  rl?.resume();
166
214
  rl?.prompt();
167
215
  };
@@ -177,6 +225,29 @@ function runSession({ client, player, reference }: SessionDeps): Promise<void> {
177
225
  turnInFlight && (activeTurnId === null || activeTurnId === turnId);
178
226
 
179
227
  client.on("ready", (frame) => {
228
+ if (sayText !== undefined) {
229
+ if (!client.supportsTextInput) {
230
+ finish(
231
+ `${reference} does not accept typed turns. Upgrade it with ` +
232
+ `'vellum upgrade' and try again.`,
233
+ );
234
+ client.end();
235
+ return;
236
+ }
237
+ console.log(
238
+ chalk.dim(`Connected to ${reference}. ${describePlayback(player)}.`),
239
+ );
240
+ console.log(`Conversation: ${chalk.dim(frame.conversationId)}\n`);
241
+ if (!client.sendText(sayText)) {
242
+ finish("Message not sent, the session is closed.");
243
+ client.end();
244
+ return;
245
+ }
246
+ turnInFlight = true;
247
+ activeTurnId = null;
248
+ return;
249
+ }
250
+
180
251
  if (!client.supportsTextInput) {
181
252
  // The `ready` echo is the only reliable signal. A daemon that predates
182
253
  // typed turns answers every `text` frame with `unknown_type`, so
@@ -310,7 +381,13 @@ function runSession({ client, player, reference }: SessionDeps): Promise<void> {
310
381
  // back at the last byte instead would make Ctrl+C quit the session while
311
382
  // the assistant is still talking, and let the next turn start a second
312
383
  // player over the top of the first.
313
- void player?.finish().then(() => endTurn());
384
+ //
385
+ // `player` is null under `--no-audio`, and optional chaining on its own
386
+ // would short-circuit the whole expression in that case, skipping
387
+ // `.then()` and leaving `endTurn()` uncalled. `?? Promise.resolve()`
388
+ // keeps a promise on the left of `.then` either way, so the turn always
389
+ // ends whether or not there is a player to wait on.
390
+ void (player?.finish() ?? Promise.resolve()).then(() => endTurn());
314
391
  });
315
392
 
316
393
  client.on("turnCancelled", (turnId) => {
@@ -17,6 +17,7 @@ import {
17
17
  waitForDaemonMigrationsReady,
18
18
  } from "../lib/http-client.js";
19
19
  import {
20
+ DAEMON_STOP_TIMEOUT_MS,
20
21
  isProcessAlive,
21
22
  resolveProcessState,
22
23
  stopProcessByPidFile,
@@ -149,7 +150,12 @@ export async function wake(): Promise<void> {
149
150
  console.log(
150
151
  `Assistant running (pid ${daemonState.pid}) — restarting in watch mode...`,
151
152
  );
152
- const stopped = await stopProcessByPidFile(pidFile, "assistant");
153
+ const stopped = await stopProcessByPidFile(
154
+ pidFile,
155
+ "assistant",
156
+ undefined,
157
+ DAEMON_STOP_TIMEOUT_MS,
158
+ );
153
159
  if (!stopped && isProcessAlive(pidFile).alive) {
154
160
  daemonRunning = true;
155
161
  daemonUnready = true;
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Gateway client for authenticated requests to a hatched assistant's runtime.
3
3
  *
4
- * Encapsulates lockfile reading, guardian-token resolution, and
5
- * authenticated fetch so callers can simply do:
4
+ * Encapsulates lockfile reading, credential resolution, and authenticated
5
+ * fetch so callers can simply do:
6
6
  *
7
7
  * ```ts
8
8
  * const client = new AssistantClient(); // active / latest
@@ -10,6 +10,11 @@
10
10
  * await client.get("/healthz");
11
11
  * await client.post("/messages/", { content: "hi" });
12
12
  * ```
13
+ *
14
+ * The auth scheme follows the assistant's topology, so callers never pick it.
15
+ * Platform-managed entries in particular have no local guardian token: they
16
+ * authenticate against the wildcard runtime proxy at
17
+ * `{platformUrl}/v1/assistants/<id>/<rest>` with the stored platform credential.
13
18
  */
14
19
 
15
20
  import { resolveAssistant } from "./assistant-config.js";
@@ -20,10 +25,25 @@ import {
20
25
  guardianTokenDueForRenewal,
21
26
  } from "./guardian-token.js";
22
27
  import { loopbackSafeFetch } from "./loopback-fetch.js";
28
+ import {
29
+ authHeaders,
30
+ invalidateOrgIdCache,
31
+ readPlatformToken,
32
+ } from "./platform-client.js";
23
33
 
24
34
  const DEFAULT_TIMEOUT_MS = 30_000;
25
35
  const FALLBACK_RUNTIME_URL = `http://127.0.0.1:${GATEWAY_PORT}`;
26
36
 
37
+ /**
38
+ * - `guardian`: `Authorization: Bearer <guardian JWT>`, refreshable on a 401.
39
+ * - `session`: caller-supplied `X-Session-Token` + explicit org id.
40
+ * - `platform`: `cloud: "vellum"` entry; headers resolved lazily from the
41
+ * stored platform token (org id needs a network lookup).
42
+ */
43
+ type AuthMode = "guardian" | "session" | "platform";
44
+
45
+ const stripTrailingSlashes = (url: string): string => url.replace(/\/+$/, "");
46
+
27
47
  export interface AssistantClientOpts {
28
48
  assistantId?: string;
29
49
  runtimeUrl?: string;
@@ -32,6 +52,9 @@ export interface AssistantClientOpts {
32
52
  * session token instead of a guardian token. The session token is
33
53
  * sent as `X-Session-Token: <sessionToken>` and the org id is
34
54
  * sent via the `Vellum-Organization-Id` header.
55
+ *
56
+ * Platform-managed assistants do NOT need this: they are detected from the
57
+ * lockfile entry and authenticated from the stored platform token.
35
58
  */
36
59
  sessionToken?: string;
37
60
  /** Required when `sessionToken` is provided. */
@@ -49,11 +72,16 @@ export class AssistantClient {
49
72
  readonly runtimeUrl: string;
50
73
 
51
74
  private readonly _assistantId: string;
52
- /** Mutable: a 401 on the guardian path refreshes this in place (see request). */
75
+ /**
76
+ * The credential for the current mode. Mutable: a 401 on the guardian path
77
+ * refreshes it in place (see request), and the platform path fills it in on
78
+ * first use (see resolveAuthHeaders).
79
+ */
53
80
  private token: string | undefined;
54
- /** True when token is a platform session token (X-Session-Token), false for guardian JWT (Authorization: Bearer). */
55
- private readonly isSessionAuth: boolean;
81
+ private readonly authMode: AuthMode;
56
82
  private readonly orgId: string | undefined;
83
+ /** Cached in-flight/resolved platform auth headers; cleared to force a re-resolve. */
84
+ private platformHeaders: Promise<Record<string, string>> | undefined;
57
85
 
58
86
  /**
59
87
  * Resolves an assistant entry from the lockfile and loads auth credentials.
@@ -73,27 +101,108 @@ export class AssistantClient {
73
101
  );
74
102
  }
75
103
 
76
- this.runtimeUrl = (
77
- opts?.runtimeUrl ||
78
- entry.localUrl ||
79
- entry.runtimeUrl ||
80
- FALLBACK_RUNTIME_URL
81
- ).replace(/\/+$/, "");
82
104
  this._assistantId = entry.assistantId;
105
+ const platformManaged = !opts?.sessionToken && entry.cloud === "vellum";
106
+ const platformHost = stripTrailingSlashes(entry.runtimeUrl);
107
+
108
+ // SECURITY: a platform credential is account-scoped, far broader than the
109
+ // per-assistant guardian JWT, so its origin is pinned to the host recorded in
110
+ // the lockfile. Honoring a URL override here would hand the caller's session
111
+ // token or `vak_` key to whatever host they named.
112
+ if (
113
+ platformManaged &&
114
+ opts?.runtimeUrl &&
115
+ stripTrailingSlashes(opts.runtimeUrl) !== platformHost
116
+ ) {
117
+ throw new Error(
118
+ `Refusing to send platform credentials to '${opts.runtimeUrl}': ` +
119
+ `assistant '${entry.assistantId}' is hosted at ${platformHost}.`,
120
+ );
121
+ }
122
+
123
+ this.runtimeUrl = platformManaged
124
+ ? platformHost
125
+ : stripTrailingSlashes(
126
+ opts?.runtimeUrl ||
127
+ entry.localUrl ||
128
+ entry.runtimeUrl ||
129
+ FALLBACK_RUNTIME_URL,
130
+ );
83
131
 
84
132
  if (opts?.sessionToken) {
85
- // Platform assistant: use X-Session-Token + Vellum-Organization-Id.
133
+ // Caller supplied the credential: X-Session-Token + Vellum-Organization-Id.
86
134
  this.token = opts.sessionToken;
87
- this.isSessionAuth = true;
135
+ this.authMode = "session";
88
136
  this.orgId = opts.orgId;
137
+ } else if (platformManaged) {
138
+ // Platform-managed: no guardian token exists locally, so authenticate with
139
+ // the stored platform credential. Resolved on first use because the org id
140
+ // behind it needs a network lookup.
141
+ this.token = undefined;
142
+ this.authMode = "platform";
143
+ this.orgId = undefined;
89
144
  } else {
90
145
  this.token =
91
146
  loadGuardianToken(this._assistantId)?.accessToken ?? entry.bearerToken;
92
- this.isSessionAuth = false;
147
+ this.authMode = "guardian";
93
148
  this.orgId = undefined;
94
149
  }
95
150
  }
96
151
 
152
+ /**
153
+ * Platform token for a `cloud: "vellum"` entry. Throws the actionable login
154
+ * error rather than letting an anonymous request fall through to a bare 403.
155
+ */
156
+ private requirePlatformToken(): string {
157
+ const token = readPlatformToken();
158
+ if (!token) {
159
+ throw new Error(
160
+ "Not logged in. Run `vellum login` first to authenticate with the platform.",
161
+ );
162
+ }
163
+ return token;
164
+ }
165
+
166
+ /**
167
+ * Auth headers for the current mode. `authHeaders` picks the right scheme for
168
+ * the platform credential (`Authorization: Bearer` for `vak_` API keys,
169
+ * `X-Session-Token` + `Vellum-Organization-Id` for session tokens) and caches
170
+ * the org-id lookup, so the result is memoized per client and a failure is
171
+ * never cached.
172
+ */
173
+ private async resolveAuthHeaders(): Promise<Record<string, string>> {
174
+ if (this.authMode !== "platform") {
175
+ const headers: Record<string, string> = {};
176
+ if (this.token) {
177
+ if (this.authMode === "session") {
178
+ headers["X-Session-Token"] = this.token;
179
+ } else {
180
+ headers["Authorization"] = `Bearer ${this.token}`;
181
+ }
182
+ }
183
+ if (this.orgId) {
184
+ headers["Vellum-Organization-Id"] = this.orgId;
185
+ }
186
+ return headers;
187
+ }
188
+
189
+ if (!this.platformHeaders) {
190
+ this.token = this.requirePlatformToken();
191
+ this.platformHeaders = authHeaders(this.token, this.runtimeUrl)
192
+ .then((resolved) => {
193
+ // request() owns Content-Type (bodyless requests must not carry one).
194
+ const headers = { ...resolved };
195
+ delete headers["Content-Type"];
196
+ return headers;
197
+ })
198
+ .catch((err: unknown) => {
199
+ this.platformHeaders = undefined;
200
+ throw err;
201
+ });
202
+ }
203
+ return this.platformHeaders;
204
+ }
205
+
97
206
  /** GET request to the gateway. Auth headers are added automatically. */
98
207
  async get(urlPath: string, opts?: RequestOpts): Promise<Response> {
99
208
  return this.request("GET", urlPath, undefined, opts);
@@ -185,17 +294,13 @@ export class AssistantClient {
185
294
  const jsonBody = body !== undefined ? JSON.stringify(body) : undefined;
186
295
 
187
296
  // Headers are built per-attempt so a refreshed token is picked up on retry.
188
- const buildHeaders = (): Record<string, string> => {
297
+ // Caller-supplied headers win over the resolved auth headers.
298
+ const buildHeaders = async (): Promise<Record<string, string>> => {
189
299
  const headers: Record<string, string> = { ...opts?.headers };
190
- if (this.token) {
191
- if (this.isSessionAuth) {
192
- headers["X-Session-Token"] ??= this.token;
193
- } else {
194
- headers["Authorization"] ??= `Bearer ${this.token}`;
195
- }
196
- }
197
- if (this.orgId) {
198
- headers["Vellum-Organization-Id"] ??= this.orgId;
300
+ for (const [name, value] of Object.entries(
301
+ await this.resolveAuthHeaders(),
302
+ )) {
303
+ headers[name] ??= value;
199
304
  }
200
305
  if (body !== undefined) {
201
306
  headers["Content-Type"] = "application/json";
@@ -203,8 +308,8 @@ export class AssistantClient {
203
308
  return headers;
204
309
  };
205
310
 
206
- const doFetch = (): Promise<Response> => {
207
- const headers = buildHeaders();
311
+ const doFetch = async (): Promise<Response> => {
312
+ const headers = await buildHeaders();
208
313
  if (opts?.signal) {
209
314
  return loopbackSafeFetch(url, {
210
315
  method,
@@ -226,11 +331,20 @@ export class AssistantClient {
226
331
 
227
332
  const response = await doFetch();
228
333
 
229
- // Reactive auto-refresh on a 401 for the guardian (non-session) path.
230
- // Ephemeral (`--token`) and access-only sessions have no stored refresh
231
- // credential and just see the original 401; the platform session-auth path
232
- // is never refreshed here (its token is managed by the Vellum platform).
233
- if (response.status === 401 && !this.isSessionAuth) {
334
+ // A stale cached Vellum-Organization-Id is the usual cause of a 401 on the
335
+ // platform path (e.g. the user switched orgs elsewhere). Drop it and retry
336
+ // once. Mirrors localRuntimeIdentity in local-runtime-client.ts.
337
+ if (response.status === 401 && this.authMode === "platform" && this.token) {
338
+ invalidateOrgIdCache(this.token, this.runtimeUrl);
339
+ this.platformHeaders = undefined;
340
+ return doFetch();
341
+ }
342
+
343
+ // Reactive auto-refresh on a 401 for the guardian path only. Ephemeral
344
+ // (`--token`) and access-only sessions have no stored refresh credential and
345
+ // just see the original 401; session and platform credentials are managed by
346
+ // the Vellum platform and are never refreshed here.
347
+ if (response.status === 401 && this.authMode === "guardian") {
234
348
  const stored = loadGuardianToken(this._assistantId);
235
349
 
236
350
  // Another process may have already rotated and persisted a fresh access
@@ -24,6 +24,7 @@ export function getCloudflareTunnelVersion(): string | null {
24
24
  encoding: "utf-8",
25
25
  timeout: 5_000,
26
26
  stdio: ["ignore", "pipe", "ignore"],
27
+ windowsHide: true,
27
28
  });
28
29
  return output.trim();
29
30
  } catch {
@@ -41,7 +42,7 @@ export function startCloudflareTunnelProcess(targetPort: number): ChildProcess {
41
42
  "cloudflared",
42
43
  ["tunnel", "--url", `http://localhost:${targetPort}`, "--no-autoupdate"],
43
44
  // Keep stdio as pipes so we can parse the URL from output.
44
- { stdio: ["ignore", "pipe", "pipe"] },
45
+ { stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
45
46
  );
46
47
  }
47
48
 
@@ -50,6 +50,7 @@ export async function relaunchDetached(
50
50
  const child = spawn(process.execPath, spawnArgs, {
51
51
  detached: true,
52
52
  stdio: ["ignore", fd, fd],
53
+ windowsHide: true,
53
54
  });
54
55
  if (typeof fd === "number") {
55
56
  closeSync(fd);
@@ -77,7 +77,7 @@ function getMacOSPlatformUUID(): string | null {
77
77
  try {
78
78
  const output = execSync(
79
79
  "ioreg -rd1 -c IOPlatformExpertDevice | awk '/IOPlatformUUID/{print $3}'",
80
- { encoding: "utf-8", timeout: 5000 },
80
+ { encoding: "utf-8", timeout: 5000, windowsHide: true },
81
81
  ).trim();
82
82
  const uuid = output.replace(/"/g, "");
83
83
  return uuid.length > 0 ? uuid : null;
@@ -316,7 +316,9 @@ function isUnreachableGatewayError(error: unknown): boolean {
316
316
  );
317
317
  }
318
318
 
319
- function classifyRefreshThrownError(error: unknown): GuardianTokenRefreshResult {
319
+ function classifyRefreshThrownError(
320
+ error: unknown,
321
+ ): GuardianTokenRefreshResult {
320
322
  if (isTimeoutLikeError(error)) {
321
323
  return {
322
324
  ok: false,
@@ -227,7 +227,11 @@ export async function hatchLocal(
227
227
  }
228
228
  }
229
229
 
230
- if (!apiKeyCheck.hasKey && !pickedProvider) {
230
+ if (
231
+ !apiKeyCheck.hasKey &&
232
+ !pickedProvider &&
233
+ !process.env.VELLUM_DESKTOP_APP
234
+ ) {
231
235
  reporter.warn(
232
236
  "Warning: No LLM provider API key is configured. The assistant will fail when you try to send a message.",
233
237
  );
@@ -53,6 +53,13 @@ type EventName = keyof LiveVoiceClientEvents;
53
53
  export interface CliLiveVoiceClientOptions {
54
54
  readonly url: string;
55
55
  readonly token: string;
56
+ /**
57
+ * Where the credential goes. `header` is the gateway's `Authorization:
58
+ * Bearer` (the local and self-hosted path); `query` means the URL already
59
+ * carries the token velay reads, and no header is sent. Defaults to `header`
60
+ * so existing callers keep the gateway behaviour.
61
+ */
62
+ readonly tokenTransport?: "header" | "query";
56
63
  readonly conversationId?: string;
57
64
  /** Override the socket factory (tests). */
58
65
  readonly webSocketFactory?: (url: string, token: string) => WebSocket;
@@ -118,7 +125,11 @@ export class CliLiveVoiceClient {
118
125
  try {
119
126
  ws = this.options.webSocketFactory
120
127
  ? this.options.webSocketFactory(this.options.url, this.options.token)
121
- : openAuthenticatedSocket(this.options.url, this.options.token);
128
+ : openAuthenticatedSocket(
129
+ this.options.url,
130
+ this.options.token,
131
+ this.options.tokenTransport ?? "header",
132
+ );
122
133
  } catch (err) {
123
134
  this.fail(
124
135
  err instanceof Error
@@ -340,7 +351,17 @@ export class CliLiveVoiceClient {
340
351
  * process listings, shell history, and gateway access logs, and this token is
341
352
  * the bound guardian's.
342
353
  */
343
- function openAuthenticatedSocket(url: string, token: string): WebSocket {
354
+ function openAuthenticatedSocket(
355
+ url: string,
356
+ token: string,
357
+ transport: "header" | "query",
358
+ ): WebSocket {
359
+ // velay reads the credential from the query string and no headers at all,
360
+ // and the URL it was given already carries it. Sending a bearer header there
361
+ // would authenticate nothing and put a single-use token in a second place.
362
+ if (transport === "query") {
363
+ return new WebSocket(url) as WebSocket;
364
+ }
344
365
  return new WebSocket(url, {
345
366
  headers: { Authorization: `Bearer ${token}` },
346
367
  } as unknown as string[]) as WebSocket;