@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.
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,182 @@
1
+ /**
2
+ * Tests for AssistantClient's reactive 401 -> refresh -> retry: a paired/local
3
+ * guardian access token that 401s is refreshed once via the stored refresh
4
+ * credential and the request retried. Self-gating (no refresh token => no
5
+ * retry) and never applied to the platform session-auth path.
6
+ */
7
+ import {
8
+ afterAll,
9
+ afterEach,
10
+ beforeEach,
11
+ describe,
12
+ expect,
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(), "client-refresh-test-"));
20
+ const ORIGINAL_LOCKFILE_DIR = process.env.VELLUM_LOCKFILE_DIR;
21
+ const ORIGINAL_CONFIG_HOME = process.env.XDG_CONFIG_HOME;
22
+ const ORIGINAL_FETCH = globalThis.fetch;
23
+
24
+ import { AssistantClient } from "../lib/assistant-client.js";
25
+ import { saveAssistantEntry } from "../lib/assistant-config.js";
26
+ import { loadGuardianToken, saveGuardianToken } from "../lib/guardian-token.js";
27
+
28
+ const RUNTIME = "http://10.0.0.9:7830";
29
+ const FUTURE = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
30
+
31
+ function seedPaired(refreshToken: string): void {
32
+ saveAssistantEntry({
33
+ assistantId: "px",
34
+ name: "Paired",
35
+ runtimeUrl: RUNTIME,
36
+ cloud: "paired",
37
+ paired: true,
38
+ species: "vellum",
39
+ });
40
+ saveGuardianToken("px", {
41
+ guardianPrincipalId: "imported",
42
+ accessToken: "old-acc",
43
+ accessTokenExpiresAt: FUTURE,
44
+ refreshToken,
45
+ refreshTokenExpiresAt: refreshToken ? FUTURE : 0,
46
+ refreshAfter: "",
47
+ isNew: false,
48
+ deviceId: "dev",
49
+ leasedAt: new Date().toISOString(),
50
+ });
51
+ }
52
+
53
+ interface Call {
54
+ url: string;
55
+ headers: Record<string, string>;
56
+ }
57
+
58
+ /** Replace global fetch with a URL-routed stub; returns the call log. */
59
+ function stubFetch(handler: (url: string, calls: Call[]) => Response): Call[] {
60
+ const calls: Call[] = [];
61
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
62
+ const url = typeof input === "string" ? input : String(input);
63
+ calls.push({
64
+ url,
65
+ headers: (init?.headers ?? {}) as Record<string, string>,
66
+ });
67
+ return handler(url, calls);
68
+ }) as typeof fetch;
69
+ return calls;
70
+ }
71
+
72
+ const isRefresh = (url: string) => url.includes("/v1/guardian/refresh");
73
+
74
+ function refreshResponse(): Response {
75
+ return new Response(
76
+ JSON.stringify({
77
+ accessToken: "new-acc",
78
+ refreshToken: "new-ref",
79
+ accessTokenExpiresAt: FUTURE,
80
+ refreshTokenExpiresAt: FUTURE,
81
+ refreshAfter: "",
82
+ }),
83
+ { status: 200, headers: { "content-type": "application/json" } },
84
+ );
85
+ }
86
+
87
+ describe("AssistantClient 401 -> refresh -> retry", () => {
88
+ beforeEach(() => {
89
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
90
+ process.env.XDG_CONFIG_HOME = testDir;
91
+ });
92
+
93
+ afterEach(() => {
94
+ globalThis.fetch = ORIGINAL_FETCH;
95
+ if (ORIGINAL_LOCKFILE_DIR === undefined)
96
+ delete process.env.VELLUM_LOCKFILE_DIR;
97
+ else process.env.VELLUM_LOCKFILE_DIR = ORIGINAL_LOCKFILE_DIR;
98
+ if (ORIGINAL_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME;
99
+ else process.env.XDG_CONFIG_HOME = ORIGINAL_CONFIG_HOME;
100
+ });
101
+
102
+ afterAll(() => {
103
+ rmSync(testDir, { recursive: true, force: true });
104
+ });
105
+
106
+ test("refreshes and retries once on 401, persisting the new token", async () => {
107
+ seedPaired("refresh-tok");
108
+ let assistantAttempts = 0;
109
+ const calls = stubFetch((url) => {
110
+ if (isRefresh(url)) return refreshResponse();
111
+ assistantAttempts++;
112
+ return new Response("", { status: assistantAttempts === 1 ? 401 : 200 });
113
+ });
114
+
115
+ const client = new AssistantClient({ assistantId: "px" });
116
+ const res = await client.get("/messages/");
117
+
118
+ expect(res.status).toBe(200);
119
+ expect(assistantAttempts).toBe(2); // original + one retry
120
+ expect(calls.filter((c) => isRefresh(c.url))).toHaveLength(1);
121
+ // The retry carried the refreshed bearer token.
122
+ const assistantCalls = calls.filter((c) => !isRefresh(c.url));
123
+ expect(assistantCalls[1].headers["Authorization"]).toBe("Bearer new-acc");
124
+ // refreshGuardianToken persisted the rotated token.
125
+ expect(loadGuardianToken("px")?.accessToken).toBe("new-acc");
126
+ });
127
+
128
+ test("does not retry when there is no stored refresh token", async () => {
129
+ seedPaired(""); // access-only
130
+ let assistantAttempts = 0;
131
+ const calls = stubFetch((url) => {
132
+ if (isRefresh(url)) return refreshResponse();
133
+ assistantAttempts++;
134
+ return new Response("", { status: 401 });
135
+ });
136
+
137
+ const client = new AssistantClient({ assistantId: "px" });
138
+ const res = await client.get("/messages/");
139
+
140
+ expect(res.status).toBe(401);
141
+ expect(assistantAttempts).toBe(1); // no retry
142
+ expect(calls.filter((c) => isRefresh(c.url))).toHaveLength(0);
143
+ });
144
+
145
+ test("never refreshes on the platform session-auth path", async () => {
146
+ seedPaired("refresh-tok"); // entry must exist; session auth ignores it
147
+ let assistantAttempts = 0;
148
+ const calls = stubFetch((url) => {
149
+ if (isRefresh(url)) return refreshResponse();
150
+ assistantAttempts++;
151
+ return new Response("", { status: 401 });
152
+ });
153
+
154
+ const client = new AssistantClient({
155
+ assistantId: "px",
156
+ sessionToken: "sess-tok",
157
+ orgId: "org-1",
158
+ });
159
+ const res = await client.get("/messages/");
160
+
161
+ expect(res.status).toBe(401);
162
+ expect(assistantAttempts).toBe(1);
163
+ expect(calls.filter((c) => isRefresh(c.url))).toHaveLength(0);
164
+ });
165
+
166
+ test("retries at most once (second 401 is not refreshed again)", async () => {
167
+ seedPaired("refresh-tok");
168
+ let assistantAttempts = 0;
169
+ const calls = stubFetch((url) => {
170
+ if (isRefresh(url)) return refreshResponse();
171
+ assistantAttempts++;
172
+ return new Response("", { status: 401 }); // always 401
173
+ });
174
+
175
+ const client = new AssistantClient({ assistantId: "px" });
176
+ const res = await client.get("/messages/");
177
+
178
+ expect(res.status).toBe(401);
179
+ expect(assistantAttempts).toBe(2); // original + one retry, no more
180
+ expect(calls.filter((c) => isRefresh(c.url))).toHaveLength(1);
181
+ });
182
+ });
@@ -0,0 +1,179 @@
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeAll,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ mock,
9
+ spyOn,
10
+ test,
11
+ } from "bun:test";
12
+
13
+ import type { OrphanedProcess } from "../lib/orphan-detection.js";
14
+
15
+ // ── Module mocks (must be set up before importing the command) ───────────────
16
+
17
+ const detectOrphansMock = mock(async (): Promise<OrphanedProcess[]> => []);
18
+ const stopProcessMock = mock(
19
+ async (_pid: number, _label: string): Promise<boolean> => true,
20
+ );
21
+
22
+ beforeAll(() => {
23
+ mock.module("../lib/orphan-detection.js", () => ({
24
+ detectOrphanedProcesses: detectOrphansMock,
25
+ }));
26
+ mock.module("../lib/process.js", () => ({
27
+ stopProcess: stopProcessMock,
28
+ }));
29
+ });
30
+
31
+ import { clean } from "../commands/clean.js";
32
+
33
+ // ── Helpers ───────────────────────────────────────────────────────────────────
34
+
35
+ function makeOrphan(
36
+ name: string,
37
+ pid: string,
38
+ source = "process table",
39
+ ): OrphanedProcess {
40
+ return { name, pid, source };
41
+ }
42
+
43
+ const originalArgv = [...process.argv];
44
+
45
+ let consoleLogSpy: ReturnType<typeof spyOn>;
46
+ let consoleErrorSpy: ReturnType<typeof spyOn>;
47
+ let exitSpy: ReturnType<typeof spyOn>;
48
+
49
+ beforeEach(() => {
50
+ consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
51
+ consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
52
+ exitSpy = spyOn(process, "exit").mockImplementation((_code?: number) => {
53
+ throw new Error(`process.exit(${_code})`);
54
+ });
55
+ detectOrphansMock.mockClear();
56
+ stopProcessMock.mockClear();
57
+ });
58
+
59
+ afterEach(() => {
60
+ process.argv = [...originalArgv];
61
+ consoleLogSpy.mockRestore();
62
+ consoleErrorSpy.mockRestore();
63
+ exitSpy.mockRestore();
64
+ });
65
+
66
+ afterAll(() => {
67
+ process.argv = [...originalArgv];
68
+ });
69
+
70
+ // ── Tests ─────────────────────────────────────────────────────────────────────
71
+
72
+ describe("vellum clean --help", () => {
73
+ test("prints usage and exits 0", async () => {
74
+ process.argv = ["bun", "vellum", "clean", "--help"];
75
+ await expect(clean()).rejects.toThrow("process.exit(0)");
76
+ const output = consoleLogSpy.mock.calls.flat().join("\n");
77
+ expect(output).toContain("Usage: vellum clean");
78
+ expect(output).toContain("orphaned");
79
+ });
80
+
81
+ test("-h is accepted as an alias for --help", async () => {
82
+ process.argv = ["bun", "vellum", "clean", "-h"];
83
+ await expect(clean()).rejects.toThrow("process.exit(0)");
84
+ });
85
+ });
86
+
87
+ describe("vellum clean — no orphans", () => {
88
+ test("prints nothing-to-do message when no orphans are found", async () => {
89
+ detectOrphansMock.mockResolvedValueOnce([]);
90
+ process.argv = ["bun", "vellum", "clean"];
91
+ await clean();
92
+ const output = consoleLogSpy.mock.calls.flat().join("\n");
93
+ expect(output).toContain("No orphaned processes found.");
94
+ expect(stopProcessMock).not.toHaveBeenCalled();
95
+ });
96
+ });
97
+
98
+ describe("vellum clean — single orphan", () => {
99
+ test("kills the orphan and prints singular 'process'", async () => {
100
+ detectOrphansMock.mockResolvedValueOnce([makeOrphan("assistant", "12345")]);
101
+ stopProcessMock.mockResolvedValueOnce(true);
102
+ process.argv = ["bun", "vellum", "clean"];
103
+ await clean();
104
+
105
+ expect(stopProcessMock).toHaveBeenCalledTimes(1);
106
+ expect(stopProcessMock).toHaveBeenCalledWith(
107
+ 12345,
108
+ "assistant (PID 12345)",
109
+ );
110
+
111
+ const output = consoleLogSpy.mock.calls.flat().join("\n");
112
+ expect(output).toContain("Found 1 orphaned process");
113
+ expect(output).not.toContain("processes");
114
+ expect(output).toContain("Cleaned up 1 process.");
115
+ expect(output).not.toContain("processes.");
116
+ });
117
+
118
+ test("reports 0 cleaned when stopProcess returns false", async () => {
119
+ detectOrphansMock.mockResolvedValueOnce([makeOrphan("gateway", "99999")]);
120
+ stopProcessMock.mockResolvedValueOnce(false);
121
+ process.argv = ["bun", "vellum", "clean"];
122
+ await clean();
123
+
124
+ const output = consoleLogSpy.mock.calls.flat().join("\n");
125
+ expect(output).toContain("Cleaned up 0 process");
126
+ });
127
+ });
128
+
129
+ describe("vellum clean — multiple orphans", () => {
130
+ test("uses plural 'processes' with multiple orphans", async () => {
131
+ detectOrphansMock.mockResolvedValueOnce([
132
+ makeOrphan("assistant", "1001"),
133
+ makeOrphan("gateway", "1002"),
134
+ makeOrphan("qdrant", "1003"),
135
+ ]);
136
+ stopProcessMock.mockResolvedValue(true);
137
+ process.argv = ["bun", "vellum", "clean"];
138
+ await clean();
139
+
140
+ expect(stopProcessMock).toHaveBeenCalledTimes(3);
141
+
142
+ const output = consoleLogSpy.mock.calls.flat().join("\n");
143
+ expect(output).toContain("Found 3 orphaned processes");
144
+ expect(output).toContain("Cleaned up 3 processes.");
145
+ });
146
+
147
+ test("counts only successfully stopped processes in the total", async () => {
148
+ detectOrphansMock.mockResolvedValueOnce([
149
+ makeOrphan("assistant", "2001"),
150
+ makeOrphan("qdrant", "2002"),
151
+ makeOrphan("gateway", "2003"),
152
+ ]);
153
+ // Only the first and third succeed
154
+ stopProcessMock
155
+ .mockResolvedValueOnce(true)
156
+ .mockResolvedValueOnce(false)
157
+ .mockResolvedValueOnce(true);
158
+ process.argv = ["bun", "vellum", "clean"];
159
+ await clean();
160
+
161
+ const output = consoleLogSpy.mock.calls.flat().join("\n");
162
+ expect(output).toContain("Found 3 orphaned processes");
163
+ expect(output).toContain("Cleaned up 2 processes.");
164
+ });
165
+
166
+ test("passes the correct PID and label to stopProcess for each orphan", async () => {
167
+ detectOrphansMock.mockResolvedValueOnce([
168
+ makeOrphan("assistant", "3001"),
169
+ makeOrphan("gateway", "3002"),
170
+ ]);
171
+ stopProcessMock.mockResolvedValue(true);
172
+ process.argv = ["bun", "vellum", "clean"];
173
+ await clean();
174
+
175
+ const calls = stopProcessMock.mock.calls as [number, string][];
176
+ expect(calls[0]).toEqual([3001, "assistant (PID 3001)"]);
177
+ expect(calls[1]).toEqual([3002, "gateway (PID 3002)"]);
178
+ });
179
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Tests for `vellum client --token <jwt> --url <gateway>`: an ephemeral session
3
+ * that authenticates with a handed-over token and needs no lockfile entry.
4
+ */
5
+ import {
6
+ afterAll,
7
+ afterEach,
8
+ beforeEach,
9
+ describe,
10
+ expect,
11
+ test,
12
+ } from "bun:test";
13
+ import { mkdtempSync, rmSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ // NB: do NOT mock.module("../lib/guardian-token.js") here — that mock is
18
+ // process-global in Bun and leaks into other test files (it dropped the
19
+ // module's other exports and broke guardian-token-paths / setup suites in CI).
20
+ // The "--token skips the credential lookup" behavior is enforced structurally
21
+ // in parseArgs (the lookup is gated on the override) and reviewed there.
22
+
23
+ // An EMPTY temp dir so there is no lockfile entry — the ephemeral path must
24
+ // work without one. Env mutation is scoped to each test and restored after, so
25
+ // it can't leak into other test files in the same Bun run.
26
+ const testDir = mkdtempSync(join(tmpdir(), "client-token-test-"));
27
+ const ORIGINAL_LOCKFILE_DIR = process.env.VELLUM_LOCKFILE_DIR;
28
+ const ORIGINAL_ARGV = [...process.argv];
29
+
30
+ import { parseArgs } from "../commands/client.js";
31
+
32
+ // A clearly non-local URL so maybeSwapToLocalhost won't rewrite it to 127.0.0.1.
33
+ const REMOTE_URL = "http://192.0.2.50:7830";
34
+
35
+ describe("client --token (ephemeral)", () => {
36
+ beforeEach(() => {
37
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
38
+ });
39
+
40
+ afterEach(() => {
41
+ process.argv = [...ORIGINAL_ARGV];
42
+ if (ORIGINAL_LOCKFILE_DIR === undefined) {
43
+ delete process.env.VELLUM_LOCKFILE_DIR;
44
+ } else {
45
+ process.env.VELLUM_LOCKFILE_DIR = ORIGINAL_LOCKFILE_DIR;
46
+ }
47
+ });
48
+
49
+ afterAll(() => {
50
+ rmSync(testDir, { recursive: true, force: true });
51
+ });
52
+
53
+ test("--url + --token resolves to a bearer session with no lockfile entry", () => {
54
+ process.argv = [
55
+ "bun",
56
+ "vellum",
57
+ "client",
58
+ "--url",
59
+ REMOTE_URL,
60
+ "--token",
61
+ "test-jwt-token",
62
+ ];
63
+ const parsed = parseArgs();
64
+
65
+ expect(parsed.runtimeUrl).toBe(REMOTE_URL);
66
+ expect(parsed.assistantId).toBe("self"); // DAEMON_INTERNAL_ASSISTANT_ID
67
+ expect(parsed.bearerToken).toBe("test-jwt-token");
68
+ expect(parsed.platformToken).toBeUndefined();
69
+ });
70
+
71
+ test("--assistant-id overrides the default 'self' segment", () => {
72
+ process.argv = [
73
+ "bun",
74
+ "vellum",
75
+ "client",
76
+ "--url",
77
+ REMOTE_URL,
78
+ "--token",
79
+ "tok",
80
+ "--assistant-id",
81
+ "remote-xyz",
82
+ ];
83
+ const parsed = parseArgs();
84
+ expect(parsed.assistantId).toBe("remote-xyz");
85
+ expect(parsed.bearerToken).toBe("tok");
86
+ });
87
+ });
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Tests for resolveFreshBearerToken: the `vellum client` TUI proactively
3
+ * refreshes a stale STORED guardian token at startup, while leaving platform
4
+ * session auth, ephemeral --token overrides, and still-fresh tokens untouched.
5
+ */
6
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
7
+ import { mkdtempSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ const ORIGINAL_XDG = process.env.XDG_CONFIG_HOME;
12
+ const ORIGINAL_ENV = process.env.VELLUM_ENVIRONMENT;
13
+ const ORIGINAL_FETCH = globalThis.fetch;
14
+
15
+ import { resolveFreshBearerToken } from "../commands/client.js";
16
+ import { saveGuardianToken } from "../lib/guardian-token.js";
17
+
18
+ const RUNTIME = "http://10.0.0.9:7830";
19
+ const past = () => new Date(Date.now() - 60_000).toISOString();
20
+ const future = () => new Date(Date.now() + 60 * 60 * 1000).toISOString();
21
+
22
+ function seed(opts: {
23
+ accessToken: string;
24
+ refreshToken: string;
25
+ refreshAfter: string;
26
+ }): void {
27
+ saveGuardianToken("px", {
28
+ guardianPrincipalId: "imported",
29
+ accessToken: opts.accessToken,
30
+ accessTokenExpiresAt: future(),
31
+ refreshToken: opts.refreshToken,
32
+ refreshTokenExpiresAt: future(),
33
+ refreshAfter: opts.refreshAfter,
34
+ isNew: false,
35
+ deviceId: "dev",
36
+ leasedAt: new Date().toISOString(),
37
+ });
38
+ }
39
+
40
+ /** Stub global fetch; returns whether the refresh endpoint was hit. */
41
+ function stubRefresh(ok: boolean): { hit: () => boolean } {
42
+ let called = false;
43
+ globalThis.fetch = (async (url: unknown, _init?: RequestInit) => {
44
+ if (String(url).includes("/v1/guardian/refresh")) {
45
+ called = true;
46
+ return new Response(
47
+ ok ? JSON.stringify({ accessToken: "new-acc" }) : "nope",
48
+ {
49
+ status: ok ? 200 : 401,
50
+ headers: { "content-type": "application/json" },
51
+ },
52
+ );
53
+ }
54
+ return new Response("", { status: 200 });
55
+ }) as typeof fetch;
56
+ return { hit: () => called };
57
+ }
58
+
59
+ describe("resolveFreshBearerToken", () => {
60
+ let tempHome: string;
61
+
62
+ beforeEach(() => {
63
+ tempHome = mkdtempSync(join(tmpdir(), "client-tui-refresh-test-"));
64
+ process.env.XDG_CONFIG_HOME = tempHome;
65
+ delete process.env.VELLUM_ENVIRONMENT; // prod config dir
66
+ });
67
+
68
+ afterEach(() => {
69
+ globalThis.fetch = ORIGINAL_FETCH;
70
+ if (ORIGINAL_XDG === undefined) delete process.env.XDG_CONFIG_HOME;
71
+ else process.env.XDG_CONFIG_HOME = ORIGINAL_XDG;
72
+ if (ORIGINAL_ENV === undefined) delete process.env.VELLUM_ENVIRONMENT;
73
+ else process.env.VELLUM_ENVIRONMENT = ORIGINAL_ENV;
74
+ rmSync(tempHome, { recursive: true, force: true });
75
+ });
76
+
77
+ test("refreshes a stale stored token and returns the new access token", async () => {
78
+ seed({ accessToken: "old-acc", refreshToken: "ref", refreshAfter: past() });
79
+ const refresh = stubRefresh(true);
80
+
81
+ const token = await resolveFreshBearerToken(
82
+ RUNTIME,
83
+ "px",
84
+ "old-acc",
85
+ "paired",
86
+ );
87
+
88
+ expect(token).toBe("new-acc");
89
+ expect(refresh.hit()).toBe(true);
90
+ });
91
+
92
+ test("leaves a still-fresh stored token unchanged (no refresh)", async () => {
93
+ seed({
94
+ accessToken: "old-acc",
95
+ refreshToken: "ref",
96
+ refreshAfter: future(),
97
+ });
98
+ const refresh = stubRefresh(true);
99
+
100
+ const token = await resolveFreshBearerToken(
101
+ RUNTIME,
102
+ "px",
103
+ "old-acc",
104
+ "paired",
105
+ );
106
+
107
+ expect(token).toBe("old-acc");
108
+ expect(refresh.hit()).toBe(false);
109
+ });
110
+
111
+ test("does not refresh an ephemeral --token (mismatches the store)", async () => {
112
+ seed({ accessToken: "old-acc", refreshToken: "ref", refreshAfter: past() });
113
+ const refresh = stubRefresh(true);
114
+
115
+ // bearerToken differs from the stored accessToken => ephemeral override.
116
+ const token = await resolveFreshBearerToken(
117
+ RUNTIME,
118
+ "px",
119
+ "ephemeral-tok",
120
+ "paired",
121
+ );
122
+
123
+ expect(token).toBe("ephemeral-tok");
124
+ expect(refresh.hit()).toBe(false);
125
+ });
126
+
127
+ test("never refreshes on the platform session-auth path", async () => {
128
+ seed({ accessToken: "old-acc", refreshToken: "ref", refreshAfter: past() });
129
+ const refresh = stubRefresh(true);
130
+
131
+ const token = await resolveFreshBearerToken(
132
+ RUNTIME,
133
+ "px",
134
+ "old-acc",
135
+ "vellum",
136
+ );
137
+
138
+ expect(token).toBe("old-acc");
139
+ expect(refresh.hit()).toBe(false);
140
+ });
141
+
142
+ test("falls back to the existing token when refresh fails", async () => {
143
+ seed({ accessToken: "old-acc", refreshToken: "ref", refreshAfter: past() });
144
+ stubRefresh(false); // refresh endpoint returns non-ok
145
+
146
+ const token = await resolveFreshBearerToken(
147
+ RUNTIME,
148
+ "px",
149
+ "old-acc",
150
+ "paired",
151
+ );
152
+
153
+ expect(token).toBe("old-acc");
154
+ });
155
+
156
+ test("does not refresh an access-only stored token (no refresh credential)", async () => {
157
+ seed({ accessToken: "old-acc", refreshToken: "", refreshAfter: past() });
158
+ const refresh = stubRefresh(true);
159
+
160
+ const token = await resolveFreshBearerToken(
161
+ RUNTIME,
162
+ "px",
163
+ "old-acc",
164
+ "paired",
165
+ );
166
+
167
+ expect(token).toBe("old-acc");
168
+ expect(refresh.hit()).toBe(false);
169
+ });
170
+ });