@vellumai/cli 0.8.9-staging.2 → 0.8.9-staging.4

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.
@@ -0,0 +1,88 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, test } from "bun:test";
5
+
6
+ import { headerHostIsLoopback, originIsAllowed } from "../util";
7
+ import { isActiveAssistant } from "../lockfile";
8
+
9
+ describe("headerHostIsLoopback", () => {
10
+ test("rejects DNS-rebound hosts", () => {
11
+ expect(headerHostIsLoopback("attacker.example")).toBe(false);
12
+ expect(headerHostIsLoopback("evil.com:3000")).toBe(false);
13
+ });
14
+
15
+ test("accepts loopback hosts", () => {
16
+ expect(headerHostIsLoopback("127.0.0.1:3000")).toBe(true);
17
+ expect(headerHostIsLoopback("localhost:3000")).toBe(true);
18
+ expect(headerHostIsLoopback("localhost")).toBe(true);
19
+ expect(headerHostIsLoopback("[::1]:3000")).toBe(true);
20
+ });
21
+
22
+ test("rejects undefined/empty", () => {
23
+ expect(headerHostIsLoopback(undefined)).toBe(false);
24
+ expect(headerHostIsLoopback("")).toBe(false);
25
+ });
26
+ });
27
+
28
+ describe("originIsAllowed", () => {
29
+ test("rejects cross-origin requests", () => {
30
+ expect(originIsAllowed("https://attacker.example")).toBe(false);
31
+ expect(originIsAllowed("http://evil.com")).toBe(false);
32
+ });
33
+
34
+ test("accepts localhost origins", () => {
35
+ expect(originIsAllowed("http://localhost:3000")).toBe(true);
36
+ expect(originIsAllowed("http://127.0.0.1:3000")).toBe(true);
37
+ });
38
+
39
+ test("allows absent origin (non-browser clients)", () => {
40
+ expect(originIsAllowed(undefined)).toBe(true);
41
+ });
42
+ });
43
+
44
+ describe("isActiveAssistant", () => {
45
+ const tempDirs: string[] = [];
46
+
47
+ afterEach(() => {
48
+ for (const dir of tempDirs.splice(0)) {
49
+ fs.rmSync(dir, { recursive: true, force: true });
50
+ }
51
+ });
52
+
53
+ function makeTempDir(): string {
54
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vellum-local-mode-test-"));
55
+ tempDirs.push(dir);
56
+ return dir;
57
+ }
58
+
59
+ test("returns true for the active assistant", () => {
60
+ const dir = makeTempDir();
61
+ const lockfilePath = path.join(dir, "lockfile.json");
62
+ fs.writeFileSync(
63
+ lockfilePath,
64
+ JSON.stringify({
65
+ assistants: [{ assistantId: "active" }, { assistantId: "inactive" }],
66
+ activeAssistant: "active",
67
+ }),
68
+ );
69
+ expect(isActiveAssistant([lockfilePath], "active")).toBe(true);
70
+ });
71
+
72
+ test("returns false for a non-active assistant", () => {
73
+ const dir = makeTempDir();
74
+ const lockfilePath = path.join(dir, "lockfile.json");
75
+ fs.writeFileSync(
76
+ lockfilePath,
77
+ JSON.stringify({
78
+ assistants: [{ assistantId: "active" }, { assistantId: "inactive" }],
79
+ activeAssistant: "active",
80
+ }),
81
+ );
82
+ expect(isActiveAssistant([lockfilePath], "inactive")).toBe(false);
83
+ });
84
+
85
+ test("returns false when lockfile does not exist", () => {
86
+ expect(isActiveAssistant(["/nonexistent/lockfile.json"], "any")).toBe(false);
87
+ });
88
+ });
@@ -9,6 +9,8 @@
9
9
  export {
10
10
  stripSensitiveFields,
11
11
  isLoopbackAddr,
12
+ headerHostIsLoopback,
13
+ originIsAllowed,
12
14
  resolveDevCliInvocation,
13
15
  } from "./util";
14
16
  export type { CliInvocation } from "./util";
@@ -19,6 +21,7 @@ export {
19
21
  getLockfileData,
20
22
  upsertLockfileAssistant,
21
23
  replacePlatformAssistants,
24
+ isActiveAssistant,
22
25
  } from "./lockfile";
23
26
  export type { LockfileResult, WriteResult } from "./lockfile";
24
27
  export { parseLockfile } from "./lockfile-contract";
@@ -88,6 +88,21 @@ export function upsertLockfileAssistant(
88
88
  return { ok: true, lockfile: parseLockfile(stripped) };
89
89
  }
90
90
 
91
+ export function isActiveAssistant(
92
+ lockfilePaths: string[],
93
+ assistantId: string,
94
+ ): boolean {
95
+ for (const candidate of lockfilePaths) {
96
+ try {
97
+ const data = JSON.parse(fs.readFileSync(candidate, "utf-8")) as Record<string, unknown>;
98
+ return data.activeAssistant === assistantId;
99
+ } catch {
100
+ continue;
101
+ }
102
+ }
103
+ return false;
104
+ }
105
+
91
106
  export function replacePlatformAssistants(
92
107
  lockfilePaths: string[],
93
108
  platformAssistants: Array<Record<string, unknown>>,
@@ -42,6 +42,39 @@ export function stripSensitiveFields(data: Record<string, unknown>): void {
42
42
  }
43
43
  }
44
44
 
45
+ function isLoopbackHostname(hostname: string): boolean {
46
+ const normalized = hostname.toLowerCase();
47
+ return (
48
+ normalized === "localhost" ||
49
+ normalized === "[::1]" ||
50
+ normalized === "::1" ||
51
+ normalized === "0:0:0:0:0:0:0:1" ||
52
+ /^127(?:\.\d{1,3}){3}$/.test(normalized)
53
+ );
54
+ }
55
+
56
+ export function headerHostIsLoopback(hostHeader: string | undefined): boolean {
57
+ if (!hostHeader) return false;
58
+ try {
59
+ return isLoopbackHostname(new URL(`http://${hostHeader}`).hostname);
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ export function originIsAllowed(originHeader: string | undefined): boolean {
66
+ if (!originHeader) return true;
67
+ try {
68
+ const origin = new URL(originHeader);
69
+ return (
70
+ (origin.protocol === "http:" || origin.protocol === "https:") &&
71
+ isLoopbackHostname(origin.hostname)
72
+ );
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
45
78
  export function isLoopbackAddr(addr: string): boolean {
46
79
  const v4Mapped = addr.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
47
80
  const normalized = v4Mapped ? v4Mapped[1]! : addr;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.8.9-staging.2",
3
+ "version": "0.8.9-staging.4",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -25,10 +25,17 @@ import { AssistantClient } from "../lib/assistant-client.js";
25
25
  import { saveAssistantEntry } from "../lib/assistant-config.js";
26
26
  import { loadGuardianToken, saveGuardianToken } from "../lib/guardian-token.js";
27
27
 
28
- const RUNTIME = "http://10.0.0.9:7830";
28
+ const RUNTIME = "https://gw.example.com";
29
29
  const FUTURE = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
30
+ const PAST = new Date(Date.now() - 60_000).toISOString();
30
31
 
31
- function seedPaired(refreshToken: string): void {
32
+ /**
33
+ * Seed a paired assistant + guardian token. `due` (default true) controls
34
+ * whether the access token has reached its renewal point — the reactive
35
+ * 401-refresh only fires for a due token.
36
+ */
37
+ function seedPaired(refreshToken: string, opts?: { due?: boolean }): void {
38
+ const due = opts?.due ?? true;
32
39
  saveAssistantEntry({
33
40
  assistantId: "px",
34
41
  name: "Paired",
@@ -40,10 +47,10 @@ function seedPaired(refreshToken: string): void {
40
47
  saveGuardianToken("px", {
41
48
  guardianPrincipalId: "imported",
42
49
  accessToken: "old-acc",
43
- accessTokenExpiresAt: FUTURE,
50
+ accessTokenExpiresAt: due ? PAST : FUTURE,
44
51
  refreshToken,
45
52
  refreshTokenExpiresAt: refreshToken ? FUTURE : 0,
46
- refreshAfter: "",
53
+ refreshAfter: due ? PAST : FUTURE,
47
54
  isNew: false,
48
55
  deviceId: "dev",
49
56
  leasedAt: new Date().toISOString(),
@@ -179,4 +186,58 @@ describe("AssistantClient 401 -> refresh -> retry", () => {
179
186
  expect(assistantAttempts).toBe(2); // original + one retry, no more
180
187
  expect(calls.filter((c) => isRefresh(c.url))).toHaveLength(1);
181
188
  });
189
+
190
+ test("does NOT refresh on a 401 when the stored token is not due for renewal", async () => {
191
+ // A forged/synthetic 401 on a still-valid token must not coax out the
192
+ // long-lived refresh credential.
193
+ seedPaired("refresh-tok", { due: false });
194
+ let assistantAttempts = 0;
195
+ const calls = stubFetch((url) => {
196
+ if (isRefresh(url)) return refreshResponse();
197
+ assistantAttempts++;
198
+ return new Response("", { status: 401 });
199
+ });
200
+
201
+ const client = new AssistantClient({ assistantId: "px" });
202
+ const res = await client.get("/messages/");
203
+
204
+ expect(res.status).toBe(401);
205
+ expect(assistantAttempts).toBe(1); // no retry
206
+ expect(calls.filter((c) => isRefresh(c.url))).toHaveLength(0); // refresh not attempted
207
+ });
208
+
209
+ test("adopts a token rotated by another process on a 401 (no refresh sent)", async () => {
210
+ // Construct the client capturing the current ("old-acc") token, then
211
+ // simulate a concurrent process (e.g. `vellum events`) rotating + persisting
212
+ // a fresh, not-due token. A 401 must pick up the fresh local token and retry
213
+ // WITHOUT sending the refresh credential.
214
+ seedPaired("refresh-tok", { due: false });
215
+ const client = new AssistantClient({ assistantId: "px" });
216
+ saveGuardianToken("px", {
217
+ guardianPrincipalId: "imported",
218
+ accessToken: "fresh-acc",
219
+ accessTokenExpiresAt: FUTURE,
220
+ refreshToken: "refresh-tok",
221
+ refreshTokenExpiresAt: FUTURE,
222
+ refreshAfter: FUTURE, // fresh — not due for renewal
223
+ isNew: false,
224
+ deviceId: "dev",
225
+ leasedAt: new Date().toISOString(),
226
+ });
227
+
228
+ const calls = stubFetch((url, log) => {
229
+ if (isRefresh(url)) return refreshResponse();
230
+ const auth = log[log.length - 1].headers["Authorization"];
231
+ return new Response("", {
232
+ status: auth === "Bearer fresh-acc" ? 200 : 401,
233
+ });
234
+ });
235
+
236
+ const res = await client.get("/messages/");
237
+
238
+ expect(res.status).toBe(200);
239
+ expect(calls.filter((c) => isRefresh(c.url))).toHaveLength(0); // no refresh sent
240
+ const assistantCalls = calls.filter((c) => !isRefresh(c.url));
241
+ expect(assistantCalls[1].headers["Authorization"]).toBe("Bearer fresh-acc");
242
+ });
182
243
  });
@@ -10,15 +10,30 @@ import { join } from "node:path";
10
10
 
11
11
  const ORIGINAL_XDG = process.env.XDG_CONFIG_HOME;
12
12
  const ORIGINAL_ENV = process.env.VELLUM_ENVIRONMENT;
13
+ const ORIGINAL_LOCKFILE_DIR = process.env.VELLUM_LOCKFILE_DIR;
13
14
  const ORIGINAL_FETCH = globalThis.fetch;
14
15
 
15
16
  import { resolveFreshBearerToken } from "../commands/client.js";
17
+ import { saveAssistantEntry } from "../lib/assistant-config.js";
16
18
  import { saveGuardianToken } from "../lib/guardian-token.js";
17
19
 
18
- const RUNTIME = "http://10.0.0.9:7830";
20
+ const RUNTIME = "https://gw.example.com";
19
21
  const past = () => new Date(Date.now() - 60_000).toISOString();
20
22
  const future = () => new Date(Date.now() + 60 * 60 * 1000).toISOString();
21
23
 
24
+ /** Persist a lockfile entry so the refresh URL-binding check has a trusted
25
+ * runtimeUrl to compare against (refresh is bound to the persisted entry). */
26
+ function seedEntry(cloud: string): void {
27
+ saveAssistantEntry({
28
+ assistantId: "px",
29
+ name: "Paired",
30
+ runtimeUrl: RUNTIME,
31
+ cloud,
32
+ paired: cloud === "paired",
33
+ species: "vellum",
34
+ });
35
+ }
36
+
22
37
  function seed(opts: {
23
38
  accessToken: string;
24
39
  refreshToken: string;
@@ -37,12 +52,15 @@ function seed(opts: {
37
52
  });
38
53
  }
39
54
 
40
- /** Stub global fetch; returns whether the refresh endpoint was hit. */
41
- function stubRefresh(ok: boolean): { hit: () => boolean } {
42
- let called = false;
55
+ /** Stub global fetch; returns whether the refresh endpoint was hit and where. */
56
+ function stubRefresh(ok: boolean): {
57
+ hit: () => boolean;
58
+ url: () => string | undefined;
59
+ } {
60
+ let calledUrl: string | undefined;
43
61
  globalThis.fetch = (async (url: unknown, _init?: RequestInit) => {
44
62
  if (String(url).includes("/v1/guardian/refresh")) {
45
- called = true;
63
+ calledUrl = String(url);
46
64
  return new Response(
47
65
  ok ? JSON.stringify({ accessToken: "new-acc" }) : "nope",
48
66
  {
@@ -53,7 +71,7 @@ function stubRefresh(ok: boolean): { hit: () => boolean } {
53
71
  }
54
72
  return new Response("", { status: 200 });
55
73
  }) as typeof fetch;
56
- return { hit: () => called };
74
+ return { hit: () => calledUrl !== undefined, url: () => calledUrl };
57
75
  }
58
76
 
59
77
  describe("resolveFreshBearerToken", () => {
@@ -62,6 +80,9 @@ describe("resolveFreshBearerToken", () => {
62
80
  beforeEach(() => {
63
81
  tempHome = mkdtempSync(join(tmpdir(), "client-tui-refresh-test-"));
64
82
  process.env.XDG_CONFIG_HOME = tempHome;
83
+ // Isolate the lockfile too — saveAssistantEntry writes the prod lockfile
84
+ // (~/.vellum.lock.json) unless VELLUM_LOCKFILE_DIR is set.
85
+ process.env.VELLUM_LOCKFILE_DIR = tempHome;
65
86
  delete process.env.VELLUM_ENVIRONMENT; // prod config dir
66
87
  });
67
88
 
@@ -69,12 +90,16 @@ describe("resolveFreshBearerToken", () => {
69
90
  globalThis.fetch = ORIGINAL_FETCH;
70
91
  if (ORIGINAL_XDG === undefined) delete process.env.XDG_CONFIG_HOME;
71
92
  else process.env.XDG_CONFIG_HOME = ORIGINAL_XDG;
93
+ if (ORIGINAL_LOCKFILE_DIR === undefined)
94
+ delete process.env.VELLUM_LOCKFILE_DIR;
95
+ else process.env.VELLUM_LOCKFILE_DIR = ORIGINAL_LOCKFILE_DIR;
72
96
  if (ORIGINAL_ENV === undefined) delete process.env.VELLUM_ENVIRONMENT;
73
97
  else process.env.VELLUM_ENVIRONMENT = ORIGINAL_ENV;
74
98
  rmSync(tempHome, { recursive: true, force: true });
75
99
  });
76
100
 
77
101
  test("refreshes a stale stored token and returns the new access token", async () => {
102
+ seedEntry("paired");
78
103
  seed({ accessToken: "old-acc", refreshToken: "ref", refreshAfter: past() });
79
104
  const refresh = stubRefresh(true);
80
105
 
@@ -89,6 +114,24 @@ describe("resolveFreshBearerToken", () => {
89
114
  expect(refresh.hit()).toBe(true);
90
115
  });
91
116
 
117
+ test("does NOT refresh against an overridden/poisoned runtime URL (no credential leak)", async () => {
118
+ // --url can override the runtime URL while still reusing the stored guardian
119
+ // token; a stale token must NOT be refreshed against an attacker origin.
120
+ seedEntry("paired"); // persisted runtimeUrl = RUNTIME
121
+ seed({ accessToken: "old-acc", refreshToken: "ref", refreshAfter: past() });
122
+ const refresh = stubRefresh(true);
123
+
124
+ const token = await resolveFreshBearerToken(
125
+ "http://attacker.example:7830",
126
+ "px",
127
+ "old-acc",
128
+ "paired",
129
+ );
130
+
131
+ expect(token).toBe("old-acc"); // unchanged
132
+ expect(refresh.hit()).toBe(false); // no refresh POST anywhere
133
+ });
134
+
92
135
  test("leaves a still-fresh stored token unchanged (no refresh)", async () => {
93
136
  seed({
94
137
  accessToken: "old-acc",
@@ -140,6 +183,7 @@ describe("resolveFreshBearerToken", () => {
140
183
  });
141
184
 
142
185
  test("falls back to the existing token when refresh fails", async () => {
186
+ seedEntry("paired");
143
187
  seed({ accessToken: "old-acc", refreshToken: "ref", refreshAfter: past() });
144
188
  stubRefresh(false); // refresh endpoint returns non-ok
145
189
 
@@ -13,6 +13,7 @@ import { dirname, join } from "node:path";
13
13
 
14
14
  import {
15
15
  getOrCreatePersistedDeviceId,
16
+ guardianTokenDueForRenewal,
16
17
  loadGuardianToken,
17
18
  refreshGuardianToken,
18
19
  saveGuardianToken,
@@ -293,7 +294,7 @@ describe("refreshGuardianToken", () => {
293
294
  );
294
295
  }) as typeof fetch;
295
296
 
296
- const result = await refreshGuardianToken("http://10.0.0.9:7830", "px");
297
+ const result = await refreshGuardianToken("https://gw.example.com", "px");
297
298
 
298
299
  expect(result?.accessToken).toBe("new-acc");
299
300
  expect(loadGuardianToken("px")?.accessToken).toBe("new-acc");
@@ -309,7 +310,9 @@ describe("refreshGuardianToken", () => {
309
310
  return new Response("", { status: 200 });
310
311
  }) as typeof fetch;
311
312
 
312
- expect(await refreshGuardianToken("http://10.0.0.9:7830", "px")).toBeNull();
313
+ expect(
314
+ await refreshGuardianToken("https://gw.example.com", "px"),
315
+ ).toBeNull();
313
316
  expect(called).toBe(false);
314
317
  });
315
318
 
@@ -321,7 +324,7 @@ describe("refreshGuardianToken", () => {
321
324
  }) as typeof fetch;
322
325
 
323
326
  expect(
324
- await refreshGuardianToken("http://10.0.0.9:7830", "missing"),
327
+ await refreshGuardianToken("https://gw.example.com", "missing"),
325
328
  ).toBeNull();
326
329
  expect(called).toBe(false);
327
330
  });
@@ -342,8 +345,131 @@ describe("refreshGuardianToken", () => {
342
345
  headers: { "content-type": "application/json" },
343
346
  })) as typeof fetch;
344
347
 
345
- const result = await refreshGuardianToken("http://10.0.0.9:7830", "px");
348
+ const result = await refreshGuardianToken("https://gw.example.com", "px");
346
349
  expect(result?.accessToken).toBe("new-acc");
347
350
  expect(existsSync(lp)).toBe(false); // stolen lock cleaned up after release
348
351
  });
352
+
353
+ // The refresh token is long-lived and replayable, so it must only travel over
354
+ // a confidential channel: https, or a loopback host. These guard the
355
+ // plaintext-interception vector flagged in the security review.
356
+
357
+ test("sends the refresh token over loopback http (127.0.0.1 / localhost / [::1])", async () => {
358
+ for (const url of [
359
+ "http://127.0.0.1:7830",
360
+ "http://localhost:7830",
361
+ "http://[::1]:7830",
362
+ ]) {
363
+ seed(future());
364
+ let called = false;
365
+ globalThis.fetch = (async (_url: unknown, _init?: RequestInit) => {
366
+ called = true;
367
+ return new Response(JSON.stringify({ accessToken: "new-acc" }), {
368
+ status: 200,
369
+ headers: { "content-type": "application/json" },
370
+ });
371
+ }) as typeof fetch;
372
+
373
+ expect(await refreshGuardianToken(url, "px")).not.toBeNull();
374
+ expect(called).toBe(true); // loopback http is allowed
375
+ }
376
+ });
377
+
378
+ test("refuses a non-loopback plaintext http URL: no fetch, returns null, warns", async () => {
379
+ seed(future());
380
+ let called = false;
381
+ globalThis.fetch = (async (_url: unknown, _init?: RequestInit) => {
382
+ called = true;
383
+ return new Response("", { status: 200 });
384
+ }) as typeof fetch;
385
+
386
+ const origWarn = console.warn;
387
+ let warned = false;
388
+ console.warn = () => {
389
+ warned = true;
390
+ };
391
+ try {
392
+ expect(
393
+ await refreshGuardianToken("http://10.0.0.5:7830", "px"),
394
+ ).toBeNull();
395
+ } finally {
396
+ console.warn = origWarn;
397
+ }
398
+ expect(called).toBe(false); // the refresh token is never sent
399
+ expect(warned).toBe(true);
400
+ });
401
+
402
+ test("refuses a malformed gateway URL: no fetch, returns null", async () => {
403
+ seed(future());
404
+ let called = false;
405
+ globalThis.fetch = (async (_url: unknown, _init?: RequestInit) => {
406
+ called = true;
407
+ return new Response("", { status: 200 });
408
+ }) as typeof fetch;
409
+
410
+ const origWarn = console.warn;
411
+ console.warn = () => {};
412
+ try {
413
+ expect(await refreshGuardianToken("not-a-url", "px")).toBeNull();
414
+ } finally {
415
+ console.warn = origWarn;
416
+ }
417
+ expect(called).toBe(false);
418
+ });
419
+ });
420
+
421
+ describe("guardianTokenDueForRenewal", () => {
422
+ const FUTURE = new Date(Date.now() + 60 * 60 * 1000).toISOString();
423
+ const PAST = new Date(Date.now() - 60_000).toISOString();
424
+
425
+ function token(over: Partial<GuardianTokenData>): GuardianTokenData {
426
+ return {
427
+ guardianPrincipalId: "p",
428
+ accessToken: "a",
429
+ accessTokenExpiresAt: FUTURE,
430
+ refreshToken: "r",
431
+ refreshTokenExpiresAt: FUTURE,
432
+ refreshAfter: "",
433
+ isNew: false,
434
+ deviceId: "d",
435
+ leasedAt: new Date().toISOString(),
436
+ ...over,
437
+ };
438
+ }
439
+
440
+ test("past refreshAfter → due", () => {
441
+ expect(guardianTokenDueForRenewal(token({ refreshAfter: PAST }))).toBe(
442
+ true,
443
+ );
444
+ });
445
+
446
+ test("future refreshAfter → not due", () => {
447
+ expect(guardianTokenDueForRenewal(token({ refreshAfter: FUTURE }))).toBe(
448
+ false,
449
+ );
450
+ });
451
+
452
+ test("empty refreshAfter falls back to accessTokenExpiresAt (past → due)", () => {
453
+ expect(
454
+ guardianTokenDueForRenewal(
455
+ token({ refreshAfter: "", accessTokenExpiresAt: PAST }),
456
+ ),
457
+ ).toBe(true);
458
+ });
459
+
460
+ test("empty refreshAfter falls back to accessTokenExpiresAt (future → not due)", () => {
461
+ expect(
462
+ guardianTokenDueForRenewal(
463
+ token({ refreshAfter: "", accessTokenExpiresAt: FUTURE }),
464
+ ),
465
+ ).toBe(false);
466
+ });
467
+
468
+ test("unparseable timestamp → not due", () => {
469
+ expect(
470
+ guardianTokenDueForRenewal(
471
+ token({ refreshAfter: "not-a-date", accessTokenExpiresAt: "nope" }),
472
+ ),
473
+ ).toBe(false);
474
+ });
349
475
  });
@@ -0,0 +1,86 @@
1
+ import { describe, test, expect } from "bun:test";
2
+
3
+ import { parseMessageArgs } from "../commands/message.js";
4
+
5
+ describe("parseMessageArgs", () => {
6
+ test("parses an inline message with the active assistant", () => {
7
+ const r = parseMessageArgs(["hello"]);
8
+ expect(r).toEqual({
9
+ ok: true,
10
+ value: {
11
+ conversationKey: undefined,
12
+ jsonOutput: false,
13
+ inlineMessage: "hello",
14
+ },
15
+ });
16
+ });
17
+
18
+ test("parses an explicit assistant plus inline message", () => {
19
+ const r = parseMessageArgs(["my-assistant", "ping"]);
20
+ expect(r.ok).toBe(true);
21
+ if (!r.ok) return;
22
+ expect(r.value.assistantId).toBe("my-assistant");
23
+ expect(r.value.inlineMessage).toBe("ping");
24
+ expect(r.value.filePath).toBeUndefined();
25
+ });
26
+
27
+ test("requires message content when nothing is provided", () => {
28
+ const r = parseMessageArgs([]);
29
+ expect(r).toEqual({ ok: false, error: "message content is required." });
30
+ });
31
+
32
+ test("reads content from --file with the active assistant", () => {
33
+ const r = parseMessageArgs(["--file", "prompt.txt"]);
34
+ expect(r.ok).toBe(true);
35
+ if (!r.ok) return;
36
+ expect(r.value.filePath).toBe("prompt.txt");
37
+ expect(r.value.assistantId).toBeUndefined();
38
+ expect(r.value.inlineMessage).toBeUndefined();
39
+ });
40
+
41
+ test("reads content from --file with an explicit assistant", () => {
42
+ const r = parseMessageArgs(["my-assistant", "--file", "prompt.txt"]);
43
+ expect(r.ok).toBe(true);
44
+ if (!r.ok) return;
45
+ expect(r.value.assistantId).toBe("my-assistant");
46
+ expect(r.value.filePath).toBe("prompt.txt");
47
+ });
48
+
49
+ test("supports stdin via --file -", () => {
50
+ const r = parseMessageArgs(["--file", "-"]);
51
+ expect(r.ok).toBe(true);
52
+ if (!r.ok) return;
53
+ expect(r.value.filePath).toBe("-");
54
+ });
55
+
56
+ test("rejects combining --file with an inline message", () => {
57
+ const r = parseMessageArgs(["my-assistant", "extra", "--file", "p.txt"]);
58
+ expect(r).toEqual({
59
+ ok: false,
60
+ error: "--file cannot be combined with an inline message argument.",
61
+ });
62
+ });
63
+
64
+ test("rejects --file without a path argument", () => {
65
+ const r = parseMessageArgs(["my-assistant", "--file"]);
66
+ expect(r).toEqual({
67
+ ok: false,
68
+ error: "--file requires a path argument.",
69
+ });
70
+ });
71
+
72
+ test("preserves --conversation-key and --json alongside --file", () => {
73
+ const r = parseMessageArgs([
74
+ "--json",
75
+ "--conversation-key",
76
+ "thread-1",
77
+ "--file",
78
+ "prompt.txt",
79
+ ]);
80
+ expect(r.ok).toBe(true);
81
+ if (!r.ok) return;
82
+ expect(r.value.jsonOutput).toBe(true);
83
+ expect(r.value.conversationKey).toBe("thread-1");
84
+ expect(r.value.filePath).toBe("prompt.txt");
85
+ });
86
+ });
@@ -779,6 +779,7 @@ describe("resolveOrHatchTarget", () => {
779
779
  "new-one",
780
780
  false,
781
781
  {},
782
+ {},
782
783
  { setupProviderCredentials: false },
783
784
  );
784
785
  expect(result).toBe(newEntry);