@vellumai/cli 0.8.6 → 0.8.7-dev.202606052135.3e62c5a
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/bun.lock +8 -0
- package/knip.json +5 -1
- package/node_modules/@vellumai/environments/bun.lock +24 -0
- package/node_modules/@vellumai/environments/package.json +18 -0
- package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
- package/node_modules/@vellumai/environments/src/index.ts +11 -0
- package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
- package/node_modules/@vellumai/environments/tsconfig.json +20 -0
- package/node_modules/@vellumai/local-mode/bun.lock +29 -0
- package/node_modules/@vellumai/local-mode/package.json +22 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +108 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +66 -0
- package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +109 -0
- package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
- package/node_modules/@vellumai/local-mode/src/hatch.ts +92 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +48 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +133 -0
- package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
- package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
- package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
- package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
- package/package.json +12 -1
- package/src/__tests__/assistant-client-refresh.test.ts +182 -0
- package/src/__tests__/clean.test.ts +179 -0
- package/src/__tests__/client-token.test.ts +87 -0
- package/src/__tests__/client-tui-refresh.test.ts +170 -0
- package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
- package/src/__tests__/connect-import.test.ts +317 -0
- package/src/__tests__/devices.test.ts +272 -0
- package/src/__tests__/env-drift.test.ts +32 -44
- package/src/__tests__/flags.test.ts +248 -0
- package/src/__tests__/guardian-token.test.ts +126 -2
- package/src/__tests__/multi-local.test.ts +1 -1
- package/src/__tests__/orphan-detection.test.ts +8 -6
- package/src/__tests__/pair.test.ts +271 -0
- package/src/__tests__/paired-lifecycle.test.ts +116 -0
- package/src/__tests__/segments-to-plain-text.test.ts +37 -0
- package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
- package/src/__tests__/unpair.test.ts +163 -0
- package/src/commands/client.ts +511 -11
- package/src/commands/connect/import.ts +217 -0
- package/src/commands/connect.ts +31 -0
- package/src/commands/devices.ts +247 -0
- package/src/commands/env.ts +1 -1
- package/src/commands/flags.ts +89 -17
- package/src/commands/pair.ts +222 -0
- package/src/commands/ps.ts +16 -0
- package/src/commands/retire.ts +20 -47
- package/src/commands/sleep.ts +7 -0
- package/src/commands/tunnel.ts +46 -2
- package/src/commands/unpair.ts +118 -0
- package/src/commands/wake.ts +7 -0
- package/src/components/DefaultMainScreen.tsx +100 -14
- package/src/index.ts +16 -0
- package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
- package/src/lib/assistant-client.ts +58 -37
- package/src/lib/assistant-config.ts +15 -3
- package/src/lib/cloudflare-tunnel.ts +276 -0
- package/src/lib/confirm-action.ts +57 -0
- package/src/lib/docker.ts +25 -1
- package/src/lib/environments/__tests__/paths.test.ts +2 -1
- package/src/lib/environments/__tests__/seeds.test.ts +2 -1
- package/src/lib/environments/paths.ts +1 -1
- package/src/lib/environments/resolve.ts +11 -35
- package/src/lib/guardian-token.ts +132 -9
- package/src/lib/hatch-local.ts +73 -33
- package/src/lib/lifecycle-reporter.ts +31 -0
- package/src/lib/local.ts +20 -6
- package/src/lib/retire-local.ts +28 -14
- package/src/lib/segments-to-plain-text.ts +35 -0
- /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
|
@@ -10,9 +10,12 @@ import {
|
|
|
10
10
|
import { Box, render as inkRender, Text, useInput, useStdout } from "ink";
|
|
11
11
|
|
|
12
12
|
import { SPECIES_CONFIG, type Species } from "../lib/constants";
|
|
13
|
+
import { lookupAssistantByIdentifier } from "../lib/assistant-config";
|
|
13
14
|
import { checkHealth } from "../lib/health-check";
|
|
15
|
+
import { loadGuardianToken, refreshGuardianToken } from "../lib/guardian-token";
|
|
14
16
|
import { appendHistory, loadHistory } from "../lib/input-history";
|
|
15
17
|
import { tuiLog } from "../lib/tui-log";
|
|
18
|
+
import { segmentsToPlainText } from "../lib/segments-to-plain-text";
|
|
16
19
|
import { statusEmoji, withStatusEmoji } from "../lib/status-emoji";
|
|
17
20
|
import {
|
|
18
21
|
getTerminalCapabilities,
|
|
@@ -62,6 +65,9 @@ const HELP_COMMANDS = [
|
|
|
62
65
|
] as const;
|
|
63
66
|
|
|
64
67
|
const SEND_TIMEOUT_MS = 5000;
|
|
68
|
+
/** Fresh deadline for a request retried after a mid-session token refresh —
|
|
69
|
+
* the original caller signal may have already timed out during the refresh. */
|
|
70
|
+
const RETRY_TIMEOUT_MS = 30_000;
|
|
65
71
|
|
|
66
72
|
// ── Layout constants ──────────────────────────────────────
|
|
67
73
|
const MAX_TOTAL_WIDTH = 72;
|
|
@@ -176,6 +182,44 @@ function friendlyErrorMessage(status: number, body: string): string {
|
|
|
176
182
|
return `HTTP ${status}: ${body || "Unknown error"}`;
|
|
177
183
|
}
|
|
178
184
|
|
|
185
|
+
/**
|
|
186
|
+
* On a 401, refresh a stale PAIRED-assistant guardian token and update the
|
|
187
|
+
* shared `auth` headers IN PLACE, returning true if the caller should retry.
|
|
188
|
+
*
|
|
189
|
+
* Scoped to paired assistants only (a remote assistant on another machine,
|
|
190
|
+
* `cloud: "paired"`) — the local/docker TUI flow and platform sessions are left
|
|
191
|
+
* untouched. Also self-gating: skips platform session auth (no `Authorization`
|
|
192
|
+
* header), ephemeral `--token` overrides (whose bearer won't match the store),
|
|
193
|
+
* and access-only tokens. Because the TUI threads one shared `auth` object by
|
|
194
|
+
* reference, mutating it here propagates to every later request and the SSE
|
|
195
|
+
* reconnect — no callback threading needed.
|
|
196
|
+
*/
|
|
197
|
+
export async function maybeRefreshAuthHeaders(
|
|
198
|
+
baseUrl: string,
|
|
199
|
+
assistantId: string,
|
|
200
|
+
auth?: Record<string, string>,
|
|
201
|
+
): Promise<boolean> {
|
|
202
|
+
if (!auth) return false;
|
|
203
|
+
const bearer = auth["Authorization"]?.replace(/^Bearer /, "");
|
|
204
|
+
if (!bearer) return false;
|
|
205
|
+
|
|
206
|
+
// Only paired (remote-on-another-machine) assistants use refreshable pair
|
|
207
|
+
// tokens; don't perturb the local/docker session flow.
|
|
208
|
+
const lookup = lookupAssistantByIdentifier(assistantId);
|
|
209
|
+
if (lookup.status !== "found" || lookup.entry.cloud !== "paired") {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const stored = loadGuardianToken(assistantId);
|
|
214
|
+
if (!stored || stored.accessToken !== bearer || !stored.refreshToken) {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
const refreshed = await refreshGuardianToken(baseUrl, assistantId);
|
|
218
|
+
if (!refreshed?.accessToken) return false;
|
|
219
|
+
auth["Authorization"] = `Bearer ${refreshed.accessToken}`;
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
179
223
|
async function runtimeRequest<T>(
|
|
180
224
|
baseUrl: string,
|
|
181
225
|
assistantId: string,
|
|
@@ -184,14 +228,30 @@ async function runtimeRequest<T>(
|
|
|
184
228
|
auth?: Record<string, string>,
|
|
185
229
|
): Promise<T> {
|
|
186
230
|
const url = `${baseUrl}/v1/assistants/${assistantId}${path}`;
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
231
|
+
const doFetch = (signalOverride?: AbortSignal) =>
|
|
232
|
+
fetch(url, {
|
|
233
|
+
...init,
|
|
234
|
+
// The retry overrides the caller's signal (see below); otherwise use it.
|
|
235
|
+
signal: signalOverride ?? init?.signal,
|
|
236
|
+
headers: {
|
|
237
|
+
"Content-Type": "application/json",
|
|
238
|
+
...auth,
|
|
239
|
+
...(init?.headers as Record<string, string> | undefined),
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
let response = await doFetch();
|
|
244
|
+
// Mid-session token expiry → 401: refresh once and retry (auth headers are
|
|
245
|
+
// mutated in place by the helper, so the retry carries the new token). The
|
|
246
|
+
// refresh can take longer than the caller's timeout (lock wait + refresh
|
|
247
|
+
// fetch), which would already have aborted the original signal — so give the
|
|
248
|
+
// retry a fresh deadline instead of reusing the (likely-expired) one.
|
|
249
|
+
if (
|
|
250
|
+
response.status === 401 &&
|
|
251
|
+
(await maybeRefreshAuthHeaders(baseUrl, assistantId, auth))
|
|
252
|
+
) {
|
|
253
|
+
response = await doFetch(AbortSignal.timeout(RETRY_TIMEOUT_MS));
|
|
254
|
+
}
|
|
195
255
|
|
|
196
256
|
if (!response.ok) {
|
|
197
257
|
const body = await response.text().catch(() => "");
|
|
@@ -230,13 +290,20 @@ async function pollMessages(
|
|
|
230
290
|
auth?: Record<string, string>,
|
|
231
291
|
): Promise<ListMessagesResponse> {
|
|
232
292
|
const params = new URLSearchParams({ conversationKey: assistantId });
|
|
233
|
-
|
|
293
|
+
const response = await runtimeRequest<ListMessagesResponse>(
|
|
234
294
|
baseUrl,
|
|
235
295
|
assistantId,
|
|
236
296
|
`/messages?${params.toString()}`,
|
|
237
297
|
undefined,
|
|
238
298
|
auth,
|
|
239
299
|
);
|
|
300
|
+
return {
|
|
301
|
+
...response,
|
|
302
|
+
messages: response.messages.map((msg) => ({
|
|
303
|
+
...msg,
|
|
304
|
+
content: segmentsToPlainText(msg.textSegments),
|
|
305
|
+
})),
|
|
306
|
+
};
|
|
240
307
|
}
|
|
241
308
|
|
|
242
309
|
async function sendMessage(
|
|
@@ -366,6 +433,11 @@ async function* streamEvents(
|
|
|
366
433
|
const params = new URLSearchParams({ conversationKey });
|
|
367
434
|
const url = `${baseUrl}/v1/assistants/${assistantId}/events?${params.toString()}`;
|
|
368
435
|
tuiLog.info("sse connect", { url, authHeaders: Object.keys(auth ?? {}) });
|
|
436
|
+
// NOTE: the SSE connect deliberately does NOT refresh-on-401 — keeping the
|
|
437
|
+
// stream path simple. After a mid-session token expiry, the REST path
|
|
438
|
+
// (runtimeRequest) refreshes the shared `auth` on the next request, and the
|
|
439
|
+
// existing reconnect (ensureConnected on the next message) re-opens the
|
|
440
|
+
// stream with the refreshed token.
|
|
369
441
|
const response = await fetch(url, {
|
|
370
442
|
headers: {
|
|
371
443
|
Accept: "text/event-stream",
|
|
@@ -626,6 +698,13 @@ export interface RuntimeMessage {
|
|
|
626
698
|
id: string;
|
|
627
699
|
role: "user" | "assistant";
|
|
628
700
|
content: string;
|
|
701
|
+
/**
|
|
702
|
+
* Ordered text segments from the daemon's history payload, split at
|
|
703
|
+
* tool_use/surface boundaries. The flat `content` body is derived from
|
|
704
|
+
* these (see `segmentsToPlainText`); the daemon no longer sends a
|
|
705
|
+
* redundant flattened `content` field on the wire.
|
|
706
|
+
*/
|
|
707
|
+
textSegments?: string[];
|
|
629
708
|
timestamp: string;
|
|
630
709
|
toolCalls?: ToolCallInfo[];
|
|
631
710
|
label?: string;
|
|
@@ -1969,9 +2048,8 @@ function ChatApp({
|
|
|
1969
2048
|
if (!isConnected) return;
|
|
1970
2049
|
|
|
1971
2050
|
try {
|
|
1972
|
-
const
|
|
1973
|
-
`${runtimeUrl}/v1/assistants/${assistantId}/btw`,
|
|
1974
|
-
{
|
|
2051
|
+
const btwFetch = () =>
|
|
2052
|
+
fetch(`${runtimeUrl}/v1/assistants/${assistantId}/btw`, {
|
|
1975
2053
|
method: "POST",
|
|
1976
2054
|
headers: {
|
|
1977
2055
|
"Content-Type": "application/json",
|
|
@@ -1982,8 +2060,16 @@ function ChatApp({
|
|
|
1982
2060
|
content: question,
|
|
1983
2061
|
}),
|
|
1984
2062
|
signal: AbortSignal.timeout(30_000),
|
|
1985
|
-
}
|
|
1986
|
-
|
|
2063
|
+
});
|
|
2064
|
+
|
|
2065
|
+
let res = await btwFetch();
|
|
2066
|
+
// Mid-session token expiry → 401: refresh once and retry.
|
|
2067
|
+
if (
|
|
2068
|
+
res.status === 401 &&
|
|
2069
|
+
(await maybeRefreshAuthHeaders(runtimeUrl, assistantId, auth))
|
|
2070
|
+
) {
|
|
2071
|
+
res = await btwFetch();
|
|
2072
|
+
}
|
|
1987
2073
|
|
|
1988
2074
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
1989
2075
|
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,8 @@ import cliPkg from "../package.json";
|
|
|
4
4
|
import { backup } from "./commands/backup";
|
|
5
5
|
import { clean } from "./commands/clean";
|
|
6
6
|
import { client } from "./commands/client";
|
|
7
|
+
import { connect } from "./commands/connect";
|
|
8
|
+
import { devices } from "./commands/devices";
|
|
7
9
|
import { env } from "./commands/env";
|
|
8
10
|
import { events } from "./commands/events";
|
|
9
11
|
import { exec } from "./commands/exec";
|
|
@@ -13,6 +15,7 @@ import { hatch } from "./commands/hatch";
|
|
|
13
15
|
import { login, logout, whoami } from "./commands/login";
|
|
14
16
|
import { logs } from "./commands/logs";
|
|
15
17
|
import { message } from "./commands/message";
|
|
18
|
+
import { pair } from "./commands/pair";
|
|
16
19
|
import { ps } from "./commands/ps";
|
|
17
20
|
import { recover } from "./commands/recover";
|
|
18
21
|
import { restore } from "./commands/restore";
|
|
@@ -25,6 +28,7 @@ import { ssh } from "./commands/ssh";
|
|
|
25
28
|
import { teleport } from "./commands/teleport";
|
|
26
29
|
import { terminal } from "./commands/terminal";
|
|
27
30
|
import { tunnel } from "./commands/tunnel";
|
|
31
|
+
import { unpair } from "./commands/unpair";
|
|
28
32
|
import { upgrade } from "./commands/upgrade";
|
|
29
33
|
import { use } from "./commands/use";
|
|
30
34
|
import { wake } from "./commands/wake";
|
|
@@ -36,6 +40,8 @@ const commands = {
|
|
|
36
40
|
backup,
|
|
37
41
|
clean,
|
|
38
42
|
client,
|
|
43
|
+
connect,
|
|
44
|
+
devices,
|
|
39
45
|
env,
|
|
40
46
|
events,
|
|
41
47
|
exec,
|
|
@@ -46,6 +52,7 @@ const commands = {
|
|
|
46
52
|
logout,
|
|
47
53
|
logs,
|
|
48
54
|
message,
|
|
55
|
+
pair,
|
|
49
56
|
ps,
|
|
50
57
|
recover,
|
|
51
58
|
restore,
|
|
@@ -58,6 +65,7 @@ const commands = {
|
|
|
58
65
|
teleport,
|
|
59
66
|
terminal,
|
|
60
67
|
tunnel,
|
|
68
|
+
unpair,
|
|
61
69
|
upgrade,
|
|
62
70
|
use,
|
|
63
71
|
wake,
|
|
@@ -73,6 +81,8 @@ function printHelp(): void {
|
|
|
73
81
|
console.log(" backup Export a backup of a running assistant");
|
|
74
82
|
console.log(" clean Kill orphaned vellum processes");
|
|
75
83
|
console.log(" client Connect to a hatched assistant");
|
|
84
|
+
console.log(" connect Import an assistant paired from another machine");
|
|
85
|
+
console.log(" devices List or revoke devices paired to a local assistant");
|
|
76
86
|
console.log(" env Manage the default CLI environment");
|
|
77
87
|
console.log(" events Stream events from a running assistant");
|
|
78
88
|
console.log(" exec Execute a command inside an assistant's container");
|
|
@@ -83,6 +93,9 @@ function printHelp(): void {
|
|
|
83
93
|
console.log(" login Log in to the Vellum platform");
|
|
84
94
|
console.log(" logout Log out of the Vellum platform");
|
|
85
95
|
console.log(" message Send a message to a running assistant");
|
|
96
|
+
console.log(
|
|
97
|
+
" pair Mint a device-scoped token to connect another machine",
|
|
98
|
+
);
|
|
86
99
|
console.log(
|
|
87
100
|
" ps List assistants (or processes for a specific assistant)",
|
|
88
101
|
);
|
|
@@ -99,6 +112,9 @@ function printHelp(): void {
|
|
|
99
112
|
console.log(" teleport Transfer assistant data between environments");
|
|
100
113
|
console.log(" terminal Open a terminal into a managed assistant container");
|
|
101
114
|
console.log(" tunnel Create a tunnel for a locally hosted assistant");
|
|
115
|
+
console.log(
|
|
116
|
+
" unpair Forget a paired assistant imported from another machine",
|
|
117
|
+
);
|
|
102
118
|
console.log(" upgrade Upgrade an assistant to a newer version");
|
|
103
119
|
console.log(" use Set the active assistant for commands");
|
|
104
120
|
console.log(" wake Start the assistant and gateway");
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { consoleLifecycleReporter } from "../lifecycle-reporter.js";
|
|
4
|
+
|
|
5
|
+
describe("consoleLifecycleReporter", () => {
|
|
6
|
+
const originalDesktopApp = process.env.VELLUM_DESKTOP_APP;
|
|
7
|
+
let stdoutWriteSpy: ReturnType<typeof spyOn>;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
stdoutWriteSpy = spyOn(process.stdout, "write").mockImplementation(
|
|
11
|
+
() => true,
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
stdoutWriteSpy.mockRestore();
|
|
17
|
+
if (originalDesktopApp === undefined) {
|
|
18
|
+
delete process.env.VELLUM_DESKTOP_APP;
|
|
19
|
+
} else {
|
|
20
|
+
process.env.VELLUM_DESKTOP_APP = originalDesktopApp;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("routes log/warn/error to the matching console methods", () => {
|
|
25
|
+
const logSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
26
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
27
|
+
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
28
|
+
|
|
29
|
+
consoleLifecycleReporter.log("hello");
|
|
30
|
+
consoleLifecycleReporter.warn("careful");
|
|
31
|
+
consoleLifecycleReporter.error("boom");
|
|
32
|
+
|
|
33
|
+
expect(logSpy).toHaveBeenCalledWith("hello");
|
|
34
|
+
expect(warnSpy).toHaveBeenCalledWith("careful");
|
|
35
|
+
expect(errorSpy).toHaveBeenCalledWith("boom");
|
|
36
|
+
|
|
37
|
+
logSpy.mockRestore();
|
|
38
|
+
warnSpy.mockRestore();
|
|
39
|
+
errorSpy.mockRestore();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("emits the HATCH_PROGRESS stdout contract under VELLUM_DESKTOP_APP", () => {
|
|
43
|
+
process.env.VELLUM_DESKTOP_APP = "1";
|
|
44
|
+
|
|
45
|
+
consoleLifecycleReporter.progress(3, 6, "Starting assistant...");
|
|
46
|
+
|
|
47
|
+
expect(stdoutWriteSpy).toHaveBeenCalledWith(
|
|
48
|
+
`HATCH_PROGRESS:${JSON.stringify({ step: 3, total: 6, label: "Starting assistant..." })}\n`,
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("suppresses progress output when not running under the desktop app", () => {
|
|
53
|
+
delete process.env.VELLUM_DESKTOP_APP;
|
|
54
|
+
|
|
55
|
+
consoleLifecycleReporter.progress(1, 6, "Allocating resources...");
|
|
56
|
+
|
|
57
|
+
expect(stdoutWriteSpy).not.toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -12,11 +12,9 @@
|
|
|
12
12
|
* ```
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
16
|
-
resolveAssistant,
|
|
17
|
-
} from "./assistant-config.js";
|
|
15
|
+
import { resolveAssistant } from "./assistant-config.js";
|
|
18
16
|
import { GATEWAY_PORT } from "./constants.js";
|
|
19
|
-
import { loadGuardianToken } from "./guardian-token.js";
|
|
17
|
+
import { loadGuardianToken, refreshGuardianToken } from "./guardian-token.js";
|
|
20
18
|
|
|
21
19
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
22
20
|
const FALLBACK_RUNTIME_URL = `http://127.0.0.1:${GATEWAY_PORT}`;
|
|
@@ -45,7 +43,8 @@ export class AssistantClient {
|
|
|
45
43
|
readonly runtimeUrl: string;
|
|
46
44
|
|
|
47
45
|
private readonly _assistantId: string;
|
|
48
|
-
|
|
46
|
+
/** Mutable: a 401 on the guardian path refreshes this in place (see request). */
|
|
47
|
+
private token: string | undefined;
|
|
49
48
|
/** True when token is a platform session token (X-Session-Token), false for guardian JWT (Authorization: Bearer). */
|
|
50
49
|
private readonly isSessionAuth: boolean;
|
|
51
50
|
private readonly orgId: string | undefined;
|
|
@@ -176,45 +175,67 @@ export class AssistantClient {
|
|
|
176
175
|
? `?${new URLSearchParams(opts.query).toString()}`
|
|
177
176
|
: "";
|
|
178
177
|
const url = `${this.runtimeUrl}/v1/assistants/${this._assistantId}${urlPath}${qs}`;
|
|
179
|
-
|
|
180
|
-
const headers: Record<string, string> = { ...opts?.headers };
|
|
181
|
-
if (this.token) {
|
|
182
|
-
if (this.isSessionAuth) {
|
|
183
|
-
headers["X-Session-Token"] ??= this.token;
|
|
184
|
-
} else {
|
|
185
|
-
headers["Authorization"] ??= `Bearer ${this.token}`;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
if (this.orgId) {
|
|
189
|
-
headers["Vellum-Organization-Id"] ??= this.orgId;
|
|
190
|
-
}
|
|
191
|
-
if (body !== undefined) {
|
|
192
|
-
headers["Content-Type"] = "application/json";
|
|
193
|
-
}
|
|
194
|
-
|
|
195
178
|
const jsonBody = body !== undefined ? JSON.stringify(body) : undefined;
|
|
196
179
|
|
|
197
|
-
|
|
180
|
+
// Headers are built per-attempt so a refreshed token is picked up on retry.
|
|
181
|
+
const buildHeaders = (): Record<string, string> => {
|
|
182
|
+
const headers: Record<string, string> = { ...opts?.headers };
|
|
183
|
+
if (this.token) {
|
|
184
|
+
if (this.isSessionAuth) {
|
|
185
|
+
headers["X-Session-Token"] ??= this.token;
|
|
186
|
+
} else {
|
|
187
|
+
headers["Authorization"] ??= `Bearer ${this.token}`;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (this.orgId) {
|
|
191
|
+
headers["Vellum-Organization-Id"] ??= this.orgId;
|
|
192
|
+
}
|
|
193
|
+
if (body !== undefined) {
|
|
194
|
+
headers["Content-Type"] = "application/json";
|
|
195
|
+
}
|
|
196
|
+
return headers;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const doFetch = (): Promise<Response> => {
|
|
200
|
+
const headers = buildHeaders();
|
|
201
|
+
if (opts?.signal) {
|
|
202
|
+
return fetch(url, {
|
|
203
|
+
method,
|
|
204
|
+
headers,
|
|
205
|
+
body: jsonBody,
|
|
206
|
+
signal: opts.signal,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
210
|
+
const controller = new AbortController();
|
|
211
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
198
212
|
return fetch(url, {
|
|
199
|
-
method,
|
|
200
|
-
headers,
|
|
201
|
-
body: jsonBody,
|
|
202
|
-
signal: opts.signal,
|
|
203
|
-
});
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
207
|
-
const controller = new AbortController();
|
|
208
|
-
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
209
|
-
try {
|
|
210
|
-
return await fetch(url, {
|
|
211
213
|
method,
|
|
212
214
|
headers,
|
|
213
215
|
body: jsonBody,
|
|
214
216
|
signal: controller.signal,
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
|
|
217
|
+
}).finally(() => clearTimeout(timeoutId));
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const response = await doFetch();
|
|
221
|
+
|
|
222
|
+
// Reactive auto-refresh: a paired/local guardian access token that has
|
|
223
|
+
// expired comes back 401. Refresh it once via the stored refresh credential
|
|
224
|
+
// and retry. Self-gating — refreshGuardianToken returns null unless a usable
|
|
225
|
+
// refresh token is stored, so ephemeral (`--token`) and access-only sessions
|
|
226
|
+
// just see the original 401. The platform session-auth path is never
|
|
227
|
+
// refreshed here (its token is managed by the Vellum platform).
|
|
228
|
+
if (response.status === 401 && !this.isSessionAuth) {
|
|
229
|
+
const refreshed = await refreshGuardianToken(
|
|
230
|
+
this.runtimeUrl,
|
|
231
|
+
this._assistantId,
|
|
232
|
+
);
|
|
233
|
+
if (refreshed?.accessToken) {
|
|
234
|
+
this.token = refreshed.accessToken;
|
|
235
|
+
return doFetch();
|
|
236
|
+
}
|
|
218
237
|
}
|
|
238
|
+
|
|
239
|
+
return response;
|
|
219
240
|
}
|
|
220
241
|
}
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
import { homedir } from "os";
|
|
11
11
|
import { dirname, join } from "path";
|
|
12
12
|
|
|
13
|
+
import { SEEDS, type EnvironmentDefinition } from "@vellumai/environments";
|
|
14
|
+
|
|
13
15
|
import { DAEMON_INTERNAL_ASSISTANT_ID } from "./constants.js";
|
|
14
16
|
import {
|
|
15
17
|
getDefaultPorts,
|
|
@@ -18,8 +20,6 @@ import {
|
|
|
18
20
|
getMultiInstanceDir,
|
|
19
21
|
} from "./environments/paths.js";
|
|
20
22
|
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
21
|
-
import { SEEDS } from "./environments/seeds.js";
|
|
22
|
-
import type { EnvironmentDefinition } from "./environments/types.js";
|
|
23
23
|
import { probePort } from "./port-probe.js";
|
|
24
24
|
|
|
25
25
|
/**
|
|
@@ -76,7 +76,19 @@ export interface AssistantEntry {
|
|
|
76
76
|
* Avoids mDNS resolution issues when the machine checks its own gateway. */
|
|
77
77
|
localUrl?: string;
|
|
78
78
|
bearerToken?: string;
|
|
79
|
+
/** Deployment topology / how the assistant is reached. Known values:
|
|
80
|
+
* `"local"` (on-machine daemon), `"docker"` (local container),
|
|
81
|
+
* `"apple-container"` (macOS-app-managed container), `"vellum"`
|
|
82
|
+
* (platform-managed, uses the X-Session-Token auth path), `"gcp"` / `"aws"`
|
|
83
|
+
* / `"custom"` (remote, SSH-managed), and `"paired"` (a remote assistant
|
|
84
|
+
* paired from another machine — reached via a bearer guardian token at
|
|
85
|
+
* `runtimeUrl`; has no local process, container, or `resources`).
|
|
86
|
+
* Kept as a free `string` (not a union) for forward-compatibility. */
|
|
79
87
|
cloud: string;
|
|
88
|
+
/** True when this entry was registered via `vellum connect import` (a remote
|
|
89
|
+
* pairing). Set alongside `cloud: "paired"`; also backs the re-import /
|
|
90
|
+
* overwrite guard in connect import. */
|
|
91
|
+
paired?: boolean;
|
|
80
92
|
instanceId?: string;
|
|
81
93
|
namespace?: string;
|
|
82
94
|
project?: string;
|
|
@@ -631,7 +643,7 @@ export async function allocateLocalResources(
|
|
|
631
643
|
|
|
632
644
|
// Env-aware bases: non-prod envs sit in their own 1000-port window so
|
|
633
645
|
// running prod and staging assistants side-by-side doesn't collide. See
|
|
634
|
-
// `
|
|
646
|
+
// the `@vellumai/environments` `portBlock` layout.
|
|
635
647
|
const basePorts = getDefaultPorts(env);
|
|
636
648
|
const daemonPort = await findAvailablePort(basePorts.daemon, reservedPorts);
|
|
637
649
|
const gatewayPort = await findAvailablePort(basePorts.gateway, [
|