@vellumai/cli 0.8.7 → 0.8.8

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 (49) hide show
  1. package/node_modules/@vellumai/local-mode/package.json +2 -1
  2. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
  3. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
  4. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +15 -0
  5. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
  6. package/node_modules/@vellumai/local-mode/src/config.ts +15 -8
  7. package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
  8. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +42 -0
  9. package/node_modules/@vellumai/local-mode/src/hatch.ts +22 -4
  10. package/node_modules/@vellumai/local-mode/src/index.ts +26 -4
  11. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
  12. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
  13. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
  14. package/node_modules/@vellumai/local-mode/src/lockfile.ts +9 -7
  15. package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
  16. package/package.json +1 -1
  17. package/src/__tests__/assistant-client-refresh.test.ts +182 -0
  18. package/src/__tests__/clean.test.ts +179 -0
  19. package/src/__tests__/client-token.test.ts +87 -0
  20. package/src/__tests__/client-tui-refresh.test.ts +170 -0
  21. package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
  22. package/src/__tests__/connect-import.test.ts +317 -0
  23. package/src/__tests__/devices.test.ts +272 -0
  24. package/src/__tests__/guardian-token.test.ts +126 -2
  25. package/src/__tests__/pair.test.ts +271 -0
  26. package/src/__tests__/paired-lifecycle.test.ts +116 -0
  27. package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
  28. package/src/__tests__/unpair.test.ts +163 -0
  29. package/src/commands/client.ts +115 -26
  30. package/src/commands/connect/import.ts +217 -0
  31. package/src/commands/connect.ts +31 -0
  32. package/src/commands/devices.ts +247 -0
  33. package/src/commands/pair.ts +222 -0
  34. package/src/commands/ps.ts +16 -0
  35. package/src/commands/retire.ts +20 -47
  36. package/src/commands/sleep.ts +7 -0
  37. package/src/commands/tunnel.ts +46 -2
  38. package/src/commands/unpair.ts +118 -0
  39. package/src/commands/wake.ts +7 -0
  40. package/src/components/DefaultMainScreen.tsx +84 -13
  41. package/src/index.ts +16 -0
  42. package/src/lib/assistant-client.ts +58 -37
  43. package/src/lib/assistant-config.ts +12 -0
  44. package/src/lib/cloudflare-tunnel.ts +276 -0
  45. package/src/lib/confirm-action.ts +57 -0
  46. package/src/lib/docker.ts +25 -1
  47. package/src/lib/environments/resolve.ts +9 -30
  48. package/src/lib/guardian-token.ts +120 -4
  49. 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
+ });
@@ -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
- function parseArgs(): ParsedArgs {
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
- // Platform-hosted assistants use a session token; local assistants use a guardian JWT.
157
- const platformToken =
158
- cloud === "vellum" ? (readPlatformToken() ?? undefined) : undefined;
159
- const bearerToken =
160
- cloud === "vellum"
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 { species?: string };
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(invocation, species);
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
- const gatewayResult = parseGatewayUrl(pathname);
543
- if (gatewayResult.match) {
544
- if (!gatewayResult.valid) {
545
- return new Response("Port must be between 1024 and 65535", {
546
- status: 400,
547
- });
548
- }
549
- const { target: gatewayTarget } = gatewayResult;
550
- const allowedPorts = readAllowedGatewayPorts(lockfilePaths);
551
- if (!allowedPorts.has(gatewayTarget.port)) {
552
- return new Response("Gateway port is not active in lockfile", {
553
- status: 403,
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
- ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
919
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
831
920
  ...getClientRegistrationHeaders(interfaceId),
832
921
  };
833
922
  }