@vellumai/cli 0.8.8 → 0.8.9-dev.202606091926.ebb2d62
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/node_modules/@vellumai/local-mode/src/__tests__/loopback-auth.test.ts +88 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +3 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +15 -0
- package/node_modules/@vellumai/local-mode/src/util.ts +33 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-client-refresh.test.ts +65 -4
- package/src/__tests__/client-tui-refresh.test.ts +50 -6
- package/src/__tests__/guardian-token.test.ts +130 -4
- package/src/__tests__/message.test.ts +86 -0
- package/src/__tests__/teleport.test.ts +1 -0
- package/src/__tests__/tui-midsession-refresh.test.ts +68 -9
- package/src/commands/client.ts +100 -58
- package/src/commands/hatch.ts +14 -4
- package/src/commands/login.ts +128 -9
- package/src/commands/message.ts +109 -19
- package/src/commands/teleport.ts +2 -0
- package/src/components/DefaultMainScreen.tsx +27 -2
- package/src/lib/__tests__/docker.test.ts +99 -0
- package/src/lib/assistant-client.ts +31 -13
- package/src/lib/docker.ts +97 -29
- package/src/lib/flag-args.test.ts +89 -0
- package/src/lib/flag-args.ts +74 -0
- package/src/lib/guardian-token.ts +54 -0
- package/src/lib/hatch-local.ts +2 -0
- package/src/lib/local.ts +6 -1
- package/src/lib/runtime-url.ts +90 -0
- package/src/lib/statefulset.ts +9 -0
|
@@ -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
|
@@ -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 = "
|
|
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
|
});
|
|
@@ -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 = "
|
|
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): {
|
|
42
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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("
|
|
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(
|
|
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("
|
|
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("
|
|
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
|
+
});
|