@vellumai/cli 0.8.8-dev.202606082140.a5125fe → 0.8.8-dev.202606082331.c911d0c
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 +1 -1
- package/src/__tests__/assistant-client-refresh.test.ts +64 -3
- package/src/__tests__/guardian-token.test.ts +57 -0
- package/src/__tests__/tui-midsession-refresh.test.ts +23 -3
- package/src/commands/client.ts +7 -6
- package/src/components/DefaultMainScreen.tsx +8 -1
- package/src/lib/assistant-client.ts +31 -13
- package/src/lib/guardian-token.ts +13 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
});
|
package/src/commands/client.ts
CHANGED
|
@@ -16,7 +16,11 @@ import {
|
|
|
16
16
|
GATEWAY_PORT,
|
|
17
17
|
type Species,
|
|
18
18
|
} from "../lib/constants";
|
|
19
|
-
import {
|
|
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
|
-
//
|
|
880
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
223
|
-
//
|
|
224
|
-
// and
|
|
225
|
-
//
|
|
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
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
)
|
|
233
|
-
|
|
234
|
-
|
|
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,
|