@vellumai/cli 0.8.8-dev.202606082058.447e3b6 → 0.8.8-dev.202606082236.8dbacc9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.8.8-dev.202606082058.447e3b6",
3
+ "version": "0.8.8-dev.202606082236.8dbacc9",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -27,8 +27,15 @@ import { loadGuardianToken, saveGuardianToken } from "../lib/guardian-token.js";
27
27
 
28
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
  });
@@ -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,
@@ -416,3 +417,59 @@ describe("refreshGuardianToken", () => {
416
417
  expect(called).toBe(false);
417
418
  });
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
+ });
475
+ });
@@ -20,6 +20,7 @@ import { saveGuardianToken } from "../lib/guardian-token";
20
20
 
21
21
  const RUNTIME = "https://gw.example.com";
22
22
  const future = () => new Date(Date.now() + 60 * 60 * 1000).toISOString();
23
+ const past = () => new Date(Date.now() - 60_000).toISOString();
23
24
 
24
25
  function seedEntry(cloud: string, localUrl?: string): void {
25
26
  saveAssistantEntry({
@@ -33,14 +34,19 @@ function seedEntry(cloud: string, localUrl?: string): void {
33
34
  });
34
35
  }
35
36
 
36
- function seedToken(accessToken: string, refreshToken: string): void {
37
+ function seedToken(
38
+ accessToken: string,
39
+ refreshToken: string,
40
+ opts?: { due?: boolean },
41
+ ): void {
42
+ const due = opts?.due ?? true;
37
43
  saveGuardianToken("px", {
38
44
  guardianPrincipalId: "imported",
39
45
  accessToken,
40
- accessTokenExpiresAt: future(),
46
+ accessTokenExpiresAt: due ? past() : future(),
41
47
  refreshToken,
42
48
  refreshTokenExpiresAt: refreshToken ? future() : 0,
43
- refreshAfter: "",
49
+ refreshAfter: due ? past() : future(),
44
50
  isNew: false,
45
51
  deviceId: "dev",
46
52
  leasedAt: new Date().toISOString(),
@@ -202,4 +208,18 @@ describe("maybeRefreshAuthHeaders", () => {
202
208
  expect(ok).toBe(false);
203
209
  expect(auth.Authorization).toBe("Bearer old-acc");
204
210
  });
211
+
212
+ test("does NOT refresh when the stored token is not due for renewal", async () => {
213
+ // A forged 401 on a still-valid token must not coax out the refresh token.
214
+ seedEntry("paired");
215
+ seedToken("old-acc", "ref", { due: false });
216
+ const refresh = stubRefresh(true);
217
+ const auth = { Authorization: "Bearer old-acc" };
218
+
219
+ const ok = await maybeRefreshAuthHeaders(RUNTIME, "px", auth);
220
+
221
+ expect(ok).toBe(false);
222
+ expect(auth.Authorization).toBe("Bearer old-acc"); // unchanged
223
+ expect(refresh.hit()).toBe(false); // refresh not attempted
224
+ });
205
225
  });
@@ -16,7 +16,11 @@ import {
16
16
  GATEWAY_PORT,
17
17
  type Species,
18
18
  } from "../lib/constants";
19
- import { loadGuardianToken, refreshGuardianToken } from "../lib/guardian-token";
19
+ import {
20
+ loadGuardianToken,
21
+ refreshGuardianToken,
22
+ guardianTokenDueForRenewal,
23
+ } from "../lib/guardian-token";
20
24
  import { normalizeRuntimeUrl, trustedRefreshUrl } from "../lib/runtime-url";
21
25
  import {
22
26
  CLI_INTERFACE_ID,
@@ -876,11 +880,8 @@ export async function resolveFreshBearerToken(
876
880
  return bearerToken;
877
881
  }
878
882
 
879
- // new Date() handles both ISO strings and epoch-ms numbers; Date.parse of an
880
- // epoch-ms string would be NaN.
881
- const renewAtRaw = stored.refreshAfter || stored.accessTokenExpiresAt;
882
- const renewAt = new Date(renewAtRaw).getTime();
883
- if (!Number.isFinite(renewAt) || renewAt > Date.now()) return bearerToken;
883
+ // Only refresh once the stored token is actually due for renewal.
884
+ if (!guardianTokenDueForRenewal(stored)) return bearerToken;
884
885
 
885
886
  // SECURITY: bind the refresh to the entry's persisted URL. `--url`/`-u` can
886
887
  // override `runtimeUrl` while still reusing this stored guardian token, so a
@@ -12,7 +12,11 @@ import { Box, render as inkRender, Text, useInput, useStdout } from "ink";
12
12
  import { SPECIES_CONFIG, type Species } from "../lib/constants";
13
13
  import { lookupAssistantByIdentifier } from "../lib/assistant-config";
14
14
  import { checkHealth } from "../lib/health-check";
15
- import { loadGuardianToken, refreshGuardianToken } from "../lib/guardian-token";
15
+ import {
16
+ guardianTokenDueForRenewal,
17
+ loadGuardianToken,
18
+ refreshGuardianToken,
19
+ } from "../lib/guardian-token";
16
20
  import { trustedRefreshUrl } from "../lib/runtime-url";
17
21
  import { appendHistory, loadHistory } from "../lib/input-history";
18
22
  import { tuiLog } from "../lib/tui-log";
@@ -232,6 +236,9 @@ export async function maybeRefreshAuthHeaders(
232
236
  if (!stored || stored.accessToken !== bearer || !stored.refreshToken) {
233
237
  return false;
234
238
  }
239
+ // Only refresh once the token is actually due for renewal, so a forged 401
240
+ // on a still-valid token can't coax out the long-lived refresh credential.
241
+ if (!guardianTokenDueForRenewal(stored)) return false;
235
242
  const refreshed = await refreshGuardianToken(refreshUrl, assistantId);
236
243
  if (!refreshed?.accessToken) return false;
237
244
  auth["Authorization"] = `Bearer ${refreshed.accessToken}`;
@@ -14,7 +14,11 @@
14
14
 
15
15
  import { resolveAssistant } from "./assistant-config.js";
16
16
  import { GATEWAY_PORT } from "./constants.js";
17
- import { loadGuardianToken, refreshGuardianToken } from "./guardian-token.js";
17
+ import {
18
+ loadGuardianToken,
19
+ refreshGuardianToken,
20
+ guardianTokenDueForRenewal,
21
+ } from "./guardian-token.js";
18
22
 
19
23
  const DEFAULT_TIMEOUT_MS = 30_000;
20
24
  const FALLBACK_RUNTIME_URL = `http://127.0.0.1:${GATEWAY_PORT}`;
@@ -219,21 +223,35 @@ export class AssistantClient {
219
223
 
220
224
  const response = await doFetch();
221
225
 
222
- // Reactive auto-refresh: a paired/local guardian access token that has
223
- // expired comes back 401. Refresh it once via the stored refresh credential
224
- // and retry. Self-gating refreshGuardianToken returns null unless a usable
225
- // refresh token is stored, so ephemeral (`--token`) and access-only sessions
226
- // just see the original 401. The platform session-auth path is never
227
- // refreshed here (its token is managed by the Vellum platform).
226
+ // Reactive auto-refresh on a 401 for the guardian (non-session) path.
227
+ // Ephemeral (`--token`) and access-only sessions have no stored refresh
228
+ // credential and just see the original 401; the platform session-auth path
229
+ // is never refreshed here (its token is managed by the Vellum platform).
228
230
  if (response.status === 401 && !this.isSessionAuth) {
229
- const refreshed = await refreshGuardianToken(
230
- this.runtimeUrl,
231
- this._assistantId,
232
- );
233
- if (refreshed?.accessToken) {
234
- this.token = refreshed.accessToken;
231
+ const stored = loadGuardianToken(this._assistantId);
232
+
233
+ // Another process may have already rotated and persisted a fresh access
234
+ // token (e.g. a concurrent `vellum events`). Adopt it and retry — this
235
+ // sends no refresh credential, just picks up the newer local token.
236
+ if (stored?.accessToken && stored.accessToken !== this.token) {
237
+ this.token = stored.accessToken;
235
238
  return doFetch();
236
239
  }
240
+
241
+ // Otherwise only disclose the long-lived refresh token when our access
242
+ // token is actually due for renewal. A 401 on a still-valid token (e.g. a
243
+ // forged 401 from an impostor endpoint trying to coax out the refresh
244
+ // credential) is surfaced as-is, not refreshed.
245
+ if (stored?.refreshToken && guardianTokenDueForRenewal(stored)) {
246
+ const refreshed = await refreshGuardianToken(
247
+ this.runtimeUrl,
248
+ this._assistantId,
249
+ );
250
+ if (refreshed?.accessToken) {
251
+ this.token = refreshed.accessToken;
252
+ return doFetch();
253
+ }
254
+ }
237
255
  }
238
256
 
239
257
  return response;
@@ -285,6 +285,19 @@ function isConfidentialRefreshUrl(gatewayUrl: string): boolean {
285
285
  }
286
286
  }
287
287
 
288
+ /**
289
+ * True when a stored guardian token has reached its renewal point — now is
290
+ * at/after `refreshAfter` (preferred) or `accessTokenExpiresAt`. Used to gate
291
+ * refresh so a forged/synthetic 401 on a still-valid token can't coax out the
292
+ * long-lived refresh credential. Unparseable timestamps → not due.
293
+ */
294
+ export function guardianTokenDueForRenewal(token: GuardianTokenData): boolean {
295
+ const raw = token.refreshAfter || token.accessTokenExpiresAt;
296
+ const at = new Date(raw).getTime();
297
+ if (!Number.isFinite(at)) return false;
298
+ return at <= Date.now();
299
+ }
300
+
288
301
  export async function refreshGuardianToken(
289
302
  gatewayUrl: string,
290
303
  assistantId: string,