@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
|
@@ -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
|
+
});
|
package/src/commands/voice.ts
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
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({
|
|
116
|
+
await runSession({
|
|
117
|
+
client,
|
|
118
|
+
player,
|
|
119
|
+
reference: connection.reference,
|
|
120
|
+
...(sayText !== undefined ? { sayText } : {}),
|
|
121
|
+
});
|
|
98
122
|
}
|
|
99
123
|
|
|
100
|
-
|
|
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({
|
|
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
|
-
|
|
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) => {
|
package/src/commands/wake.ts
CHANGED
|
@@ -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(
|
|
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,
|
|
5
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
//
|
|
133
|
+
// Caller supplied the credential: X-Session-Token + Vellum-Organization-Id.
|
|
86
134
|
this.token = opts.sessionToken;
|
|
87
|
-
this.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
|
@@ -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(
|
|
319
|
+
function classifyRefreshThrownError(
|
|
320
|
+
error: unknown,
|
|
321
|
+
): GuardianTokenRefreshResult {
|
|
320
322
|
if (isTimeoutLikeError(error)) {
|
|
321
323
|
return {
|
|
322
324
|
ok: false,
|
package/src/lib/hatch-local.ts
CHANGED
|
@@ -227,7 +227,11 @@ export async function hatchLocal(
|
|
|
227
227
|
}
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
-
if (
|
|
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(
|
|
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(
|
|
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;
|