@vellumai/cli 0.8.7 → 0.8.8-dev.202606052332.17fc8ea
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/node_modules/@vellumai/local-mode/package.json +2 -1
- 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 +15 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +15 -8
- package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +42 -0
- package/node_modules/@vellumai/local-mode/src/hatch.ts +22 -4
- package/node_modules/@vellumai/local-mode/src/index.ts +26 -4
- 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 +9 -7
- package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
- package/package.json +1 -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__/guardian-token.test.ts +126 -2
- package/src/__tests__/pair.test.ts +271 -0
- package/src/__tests__/paired-lifecycle.test.ts +116 -0
- package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
- package/src/__tests__/unpair.test.ts +163 -0
- package/src/commands/client.ts +115 -26
- 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/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 +84 -13
- package/src/index.ts +16 -0
- package/src/lib/assistant-client.ts +58 -37
- package/src/lib/assistant-config.ts +12 -0
- 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/resolve.ts +9 -30
- package/src/lib/guardian-token.ts +120 -4
- package/src/lib/local.ts +20 -6
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the `cloud: "paired"` lifecycle guards: `vellum wake`/`vellum sleep`
|
|
3
|
+
* must refuse a remote pairing with a clear "managed on its host machine"
|
|
4
|
+
* message instead of treating it as an on-machine process.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
afterAll,
|
|
8
|
+
afterEach,
|
|
9
|
+
beforeEach,
|
|
10
|
+
describe,
|
|
11
|
+
expect,
|
|
12
|
+
spyOn,
|
|
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(), "paired-lifecycle-test-"));
|
|
20
|
+
const ORIGINAL_LOCKFILE_DIR = process.env.VELLUM_LOCKFILE_DIR;
|
|
21
|
+
const ORIGINAL_CONFIG_HOME = process.env.XDG_CONFIG_HOME;
|
|
22
|
+
const ORIGINAL_ARGV = [...process.argv];
|
|
23
|
+
|
|
24
|
+
import { saveAssistantEntry } from "../lib/assistant-config.js";
|
|
25
|
+
import { retire } from "../commands/retire.js";
|
|
26
|
+
import { sleep } from "../commands/sleep.js";
|
|
27
|
+
import { wake } from "../commands/wake.js";
|
|
28
|
+
|
|
29
|
+
function seedPairedEntry(): void {
|
|
30
|
+
saveAssistantEntry({
|
|
31
|
+
assistantId: "px",
|
|
32
|
+
name: "Paired Box",
|
|
33
|
+
runtimeUrl: "http://10.0.0.9:7830",
|
|
34
|
+
cloud: "paired",
|
|
35
|
+
paired: true,
|
|
36
|
+
species: "vellum",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Run `fn` with console.error + process.exit spied; return {exited, errors}. */
|
|
41
|
+
async function runGuarded(
|
|
42
|
+
fn: () => Promise<void>,
|
|
43
|
+
): Promise<{ exited: boolean; errors: string }> {
|
|
44
|
+
const errors: string[] = [];
|
|
45
|
+
const errSpy = spyOn(console, "error").mockImplementation(
|
|
46
|
+
(...a: unknown[]) => {
|
|
47
|
+
errors.push(a.join(" "));
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((c?: number) => {
|
|
51
|
+
throw new Error(`exit:${c}`);
|
|
52
|
+
}) as never);
|
|
53
|
+
let exited = false;
|
|
54
|
+
try {
|
|
55
|
+
await fn();
|
|
56
|
+
} catch (e) {
|
|
57
|
+
exited = (e as Error).message === "exit:1";
|
|
58
|
+
} finally {
|
|
59
|
+
errSpy.mockRestore();
|
|
60
|
+
exitSpy.mockRestore();
|
|
61
|
+
}
|
|
62
|
+
return { exited, errors: errors.join("\n") };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("paired lifecycle guards", () => {
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
68
|
+
process.env.XDG_CONFIG_HOME = testDir;
|
|
69
|
+
seedPairedEntry();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
process.argv = [...ORIGINAL_ARGV];
|
|
74
|
+
if (ORIGINAL_LOCKFILE_DIR === undefined)
|
|
75
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
76
|
+
else process.env.VELLUM_LOCKFILE_DIR = ORIGINAL_LOCKFILE_DIR;
|
|
77
|
+
if (ORIGINAL_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME;
|
|
78
|
+
else process.env.XDG_CONFIG_HOME = ORIGINAL_CONFIG_HOME;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterAll(() => {
|
|
82
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("wake refuses a paired entry with a host-machine message", async () => {
|
|
86
|
+
process.argv = ["bun", "vellum", "wake", "px"];
|
|
87
|
+
const { exited, errors } = await runGuarded(wake);
|
|
88
|
+
|
|
89
|
+
expect(exited).toBe(true);
|
|
90
|
+
expect(errors).toContain("paired from another machine");
|
|
91
|
+
expect(errors).toContain("vellum client px");
|
|
92
|
+
// It must NOT fall through to the generic local/docker guard.
|
|
93
|
+
expect(errors).not.toContain("only works with local and docker");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("sleep refuses a paired entry with a host-machine message", async () => {
|
|
97
|
+
process.argv = ["bun", "vellum", "sleep", "px"];
|
|
98
|
+
const { exited, errors } = await runGuarded(sleep);
|
|
99
|
+
|
|
100
|
+
expect(exited).toBe(true);
|
|
101
|
+
expect(errors).toContain("paired from another machine");
|
|
102
|
+
expect(errors).toContain("vellum client px");
|
|
103
|
+
expect(errors).not.toContain("only works with local and docker");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("retire refuses a paired entry and points to unpair", async () => {
|
|
107
|
+
process.argv = ["bun", "vellum", "retire", "px", "--yes"];
|
|
108
|
+
const { exited, errors } = await runGuarded(retire);
|
|
109
|
+
|
|
110
|
+
expect(exited).toBe(true);
|
|
111
|
+
expect(errors).toContain("paired from another machine");
|
|
112
|
+
expect(errors).toContain("vellum unpair");
|
|
113
|
+
// It must NOT fall through to the generic "Unknown cloud type" path.
|
|
114
|
+
expect(errors).not.toContain("Unknown cloud type");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for maybeRefreshAuthHeaders: the TUI's mid-session 401 -> refresh of a
|
|
3
|
+
* PAIRED assistant's guardian token, mutating the shared auth headers in place.
|
|
4
|
+
* Scoped to cloud:"paired"; skips local/docker, platform session auth, ephemeral
|
|
5
|
+
* --token, and access-only tokens.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
const ORIGINAL_XDG = process.env.XDG_CONFIG_HOME;
|
|
13
|
+
const ORIGINAL_ENV = process.env.VELLUM_ENVIRONMENT;
|
|
14
|
+
const ORIGINAL_LOCKFILE_DIR = process.env.VELLUM_LOCKFILE_DIR;
|
|
15
|
+
const ORIGINAL_FETCH = globalThis.fetch;
|
|
16
|
+
|
|
17
|
+
import { maybeRefreshAuthHeaders } from "../components/DefaultMainScreen";
|
|
18
|
+
import { saveAssistantEntry } from "../lib/assistant-config";
|
|
19
|
+
import { saveGuardianToken } from "../lib/guardian-token";
|
|
20
|
+
|
|
21
|
+
const RUNTIME = "http://10.0.0.9:7830";
|
|
22
|
+
const future = () => new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
|
23
|
+
|
|
24
|
+
function seedEntry(cloud: string): void {
|
|
25
|
+
saveAssistantEntry({
|
|
26
|
+
assistantId: "px",
|
|
27
|
+
name: "Paired",
|
|
28
|
+
runtimeUrl: RUNTIME,
|
|
29
|
+
cloud,
|
|
30
|
+
paired: cloud === "paired",
|
|
31
|
+
species: "vellum",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function seedToken(accessToken: string, refreshToken: string): void {
|
|
36
|
+
saveGuardianToken("px", {
|
|
37
|
+
guardianPrincipalId: "imported",
|
|
38
|
+
accessToken,
|
|
39
|
+
accessTokenExpiresAt: future(),
|
|
40
|
+
refreshToken,
|
|
41
|
+
refreshTokenExpiresAt: refreshToken ? future() : 0,
|
|
42
|
+
refreshAfter: "",
|
|
43
|
+
isNew: false,
|
|
44
|
+
deviceId: "dev",
|
|
45
|
+
leasedAt: new Date().toISOString(),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stubRefresh(ok: boolean): { hit: () => boolean } {
|
|
50
|
+
let called = false;
|
|
51
|
+
globalThis.fetch = (async (url: unknown, _init?: RequestInit) => {
|
|
52
|
+
if (String(url).includes("/v1/guardian/refresh")) {
|
|
53
|
+
called = true;
|
|
54
|
+
return new Response(
|
|
55
|
+
ok ? JSON.stringify({ accessToken: "new-acc" }) : "x",
|
|
56
|
+
{
|
|
57
|
+
status: ok ? 200 : 401,
|
|
58
|
+
headers: { "content-type": "application/json" },
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return new Response("", { status: 200 });
|
|
63
|
+
}) as typeof fetch;
|
|
64
|
+
return { hit: () => called };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("maybeRefreshAuthHeaders", () => {
|
|
68
|
+
let tempHome: string;
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
tempHome = mkdtempSync(join(tmpdir(), "tui-midsession-test-"));
|
|
72
|
+
process.env.XDG_CONFIG_HOME = tempHome;
|
|
73
|
+
// Isolate the lockfile too — saveAssistantEntry writes the prod lockfile
|
|
74
|
+
// (~/.vellum.lock.json) unless VELLUM_LOCKFILE_DIR is set, which would
|
|
75
|
+
// mutate the real user/CI lockfile.
|
|
76
|
+
process.env.VELLUM_LOCKFILE_DIR = tempHome;
|
|
77
|
+
delete process.env.VELLUM_ENVIRONMENT;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
afterEach(() => {
|
|
81
|
+
globalThis.fetch = ORIGINAL_FETCH;
|
|
82
|
+
if (ORIGINAL_XDG === undefined) delete process.env.XDG_CONFIG_HOME;
|
|
83
|
+
else process.env.XDG_CONFIG_HOME = ORIGINAL_XDG;
|
|
84
|
+
if (ORIGINAL_LOCKFILE_DIR === undefined)
|
|
85
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
86
|
+
else process.env.VELLUM_LOCKFILE_DIR = ORIGINAL_LOCKFILE_DIR;
|
|
87
|
+
if (ORIGINAL_ENV === undefined) delete process.env.VELLUM_ENVIRONMENT;
|
|
88
|
+
else process.env.VELLUM_ENVIRONMENT = ORIGINAL_ENV;
|
|
89
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("refreshes a paired assistant and mutates the auth header in place", async () => {
|
|
93
|
+
seedEntry("paired");
|
|
94
|
+
seedToken("old-acc", "ref");
|
|
95
|
+
const refresh = stubRefresh(true);
|
|
96
|
+
const auth = { Authorization: "Bearer old-acc" };
|
|
97
|
+
|
|
98
|
+
const ok = await maybeRefreshAuthHeaders(RUNTIME, "px", auth);
|
|
99
|
+
|
|
100
|
+
expect(ok).toBe(true);
|
|
101
|
+
expect(auth.Authorization).toBe("Bearer new-acc");
|
|
102
|
+
expect(refresh.hit()).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("does NOT refresh a local assistant (scoped to paired only)", async () => {
|
|
106
|
+
seedEntry("local");
|
|
107
|
+
seedToken("old-acc", "ref"); // even with a refreshable token
|
|
108
|
+
const refresh = stubRefresh(true);
|
|
109
|
+
const auth = { Authorization: "Bearer old-acc" };
|
|
110
|
+
|
|
111
|
+
const ok = await maybeRefreshAuthHeaders(RUNTIME, "px", auth);
|
|
112
|
+
|
|
113
|
+
expect(ok).toBe(false);
|
|
114
|
+
expect(auth.Authorization).toBe("Bearer old-acc");
|
|
115
|
+
expect(refresh.hit()).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("skips platform session auth (no Authorization header)", async () => {
|
|
119
|
+
seedEntry("paired");
|
|
120
|
+
seedToken("old-acc", "ref");
|
|
121
|
+
const refresh = stubRefresh(true);
|
|
122
|
+
const auth = { "X-Session-Token": "sess" };
|
|
123
|
+
|
|
124
|
+
const ok = await maybeRefreshAuthHeaders(RUNTIME, "px", auth);
|
|
125
|
+
|
|
126
|
+
expect(ok).toBe(false);
|
|
127
|
+
expect(refresh.hit()).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("skips an ephemeral token that does not match the store", async () => {
|
|
131
|
+
seedEntry("paired");
|
|
132
|
+
seedToken("stored-acc", "ref");
|
|
133
|
+
const refresh = stubRefresh(true);
|
|
134
|
+
const auth = { Authorization: "Bearer ephemeral-acc" };
|
|
135
|
+
|
|
136
|
+
const ok = await maybeRefreshAuthHeaders(RUNTIME, "px", auth);
|
|
137
|
+
|
|
138
|
+
expect(ok).toBe(false);
|
|
139
|
+
expect(auth.Authorization).toBe("Bearer ephemeral-acc");
|
|
140
|
+
expect(refresh.hit()).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("skips an access-only token (no refresh credential)", async () => {
|
|
144
|
+
seedEntry("paired");
|
|
145
|
+
seedToken("old-acc", ""); // no refresh token
|
|
146
|
+
const refresh = stubRefresh(true);
|
|
147
|
+
const auth = { Authorization: "Bearer old-acc" };
|
|
148
|
+
|
|
149
|
+
const ok = await maybeRefreshAuthHeaders(RUNTIME, "px", auth);
|
|
150
|
+
|
|
151
|
+
expect(ok).toBe(false);
|
|
152
|
+
expect(refresh.hit()).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("returns false and leaves auth unchanged when refresh fails", async () => {
|
|
156
|
+
seedEntry("paired");
|
|
157
|
+
seedToken("old-acc", "ref");
|
|
158
|
+
stubRefresh(false); // refresh endpoint returns non-ok
|
|
159
|
+
const auth = { Authorization: "Bearer old-acc" };
|
|
160
|
+
|
|
161
|
+
const ok = await maybeRefreshAuthHeaders(RUNTIME, "px", auth);
|
|
162
|
+
|
|
163
|
+
expect(ok).toBe(false);
|
|
164
|
+
expect(auth.Authorization).toBe("Bearer old-acc");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `vellum unpair <name>`: forget a paired (cloud:"paired") assistant
|
|
3
|
+
* by removing its lockfile entry + guardian token. Refuses non-paired entries.
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
afterAll,
|
|
7
|
+
afterEach,
|
|
8
|
+
beforeEach,
|
|
9
|
+
describe,
|
|
10
|
+
expect,
|
|
11
|
+
spyOn,
|
|
12
|
+
test,
|
|
13
|
+
} from "bun:test";
|
|
14
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
const testDir = mkdtempSync(join(tmpdir(), "unpair-test-"));
|
|
19
|
+
const ORIGINAL_LOCKFILE_DIR = process.env.VELLUM_LOCKFILE_DIR;
|
|
20
|
+
const ORIGINAL_CONFIG_HOME = process.env.XDG_CONFIG_HOME;
|
|
21
|
+
const ORIGINAL_ARGV = [...process.argv];
|
|
22
|
+
|
|
23
|
+
import { unpair } from "../commands/unpair.js";
|
|
24
|
+
import {
|
|
25
|
+
findAssistantByName,
|
|
26
|
+
saveAssistantEntry,
|
|
27
|
+
} from "../lib/assistant-config.js";
|
|
28
|
+
import {
|
|
29
|
+
deleteGuardianToken,
|
|
30
|
+
loadGuardianToken,
|
|
31
|
+
saveGuardianToken,
|
|
32
|
+
} from "../lib/guardian-token.js";
|
|
33
|
+
|
|
34
|
+
function seedToken(assistantId: string): void {
|
|
35
|
+
saveGuardianToken(assistantId, {
|
|
36
|
+
guardianPrincipalId: "imported",
|
|
37
|
+
accessToken: "acc",
|
|
38
|
+
accessTokenExpiresAt: Date.now() + 3_600_000,
|
|
39
|
+
refreshToken: "ref",
|
|
40
|
+
refreshTokenExpiresAt: Date.now() + 3_600_000,
|
|
41
|
+
refreshAfter: "",
|
|
42
|
+
isNew: false,
|
|
43
|
+
deviceId: "dev",
|
|
44
|
+
leasedAt: new Date().toISOString(),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Run unpair with console.error + process.exit spied. */
|
|
49
|
+
async function runUnpair(): Promise<{ exited: boolean; errors: string }> {
|
|
50
|
+
const errors: string[] = [];
|
|
51
|
+
const logSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
52
|
+
const errSpy = spyOn(console, "error").mockImplementation(
|
|
53
|
+
(...a: unknown[]) => {
|
|
54
|
+
errors.push(a.join(" "));
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((c?: number) => {
|
|
58
|
+
throw new Error(`exit:${c}`);
|
|
59
|
+
}) as never);
|
|
60
|
+
let exited = false;
|
|
61
|
+
try {
|
|
62
|
+
await unpair();
|
|
63
|
+
} catch (e) {
|
|
64
|
+
exited = (e as Error).message === "exit:1";
|
|
65
|
+
} finally {
|
|
66
|
+
logSpy.mockRestore();
|
|
67
|
+
errSpy.mockRestore();
|
|
68
|
+
exitSpy.mockRestore();
|
|
69
|
+
}
|
|
70
|
+
return { exited, errors: errors.join("\n") };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("vellum unpair", () => {
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
76
|
+
process.env.XDG_CONFIG_HOME = testDir;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
process.argv = [...ORIGINAL_ARGV];
|
|
81
|
+
if (ORIGINAL_LOCKFILE_DIR === undefined)
|
|
82
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
83
|
+
else process.env.VELLUM_LOCKFILE_DIR = ORIGINAL_LOCKFILE_DIR;
|
|
84
|
+
if (ORIGINAL_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME;
|
|
85
|
+
else process.env.XDG_CONFIG_HOME = ORIGINAL_CONFIG_HOME;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterAll(() => {
|
|
89
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("removes a paired entry's lockfile entry and guardian token", async () => {
|
|
93
|
+
saveAssistantEntry({
|
|
94
|
+
assistantId: "px",
|
|
95
|
+
name: "Paired Box",
|
|
96
|
+
runtimeUrl: "http://10.0.0.9:7830",
|
|
97
|
+
cloud: "paired",
|
|
98
|
+
paired: true,
|
|
99
|
+
species: "vellum",
|
|
100
|
+
});
|
|
101
|
+
seedToken("px");
|
|
102
|
+
expect(findAssistantByName("px")).not.toBeNull();
|
|
103
|
+
expect(loadGuardianToken("px")).not.toBeNull();
|
|
104
|
+
|
|
105
|
+
// Non-interactive test env → use --yes to bypass the confirmation prompt.
|
|
106
|
+
process.argv = ["bun", "vellum", "unpair", "px", "--yes"];
|
|
107
|
+
const { exited } = await runUnpair();
|
|
108
|
+
|
|
109
|
+
expect(exited).toBe(false);
|
|
110
|
+
expect(findAssistantByName("px")).toBeNull();
|
|
111
|
+
expect(loadGuardianToken("px")).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("refuses to unpair without --yes in a non-interactive terminal", async () => {
|
|
115
|
+
saveAssistantEntry({
|
|
116
|
+
assistantId: "py",
|
|
117
|
+
name: "Paired Two",
|
|
118
|
+
runtimeUrl: "http://10.0.0.9:7830",
|
|
119
|
+
cloud: "paired",
|
|
120
|
+
paired: true,
|
|
121
|
+
species: "vellum",
|
|
122
|
+
});
|
|
123
|
+
seedToken("py");
|
|
124
|
+
|
|
125
|
+
process.argv = ["bun", "vellum", "unpair", "py"]; // no --yes
|
|
126
|
+
const { exited, errors } = await runUnpair();
|
|
127
|
+
|
|
128
|
+
expect(exited).toBe(true);
|
|
129
|
+
expect(errors).toContain("--yes");
|
|
130
|
+
// Not removed — confirmation was required.
|
|
131
|
+
expect(findAssistantByName("py")).not.toBeNull();
|
|
132
|
+
expect(loadGuardianToken("py")).not.toBeNull();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("refuses a non-paired (local) assistant and leaves it intact", async () => {
|
|
136
|
+
saveAssistantEntry({
|
|
137
|
+
assistantId: "desk",
|
|
138
|
+
name: "Desk",
|
|
139
|
+
runtimeUrl: "http://127.0.0.1:7830",
|
|
140
|
+
cloud: "local",
|
|
141
|
+
species: "vellum",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
process.argv = ["bun", "vellum", "unpair", "desk"];
|
|
145
|
+
const { exited, errors } = await runUnpair();
|
|
146
|
+
|
|
147
|
+
expect(exited).toBe(true);
|
|
148
|
+
expect(errors).toContain("vellum retire");
|
|
149
|
+
expect(findAssistantByName("desk")).not.toBeNull(); // untouched
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("errors on an unknown name", async () => {
|
|
153
|
+
process.argv = ["bun", "vellum", "unpair", "nope"];
|
|
154
|
+
const { exited } = await runUnpair();
|
|
155
|
+
expect(exited).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("deleteGuardianToken is a no-op when the token is absent", () => {
|
|
159
|
+
// No token seeded for this id; calling (twice) must not throw.
|
|
160
|
+
expect(() => deleteGuardianToken("ghost")).not.toThrow();
|
|
161
|
+
expect(() => deleteGuardianToken("ghost")).not.toThrow();
|
|
162
|
+
});
|
|
163
|
+
});
|
package/src/commands/client.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
GATEWAY_PORT,
|
|
18
18
|
type Species,
|
|
19
19
|
} from "../lib/constants";
|
|
20
|
-
import { loadGuardianToken } from "../lib/guardian-token";
|
|
20
|
+
import { loadGuardianToken, refreshGuardianToken } from "../lib/guardian-token";
|
|
21
21
|
import { getLocalLanIPv4 } from "../lib/local";
|
|
22
22
|
import {
|
|
23
23
|
CLI_INTERFACE_ID,
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
runRetire,
|
|
33
33
|
getGuardianAccessToken,
|
|
34
34
|
parseGatewayUrl,
|
|
35
|
+
resolveGatewayProxyTarget,
|
|
35
36
|
readAllowedGatewayPorts,
|
|
36
37
|
isLoopbackAddr,
|
|
37
38
|
resolveDevCliInvocation,
|
|
@@ -82,7 +83,8 @@ function readAssistantName(entry: AssistantEntry | null): string | undefined {
|
|
|
82
83
|
: undefined;
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
|
|
86
|
+
// Exported for unit testing the arg/auth resolution without launching the TUI.
|
|
87
|
+
export function parseArgs(): ParsedArgs {
|
|
86
88
|
const args = process.argv.slice(3);
|
|
87
89
|
|
|
88
90
|
const positionalName = parseAssistantTargetArg(args, [
|
|
@@ -92,6 +94,8 @@ function parseArgs(): ParsedArgs {
|
|
|
92
94
|
"-a",
|
|
93
95
|
"--interface",
|
|
94
96
|
"-i",
|
|
97
|
+
"--token",
|
|
98
|
+
"-t",
|
|
95
99
|
]);
|
|
96
100
|
const flagArgs: string[] = [];
|
|
97
101
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -105,7 +109,9 @@ function parseArgs(): ParsedArgs {
|
|
|
105
109
|
arg === "--assistant-id" ||
|
|
106
110
|
arg === "-a" ||
|
|
107
111
|
arg === "--interface" ||
|
|
108
|
-
arg === "-i"
|
|
112
|
+
arg === "-i" ||
|
|
113
|
+
arg === "--token" ||
|
|
114
|
+
arg === "-t") &&
|
|
109
115
|
args[i + 1]
|
|
110
116
|
) {
|
|
111
117
|
flagArgs.push(arg, args[++i]);
|
|
@@ -153,11 +159,31 @@ function parseArgs(): ParsedArgs {
|
|
|
153
159
|
const cloud = entry?.cloud;
|
|
154
160
|
const species: Species = (entry?.species as Species) ?? "vellum";
|
|
155
161
|
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
162
|
+
// Ephemeral auth: a handed-over token (e.g. from `vellum pair`) used for this
|
|
163
|
+
// session only. Resolve it BEFORE the credential lookup below so an ephemeral
|
|
164
|
+
// session never reads (or writes) the local token store.
|
|
165
|
+
let bearerTokenOverride: string | undefined;
|
|
166
|
+
for (let i = 0; i < flagArgs.length; i++) {
|
|
167
|
+
if (
|
|
168
|
+
(flagArgs[i] === "--token" || flagArgs[i] === "-t") &&
|
|
169
|
+
flagArgs[i + 1]
|
|
170
|
+
) {
|
|
171
|
+
bearerTokenOverride = flagArgs[i + 1];
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Platform-hosted assistants (cloud "vellum") use a session token; every
|
|
176
|
+
// other topology — local, docker, and "paired" (a remote assistant paired
|
|
177
|
+
// from another machine) — uses a bearer guardian JWT. Both are skipped
|
|
178
|
+
// entirely when --token supplies the credential, so no saved creds are read.
|
|
179
|
+
const platformToken = bearerTokenOverride
|
|
180
|
+
? undefined
|
|
181
|
+
: cloud === "vellum"
|
|
182
|
+
? (readPlatformToken() ?? undefined)
|
|
183
|
+
: undefined;
|
|
184
|
+
const bearerToken = bearerTokenOverride
|
|
185
|
+
? bearerTokenOverride
|
|
186
|
+
: cloud === "vellum"
|
|
161
187
|
? undefined
|
|
162
188
|
: (loadGuardianToken(entry?.assistantId ?? "")?.accessToken ?? undefined);
|
|
163
189
|
|
|
@@ -247,6 +273,9 @@ ${ANSI.bold}ARGUMENTS:${ANSI.reset}
|
|
|
247
273
|
|
|
248
274
|
${ANSI.bold}OPTIONS:${ANSI.reset}
|
|
249
275
|
-u, --url <url> Runtime URL
|
|
276
|
+
-t, --token <jwt> Bearer token to use for this session (e.g. from
|
|
277
|
+
'vellum pair'). Overrides the stored token and is
|
|
278
|
+
not persisted.
|
|
250
279
|
-a, --assistant-id <id> Assistant ID
|
|
251
280
|
-i, --interface <id> Interface identifier: cli (default) or web
|
|
252
281
|
-h, --help Show this help message
|
|
@@ -260,6 +289,10 @@ ${ANSI.bold}EXAMPLES:${ANSI.reset}
|
|
|
260
289
|
vellum client vellum-assistant-foo
|
|
261
290
|
vellum client --url http://34.56.78.90:${GATEWAY_PORT}
|
|
262
291
|
vellum client vellum-assistant-foo --url http://localhost:${GATEWAY_PORT}
|
|
292
|
+
|
|
293
|
+
# Ephemeral: connect to another machine's assistant with a paired token
|
|
294
|
+
# (no lockfile entry, nothing persisted):
|
|
295
|
+
vellum client --url http://10.0.0.196:${GATEWAY_PORT} --token <jwt>
|
|
263
296
|
`);
|
|
264
297
|
}
|
|
265
298
|
|
|
@@ -433,11 +466,16 @@ async function handleLocalEndpoints(
|
|
|
433
466
|
if (req.method !== "POST") return new Response(null, { status: 405 });
|
|
434
467
|
|
|
435
468
|
let species = "vellum";
|
|
469
|
+
let remote: string | undefined;
|
|
436
470
|
const contentType = req.headers.get("content-type") ?? "";
|
|
437
471
|
if (contentType.includes("json")) {
|
|
438
472
|
try {
|
|
439
|
-
const body = (await req.json()) as {
|
|
473
|
+
const body = (await req.json()) as {
|
|
474
|
+
species?: string;
|
|
475
|
+
remote?: string;
|
|
476
|
+
};
|
|
440
477
|
if (body.species) species = body.species;
|
|
478
|
+
if (body.remote) remote = body.remote;
|
|
441
479
|
} catch {
|
|
442
480
|
return Response.json(
|
|
443
481
|
{ ok: false, error: "Invalid JSON body" },
|
|
@@ -456,7 +494,11 @@ async function handleLocalEndpoints(
|
|
|
456
494
|
);
|
|
457
495
|
}
|
|
458
496
|
|
|
459
|
-
const result = await runHatch(
|
|
497
|
+
const result = await runHatch(
|
|
498
|
+
invocation,
|
|
499
|
+
species,
|
|
500
|
+
remote ? { remote } : undefined,
|
|
501
|
+
);
|
|
460
502
|
if (result.ok) {
|
|
461
503
|
return Response.json({ ok: true, assistantId: result.assistantId });
|
|
462
504
|
}
|
|
@@ -538,21 +580,21 @@ async function handleLocalEndpoints(
|
|
|
538
580
|
return Response.json({ error: result.error }, { status: result.status });
|
|
539
581
|
}
|
|
540
582
|
|
|
541
|
-
// Gateway proxy
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
}
|
|
583
|
+
// Gateway proxy — same allowlist decision the web (Vite middleware) and
|
|
584
|
+
// Electron (`app://` handler) hosts use, so all three can't drift.
|
|
585
|
+
const gatewayDecision = resolveGatewayProxyTarget(pathname, () =>
|
|
586
|
+
readAllowedGatewayPorts(lockfilePaths),
|
|
587
|
+
);
|
|
588
|
+
if (gatewayDecision.kind === "invalid-port") {
|
|
589
|
+
return new Response("Port must be between 1024 and 65535", { status: 400 });
|
|
590
|
+
}
|
|
591
|
+
if (gatewayDecision.kind === "forbidden-port") {
|
|
592
|
+
return new Response("Gateway port is not active in lockfile", {
|
|
593
|
+
status: 403,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
if (gatewayDecision.kind === "forward") {
|
|
597
|
+
const { target: gatewayTarget } = gatewayDecision;
|
|
556
598
|
const targetUrl = `http://127.0.0.1:${gatewayTarget.port}${gatewayTarget.path}${url.search}`;
|
|
557
599
|
const headers = new Headers(req.headers);
|
|
558
600
|
headers.set("host", `127.0.0.1:${gatewayTarget.port}`);
|
|
@@ -779,6 +821,42 @@ async function runViteDevServer(webSourceDir: string): Promise<void> {
|
|
|
779
821
|
});
|
|
780
822
|
}
|
|
781
823
|
|
|
824
|
+
/**
|
|
825
|
+
* Return a possibly-refreshed bearer token for the TUI's startup auth.
|
|
826
|
+
*
|
|
827
|
+
* Only a STORED guardian token is refreshable: platform session auth
|
|
828
|
+
* (`cloud === "vellum"`) and ephemeral `--token` overrides (whose token won't
|
|
829
|
+
* match the store) are left untouched, as is a token that's still fresh. When
|
|
830
|
+
* the stored token has passed its `refreshAfter` (or expiry) and a refresh
|
|
831
|
+
* token is available, refresh once via the concurrency-safe refreshGuardianToken
|
|
832
|
+
* and use the rotated access token. Falls back to the existing token if refresh
|
|
833
|
+
* isn't possible/fails — the session still starts (same as before).
|
|
834
|
+
*/
|
|
835
|
+
export async function resolveFreshBearerToken(
|
|
836
|
+
runtimeUrl: string,
|
|
837
|
+
assistantId: string,
|
|
838
|
+
bearerToken: string | undefined,
|
|
839
|
+
cloud: string | undefined,
|
|
840
|
+
): Promise<string | undefined> {
|
|
841
|
+
if (cloud === "vellum" || !bearerToken || !assistantId) return bearerToken;
|
|
842
|
+
|
|
843
|
+
const stored = loadGuardianToken(assistantId);
|
|
844
|
+
// Refresh only the stored token (an ephemeral --token won't match), and only
|
|
845
|
+
// when a refresh credential is present.
|
|
846
|
+
if (!stored || stored.accessToken !== bearerToken || !stored.refreshToken) {
|
|
847
|
+
return bearerToken;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// new Date() handles both ISO strings and epoch-ms numbers; Date.parse of an
|
|
851
|
+
// epoch-ms string would be NaN.
|
|
852
|
+
const renewAtRaw = stored.refreshAfter || stored.accessTokenExpiresAt;
|
|
853
|
+
const renewAt = new Date(renewAtRaw).getTime();
|
|
854
|
+
if (!Number.isFinite(renewAt) || renewAt > Date.now()) return bearerToken;
|
|
855
|
+
|
|
856
|
+
const refreshed = await refreshGuardianToken(runtimeUrl, assistantId);
|
|
857
|
+
return refreshed?.accessToken ?? bearerToken;
|
|
858
|
+
}
|
|
859
|
+
|
|
782
860
|
export async function client(): Promise<void> {
|
|
783
861
|
const {
|
|
784
862
|
runtimeUrl,
|
|
@@ -826,8 +904,19 @@ export async function client(): Promise<void> {
|
|
|
826
904
|
...getClientRegistrationHeaders(interfaceId),
|
|
827
905
|
};
|
|
828
906
|
} else {
|
|
907
|
+
// Proactively refresh a stale STORED guardian token before opening the TUI,
|
|
908
|
+
// so launching after the access token expired renews transparently rather
|
|
909
|
+
// than erroring. (Mid-session expiry — the token dying while the TUI is
|
|
910
|
+
// already open — is a separate follow-up, since the TUI threads a static
|
|
911
|
+
// auth object through React.)
|
|
912
|
+
const token = await resolveFreshBearerToken(
|
|
913
|
+
runtimeUrl,
|
|
914
|
+
assistantId,
|
|
915
|
+
bearerToken,
|
|
916
|
+
cloud,
|
|
917
|
+
);
|
|
829
918
|
auth = {
|
|
830
|
-
...(
|
|
919
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
831
920
|
...getClientRegistrationHeaders(interfaceId),
|
|
832
921
|
};
|
|
833
922
|
}
|