@vellumai/cli 0.8.8-dev.202606081515.c77a9b6 → 0.8.8-dev.202606081859.f7bdc00
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__/client-tui-refresh.test.ts +49 -5
- package/src/__tests__/message.test.ts +86 -0
- package/src/__tests__/tui-midsession-refresh.test.ts +44 -5
- package/src/commands/client.ts +30 -43
- package/src/commands/message.ts +109 -19
- package/src/components/DefaultMainScreen.tsx +19 -1
- package/src/lib/__tests__/docker.test.ts +18 -12
- package/src/lib/docker.ts +14 -9
- package/src/lib/runtime-url.ts +90 -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
|
@@ -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
20
|
const RUNTIME = "http://10.0.0.9:7830";
|
|
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
|
|
|
@@ -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
|
+
});
|
|
@@ -21,11 +21,12 @@ import { saveGuardianToken } from "../lib/guardian-token";
|
|
|
21
21
|
const RUNTIME = "http://10.0.0.9:7830";
|
|
22
22
|
const future = () => new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
|
23
23
|
|
|
24
|
-
function seedEntry(cloud: string): void {
|
|
24
|
+
function seedEntry(cloud: string, localUrl?: string): void {
|
|
25
25
|
saveAssistantEntry({
|
|
26
26
|
assistantId: "px",
|
|
27
27
|
name: "Paired",
|
|
28
28
|
runtimeUrl: RUNTIME,
|
|
29
|
+
...(localUrl ? { localUrl } : {}),
|
|
29
30
|
cloud,
|
|
30
31
|
paired: cloud === "paired",
|
|
31
32
|
species: "vellum",
|
|
@@ -46,11 +47,14 @@ function seedToken(accessToken: string, refreshToken: string): void {
|
|
|
46
47
|
});
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
function stubRefresh(ok: boolean): {
|
|
50
|
-
|
|
50
|
+
function stubRefresh(ok: boolean): {
|
|
51
|
+
hit: () => boolean;
|
|
52
|
+
url: () => string | undefined;
|
|
53
|
+
} {
|
|
54
|
+
let calledUrl: string | undefined;
|
|
51
55
|
globalThis.fetch = (async (url: unknown, _init?: RequestInit) => {
|
|
52
56
|
if (String(url).includes("/v1/guardian/refresh")) {
|
|
53
|
-
|
|
57
|
+
calledUrl = String(url);
|
|
54
58
|
return new Response(
|
|
55
59
|
ok ? JSON.stringify({ accessToken: "new-acc" }) : "x",
|
|
56
60
|
{
|
|
@@ -61,7 +65,7 @@ function stubRefresh(ok: boolean): { hit: () => boolean } {
|
|
|
61
65
|
}
|
|
62
66
|
return new Response("", { status: 200 });
|
|
63
67
|
}) as typeof fetch;
|
|
64
|
-
return { hit: () =>
|
|
68
|
+
return { hit: () => calledUrl !== undefined, url: () => calledUrl };
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
describe("maybeRefreshAuthHeaders", () => {
|
|
@@ -102,6 +106,41 @@ describe("maybeRefreshAuthHeaders", () => {
|
|
|
102
106
|
expect(refresh.hit()).toBe(true);
|
|
103
107
|
});
|
|
104
108
|
|
|
109
|
+
test("does NOT refresh against an overridden/poisoned baseUrl (no credential leak)", async () => {
|
|
110
|
+
// The CLI lets --url override the runtime URL while still using the stored
|
|
111
|
+
// paired guardian token. A 401 from that attacker origin must NOT cause us
|
|
112
|
+
// to POST the refreshToken + deviceId there.
|
|
113
|
+
seedEntry("paired"); // persisted runtimeUrl = RUNTIME
|
|
114
|
+
seedToken("old-acc", "ref");
|
|
115
|
+
const refresh = stubRefresh(true);
|
|
116
|
+
const auth = { Authorization: "Bearer old-acc" };
|
|
117
|
+
const attacker = "http://attacker.example:7830";
|
|
118
|
+
|
|
119
|
+
const ok = await maybeRefreshAuthHeaders(attacker, "px", auth);
|
|
120
|
+
|
|
121
|
+
expect(ok).toBe(false);
|
|
122
|
+
expect(auth.Authorization).toBe("Bearer old-acc"); // unchanged
|
|
123
|
+
expect(refresh.hit()).toBe(false); // no refresh POST anywhere
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("refreshes against the matched persisted URL, keeping the session's interface", async () => {
|
|
127
|
+
// When an entry persists both a loopback localUrl and a different
|
|
128
|
+
// runtimeUrl, a session on the loopback URL must refresh against THAT URL,
|
|
129
|
+
// not the external runtimeUrl (which may be unreachable / public-facing).
|
|
130
|
+
const localUrl = "http://127.0.0.1:7830";
|
|
131
|
+
seedEntry("paired", localUrl); // runtimeUrl = RUNTIME (10.0.0.9), localUrl = loopback
|
|
132
|
+
seedToken("old-acc", "ref");
|
|
133
|
+
const refresh = stubRefresh(true);
|
|
134
|
+
const auth = { Authorization: "Bearer old-acc" };
|
|
135
|
+
|
|
136
|
+
const ok = await maybeRefreshAuthHeaders(localUrl, "px", auth);
|
|
137
|
+
|
|
138
|
+
expect(ok).toBe(true);
|
|
139
|
+
expect(refresh.hit()).toBe(true);
|
|
140
|
+
expect(refresh.url()).toContain("127.0.0.1");
|
|
141
|
+
expect(refresh.url()).not.toContain("10.0.0.9");
|
|
142
|
+
});
|
|
143
|
+
|
|
105
144
|
test("does NOT refresh a local assistant (scoped to paired only)", async () => {
|
|
106
145
|
seedEntry("local");
|
|
107
146
|
seedToken("old-acc", "ref"); // even with a refreshable token
|
package/src/commands/client.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { hostname } from "node:os";
|
|
4
3
|
import path from "node:path";
|
|
5
4
|
|
|
6
5
|
import {
|
|
@@ -18,7 +17,7 @@ import {
|
|
|
18
17
|
type Species,
|
|
19
18
|
} from "../lib/constants";
|
|
20
19
|
import { loadGuardianToken, refreshGuardianToken } from "../lib/guardian-token";
|
|
21
|
-
import {
|
|
20
|
+
import { normalizeRuntimeUrl, trustedRefreshUrl } from "../lib/runtime-url";
|
|
22
21
|
import {
|
|
23
22
|
CLI_INTERFACE_ID,
|
|
24
23
|
WEB_INTERFACE_ID,
|
|
@@ -28,6 +27,7 @@ import {
|
|
|
28
27
|
getLockfileData,
|
|
29
28
|
upsertLockfileAssistant,
|
|
30
29
|
replacePlatformAssistants,
|
|
30
|
+
isActiveAssistant,
|
|
31
31
|
runHatch,
|
|
32
32
|
runRetire,
|
|
33
33
|
getGuardianAccessToken,
|
|
@@ -35,6 +35,8 @@ import {
|
|
|
35
35
|
resolveGatewayProxyTarget,
|
|
36
36
|
readAllowedGatewayPorts,
|
|
37
37
|
isLoopbackAddr,
|
|
38
|
+
headerHostIsLoopback,
|
|
39
|
+
originIsAllowed,
|
|
38
40
|
resolveDevCliInvocation,
|
|
39
41
|
resolveLockfilePaths,
|
|
40
42
|
resolveConfigDir,
|
|
@@ -212,7 +214,7 @@ export function parseArgs(): ParsedArgs {
|
|
|
212
214
|
}
|
|
213
215
|
|
|
214
216
|
return {
|
|
215
|
-
runtimeUrl:
|
|
217
|
+
runtimeUrl: normalizeRuntimeUrl(runtimeUrl),
|
|
216
218
|
assistantId,
|
|
217
219
|
assistantName,
|
|
218
220
|
species,
|
|
@@ -223,45 +225,6 @@ export function parseArgs(): ParsedArgs {
|
|
|
223
225
|
};
|
|
224
226
|
}
|
|
225
227
|
|
|
226
|
-
/**
|
|
227
|
-
* If the hostname in `url` matches this machine's local DNS name, LAN IP, or
|
|
228
|
-
* raw hostname, replace it with 127.0.0.1 so the client avoids mDNS round-trips
|
|
229
|
-
* when talking to an assistant running on the same machine.
|
|
230
|
-
*/
|
|
231
|
-
function maybeSwapToLocalhost(url: string): string {
|
|
232
|
-
let parsed: URL;
|
|
233
|
-
try {
|
|
234
|
-
parsed = new URL(url);
|
|
235
|
-
} catch {
|
|
236
|
-
return url;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const urlHost = parsed.hostname.toLowerCase();
|
|
240
|
-
|
|
241
|
-
const localNames: string[] = [];
|
|
242
|
-
|
|
243
|
-
const host = hostname();
|
|
244
|
-
if (host) {
|
|
245
|
-
localNames.push(host.toLowerCase());
|
|
246
|
-
// Also consider the bare name without .local suffix
|
|
247
|
-
if (host.toLowerCase().endsWith(".local")) {
|
|
248
|
-
localNames.push(host.toLowerCase().slice(0, -".local".length));
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
const lanIp = getLocalLanIPv4();
|
|
253
|
-
if (lanIp) {
|
|
254
|
-
localNames.push(lanIp);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
if (localNames.includes(urlHost)) {
|
|
258
|
-
parsed.hostname = "127.0.0.1";
|
|
259
|
-
return parsed.toString().replace(/\/+$/, "");
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
return url;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
228
|
function printUsage(): void {
|
|
266
229
|
console.log(`${ANSI.bold}vellum client${ANSI.reset} - Connect to a hatched assistant
|
|
267
230
|
|
|
@@ -424,6 +387,13 @@ async function handleLocalEndpoints(
|
|
|
424
387
|
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
425
388
|
}
|
|
426
389
|
|
|
390
|
+
if (
|
|
391
|
+
!headerHostIsLoopback(req.headers.get("host") ?? undefined) ||
|
|
392
|
+
!originIsAllowed(req.headers.get("origin") ?? undefined)
|
|
393
|
+
) {
|
|
394
|
+
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
395
|
+
}
|
|
396
|
+
|
|
427
397
|
// Lockfile
|
|
428
398
|
if (LOCKFILE_PATTERN.test(pathname)) {
|
|
429
399
|
if (req.method === "GET") {
|
|
@@ -530,6 +500,13 @@ async function handleLocalEndpoints(
|
|
|
530
500
|
);
|
|
531
501
|
}
|
|
532
502
|
|
|
503
|
+
if (!isActiveAssistant(lockfilePaths, assistantId)) {
|
|
504
|
+
return Response.json(
|
|
505
|
+
{ ok: false, error: "Can only retire the active local assistant" },
|
|
506
|
+
{ status: 403 },
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
533
510
|
let invocation: CliInvocation;
|
|
534
511
|
try {
|
|
535
512
|
invocation = resolveDevCliInvocation(_baseDir);
|
|
@@ -853,7 +830,17 @@ export async function resolveFreshBearerToken(
|
|
|
853
830
|
const renewAt = new Date(renewAtRaw).getTime();
|
|
854
831
|
if (!Number.isFinite(renewAt) || renewAt > Date.now()) return bearerToken;
|
|
855
832
|
|
|
856
|
-
|
|
833
|
+
// SECURITY: bind the refresh to the entry's persisted URL. `--url`/`-u` can
|
|
834
|
+
// override `runtimeUrl` while still reusing this stored guardian token, so a
|
|
835
|
+
// poisoned/attacker URL must not receive the long-lived refreshToken +
|
|
836
|
+
// deviceId. Refresh only when the URL is one of the entry's persisted URLs,
|
|
837
|
+
// and send to the trusted persisted URL — not the caller-supplied one.
|
|
838
|
+
const lookup = lookupAssistantByIdentifier(assistantId);
|
|
839
|
+
if (lookup.status !== "found") return bearerToken;
|
|
840
|
+
const refreshUrl = trustedRefreshUrl(lookup.entry, runtimeUrl);
|
|
841
|
+
if (!refreshUrl) return bearerToken;
|
|
842
|
+
|
|
843
|
+
const refreshed = await refreshGuardianToken(refreshUrl, assistantId);
|
|
857
844
|
return refreshed?.accessToken ?? bearerToken;
|
|
858
845
|
}
|
|
859
846
|
|
package/src/commands/message.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* subscribe to SSE events (use `vellum events` for that).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
|
|
9
11
|
import { extractFlag } from "../lib/arg-utils.js";
|
|
10
12
|
import { AssistantClient } from "../lib/assistant-client.js";
|
|
11
13
|
|
|
@@ -14,57 +16,145 @@ function printUsage(): void {
|
|
|
14
16
|
|
|
15
17
|
USAGE:
|
|
16
18
|
vellum message [assistant] <message>
|
|
19
|
+
vellum message [assistant] --file <path>
|
|
17
20
|
|
|
18
21
|
ARGUMENTS:
|
|
19
22
|
[assistant] Instance name (default: active assistant)
|
|
20
|
-
<message> Message content to send
|
|
23
|
+
<message> Message content to send (omit when using --file)
|
|
21
24
|
|
|
22
25
|
OPTIONS:
|
|
26
|
+
--file <path> Read message content from a file ("-" reads stdin)
|
|
23
27
|
--conversation-key <key> Conversation key (default: stable key per channel/interface)
|
|
24
28
|
--json Output raw JSON response
|
|
25
29
|
|
|
26
30
|
EXAMPLES:
|
|
27
31
|
vellum message "hello"
|
|
28
32
|
vellum message my-assistant "ping"
|
|
33
|
+
vellum message --file prompt.txt
|
|
34
|
+
vellum message my-assistant --file prompt.txt
|
|
35
|
+
cat prompt.txt | vellum message --file -
|
|
29
36
|
vellum message --conversation-key my-thread "hello"
|
|
30
37
|
vellum message --json "hello"
|
|
31
38
|
`);
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
interface ParsedMessageArgs {
|
|
42
|
+
assistantId?: string;
|
|
43
|
+
conversationKey?: string;
|
|
44
|
+
jsonOutput: boolean;
|
|
45
|
+
/** Path to read message content from, or undefined for an inline message. */
|
|
46
|
+
filePath?: string;
|
|
47
|
+
/** Inline message content, present only when --file was not used. */
|
|
48
|
+
inlineMessage?: string;
|
|
49
|
+
}
|
|
36
50
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
51
|
+
type ParseResult =
|
|
52
|
+
| { ok: true; value: ParsedMessageArgs }
|
|
53
|
+
| { ok: false; error: string };
|
|
41
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Parse `vellum message` arguments. Pure: does no I/O and never exits, so the
|
|
57
|
+
* positional/flag rules can be unit-tested. File reading and validation of the
|
|
58
|
+
* resolved content happen in {@link message}.
|
|
59
|
+
*/
|
|
60
|
+
export function parseMessageArgs(rawArgs: string[]): ParseResult {
|
|
42
61
|
const jsonOutput = rawArgs.includes("--json");
|
|
43
62
|
let args = rawArgs.filter((a) => a !== "--json");
|
|
44
63
|
|
|
45
|
-
const [conversationKey,
|
|
64
|
+
const [conversationKey, afterConversationKey] = extractFlag(
|
|
46
65
|
args,
|
|
47
66
|
"--conversation-key",
|
|
48
67
|
);
|
|
49
|
-
args =
|
|
68
|
+
args = afterConversationKey;
|
|
69
|
+
|
|
70
|
+
const fileFlagPresent = args.includes("--file");
|
|
71
|
+
const [filePath, afterFile] = extractFlag(args, "--file");
|
|
72
|
+
args = afterFile;
|
|
73
|
+
|
|
74
|
+
// `extractFlag` strips a trailing value-less `--file`, which would otherwise
|
|
75
|
+
// make the next positional masquerade as the message content. Reject it.
|
|
76
|
+
if (fileFlagPresent && filePath === undefined) {
|
|
77
|
+
return { ok: false, error: "--file requires a path argument." };
|
|
78
|
+
}
|
|
50
79
|
|
|
51
|
-
|
|
52
|
-
|
|
80
|
+
if (filePath !== undefined) {
|
|
81
|
+
// vellum message [assistant] --file <path>
|
|
82
|
+
// The message content comes from the file, so any remaining positional
|
|
83
|
+
// arg is the assistant target.
|
|
84
|
+
if (args.length >= 2) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
error: "--file cannot be combined with an inline message argument.",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
ok: true,
|
|
92
|
+
value: { assistantId: args[0], conversationKey, jsonOutput, filePath },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
53
95
|
|
|
54
96
|
if (args.length >= 2) {
|
|
55
97
|
// vellum message <assistant> <message>
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
value: {
|
|
101
|
+
assistantId: args[0],
|
|
102
|
+
conversationKey,
|
|
103
|
+
jsonOutput,
|
|
104
|
+
inlineMessage: args[1],
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (args.length === 1) {
|
|
59
109
|
// vellum message <message> (uses active/latest assistant)
|
|
60
|
-
|
|
110
|
+
return {
|
|
111
|
+
ok: true,
|
|
112
|
+
value: { conversationKey, jsonOutput, inlineMessage: args[0] },
|
|
113
|
+
};
|
|
61
114
|
}
|
|
62
115
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
116
|
+
return { ok: false, error: "message content is required." };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function exitWithUsage(error: string): never {
|
|
120
|
+
console.error(`Error: ${error}`);
|
|
121
|
+
console.error("");
|
|
122
|
+
printUsage();
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function message(): Promise<void> {
|
|
127
|
+
const rawArgs = process.argv.slice(3);
|
|
128
|
+
|
|
129
|
+
if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
|
|
66
130
|
printUsage();
|
|
67
|
-
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const parsed = parseMessageArgs(rawArgs);
|
|
135
|
+
if (!parsed.ok) {
|
|
136
|
+
exitWithUsage(parsed.error);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const { assistantId, conversationKey, jsonOutput, filePath, inlineMessage } =
|
|
140
|
+
parsed.value;
|
|
141
|
+
|
|
142
|
+
let messageContent: string;
|
|
143
|
+
if (filePath !== undefined) {
|
|
144
|
+
try {
|
|
145
|
+
messageContent = readFileSync(filePath === "-" ? 0 : filePath, "utf-8");
|
|
146
|
+
} catch (error) {
|
|
147
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
148
|
+
console.error(
|
|
149
|
+
`Error: could not read message file "${filePath}": ${reason}`,
|
|
150
|
+
);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
if (messageContent.length === 0) {
|
|
154
|
+
exitWithUsage(`message file "${filePath}" is empty.`);
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
messageContent = inlineMessage ?? "";
|
|
68
158
|
}
|
|
69
159
|
|
|
70
160
|
const client = new AssistantClient({ assistantId });
|
|
@@ -13,6 +13,7 @@ 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
15
|
import { loadGuardianToken, refreshGuardianToken } from "../lib/guardian-token";
|
|
16
|
+
import { trustedRefreshUrl } from "../lib/runtime-url";
|
|
16
17
|
import { appendHistory, loadHistory } from "../lib/input-history";
|
|
17
18
|
import { tuiLog } from "../lib/tui-log";
|
|
18
19
|
import { segmentsToPlainText } from "../lib/segments-to-plain-text";
|
|
@@ -193,6 +194,16 @@ function friendlyErrorMessage(status: number, body: string): string {
|
|
|
193
194
|
* and access-only tokens. Because the TUI threads one shared `auth` object by
|
|
194
195
|
* reference, mutating it here propagates to every later request and the SSE
|
|
195
196
|
* reconnect — no callback threading needed.
|
|
197
|
+
*
|
|
198
|
+
* SECURITY: the refresh is bound to the paired entry's persisted runtime URL.
|
|
199
|
+
* `vellum client` lets `--url`/`-u` override the runtime URL while still using
|
|
200
|
+
* the selected paired entry's stored guardian token, so a victim pointed at an
|
|
201
|
+
* attacker-controlled (or poisoned/redirected) URL that returns 401 must NOT
|
|
202
|
+
* cause us to POST the long-lived refreshToken + deviceId to that origin. We
|
|
203
|
+
* therefore (a) refuse to refresh unless `baseUrl` normalizes to one of the
|
|
204
|
+
* entry's persisted URLs, and (b) send the refresh to the persisted URL rather
|
|
205
|
+
* than the caller-supplied `baseUrl` — defense in depth if the gate is ever
|
|
206
|
+
* bypassed.
|
|
196
207
|
*/
|
|
197
208
|
export async function maybeRefreshAuthHeaders(
|
|
198
209
|
baseUrl: string,
|
|
@@ -210,11 +221,18 @@ export async function maybeRefreshAuthHeaders(
|
|
|
210
221
|
return false;
|
|
211
222
|
}
|
|
212
223
|
|
|
224
|
+
// Bind the refresh origin to the persisted paired entry: refuse (and never
|
|
225
|
+
// leak credentials) if `baseUrl` was overridden via --url or poisoned to an
|
|
226
|
+
// origin that isn't one of the entry's persisted URLs. `refreshUrl` is the
|
|
227
|
+
// trusted persisted URL we actually send to.
|
|
228
|
+
const refreshUrl = trustedRefreshUrl(lookup.entry, baseUrl);
|
|
229
|
+
if (!refreshUrl) return false;
|
|
230
|
+
|
|
213
231
|
const stored = loadGuardianToken(assistantId);
|
|
214
232
|
if (!stored || stored.accessToken !== bearer || !stored.refreshToken) {
|
|
215
233
|
return false;
|
|
216
234
|
}
|
|
217
|
-
const refreshed = await refreshGuardianToken(
|
|
235
|
+
const refreshed = await refreshGuardianToken(refreshUrl, assistantId);
|
|
218
236
|
if (!refreshed?.accessToken) return false;
|
|
219
237
|
auth["Authorization"] = `Bearer ${refreshed.accessToken}`;
|
|
220
238
|
return true;
|
|
@@ -293,20 +293,22 @@ describe("collectWatchTargets", () => {
|
|
|
293
293
|
rmSync(repoRoot, { recursive: true, force: true });
|
|
294
294
|
});
|
|
295
295
|
|
|
296
|
-
function scaffold(
|
|
296
|
+
function scaffold(
|
|
297
|
+
relDir: string,
|
|
298
|
+
{ src = true, pkg = true, dockerfile = false } = {},
|
|
299
|
+
): void {
|
|
300
|
+
mkdirSync(join(repoRoot, relDir), { recursive: true });
|
|
297
301
|
if (src) mkdirSync(join(repoRoot, relDir, "src"), { recursive: true });
|
|
298
|
-
if (pkg) {
|
|
299
|
-
|
|
300
|
-
writeFileSync(join(repoRoot, relDir, "package.json"), "{}");
|
|
301
|
-
}
|
|
302
|
+
if (pkg) writeFileSync(join(repoRoot, relDir, "package.json"), "{}");
|
|
303
|
+
if (dockerfile) writeFileSync(join(repoRoot, relDir, "Dockerfile"), "");
|
|
302
304
|
}
|
|
303
305
|
|
|
304
|
-
test("scopes watch targets to
|
|
305
|
-
// GIVEN the three services plus a couple of
|
|
306
|
-
//
|
|
307
|
-
scaffold("assistant");
|
|
308
|
-
scaffold("credential-executor");
|
|
309
|
-
scaffold("gateway");
|
|
306
|
+
test("scopes watch targets to src/, package.json, and the Dockerfile", () => {
|
|
307
|
+
// GIVEN the three services (each with a Dockerfile) plus a couple of
|
|
308
|
+
// shared packages (libraries, no Dockerfile)
|
|
309
|
+
scaffold("assistant", { dockerfile: true });
|
|
310
|
+
scaffold("credential-executor", { dockerfile: true });
|
|
311
|
+
scaffold("gateway", { dockerfile: true });
|
|
310
312
|
scaffold("packages/service-contracts");
|
|
311
313
|
scaffold("packages/local-mode");
|
|
312
314
|
|
|
@@ -324,12 +326,16 @@ describe("collectWatchTargets", () => {
|
|
|
324
326
|
].sort(),
|
|
325
327
|
);
|
|
326
328
|
|
|
327
|
-
// AND
|
|
329
|
+
// AND the package.json manifests and service Dockerfiles are watched as
|
|
330
|
+
// individual files (packages have no Dockerfile, so none is emitted)
|
|
328
331
|
expect(files.sort()).toEqual(
|
|
329
332
|
[
|
|
330
333
|
join(repoRoot, "assistant", "package.json"),
|
|
334
|
+
join(repoRoot, "assistant", "Dockerfile"),
|
|
331
335
|
join(repoRoot, "credential-executor", "package.json"),
|
|
336
|
+
join(repoRoot, "credential-executor", "Dockerfile"),
|
|
332
337
|
join(repoRoot, "gateway", "package.json"),
|
|
338
|
+
join(repoRoot, "gateway", "Dockerfile"),
|
|
333
339
|
join(repoRoot, "packages", "local-mode", "package.json"),
|
|
334
340
|
join(repoRoot, "packages", "service-contracts", "package.json"),
|
|
335
341
|
].sort(),
|
package/src/lib/docker.ts
CHANGED
|
@@ -791,7 +791,7 @@ export async function captureImageRefs(
|
|
|
791
791
|
|
|
792
792
|
/**
|
|
793
793
|
* Build the set of paths the hot-reload watcher should observe, scoped to
|
|
794
|
-
* each service's `src/` tree
|
|
794
|
+
* each service's `src/` tree, `package.json` manifest, and `Dockerfile`.
|
|
795
795
|
*
|
|
796
796
|
* We deliberately avoid recursively watching whole service directories.
|
|
797
797
|
* Those contain `.claude/` command symlinks — which dangle in a fresh
|
|
@@ -799,8 +799,11 @@ export async function captureImageRefs(
|
|
|
799
799
|
* repo — as well as `node_modules`. `fs.watch(dir, { recursive: true })`
|
|
800
800
|
* traverses those entries and emits an unhandled `error` event on a broken
|
|
801
801
|
* symlink, which crashes the CLI process. Source code only ever lives under
|
|
802
|
-
* `src
|
|
803
|
-
*
|
|
802
|
+
* `src/`, so watching that tree plus the two manifests that drive the image
|
|
803
|
+
* build (`package.json` and `Dockerfile`) preserves hot-reload without
|
|
804
|
+
* walking into symlinked or generated trees. The `Dockerfile` is watched as
|
|
805
|
+
* an individual file for the same reason — editing build steps should
|
|
806
|
+
* trigger a rebuild, but the file sits next to the symlinked trees we avoid.
|
|
804
807
|
*
|
|
805
808
|
* Returning a plain record keeps this trivially unit-testable — see
|
|
806
809
|
* `__tests__/docker.test.ts`.
|
|
@@ -828,8 +831,10 @@ export function collectWatchTargets(repoRoot: string): {
|
|
|
828
831
|
for (const root of serviceRoots) {
|
|
829
832
|
const srcDir = join(root, "src");
|
|
830
833
|
if (existsSync(srcDir)) dirs.push(srcDir);
|
|
831
|
-
const
|
|
832
|
-
|
|
834
|
+
for (const name of ["package.json", "Dockerfile"]) {
|
|
835
|
+
const file = join(root, name);
|
|
836
|
+
if (existsSync(file)) files.push(file);
|
|
837
|
+
}
|
|
833
838
|
}
|
|
834
839
|
return { dirs, files };
|
|
835
840
|
}
|
|
@@ -868,8 +873,8 @@ function affectedServices(
|
|
|
868
873
|
|
|
869
874
|
/**
|
|
870
875
|
* Watch for source changes across the assistant, gateway, credential-executor,
|
|
871
|
-
* and packages services — scoped to each service's `src/` tree
|
|
872
|
-
* `
|
|
876
|
+
* and packages services — scoped to each service's `src/` tree, `package.json`,
|
|
877
|
+
* and `Dockerfile` (see `collectWatchTargets`). When changes are detected,
|
|
873
878
|
* rebuild the affected images and restart their containers.
|
|
874
879
|
*/
|
|
875
880
|
function startFileWatcher(opts: {
|
|
@@ -1006,8 +1011,8 @@ function startFileWatcher(opts: {
|
|
|
1006
1011
|
}
|
|
1007
1012
|
|
|
1008
1013
|
console.log("👀 Watching for file changes in:");
|
|
1009
|
-
console.log(" <service>/src
|
|
1010
|
-
console.log(" assistant/, gateway/, credential-executor/, packages/*");
|
|
1014
|
+
console.log(" <service>/src, <service>/package.json, <service>/Dockerfile");
|
|
1015
|
+
console.log(" for assistant/, gateway/, credential-executor/, packages/*");
|
|
1011
1016
|
console.log("");
|
|
1012
1017
|
|
|
1013
1018
|
return () => {
|
package/src/lib/runtime-url.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { hostname } from "node:os";
|
|
2
|
+
|
|
3
|
+
import { getLocalLanIPv4 } from "./local";
|
|
1
4
|
import type { AssistantEntry } from "./assistant-config.js";
|
|
2
5
|
|
|
3
6
|
/**
|
|
@@ -50,3 +53,90 @@ export function resolveRuntimeUrl(
|
|
|
50
53
|
}
|
|
51
54
|
return `${entry.runtimeUrl}/v1/${subpath}`;
|
|
52
55
|
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* If the hostname in `url` matches this machine's local DNS name, LAN IP, or
|
|
59
|
+
* raw hostname, replace it with 127.0.0.1 so the client avoids mDNS round-trips
|
|
60
|
+
* when talking to an assistant running on the same machine. Trailing slashes are
|
|
61
|
+
* stripped on a swap. Returns the input unchanged if it doesn't parse as a URL.
|
|
62
|
+
*/
|
|
63
|
+
function maybeSwapToLocalhost(url: string): string {
|
|
64
|
+
let parsed: URL;
|
|
65
|
+
try {
|
|
66
|
+
parsed = new URL(url);
|
|
67
|
+
} catch {
|
|
68
|
+
return url;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const urlHost = parsed.hostname.toLowerCase();
|
|
72
|
+
|
|
73
|
+
const localNames: string[] = [];
|
|
74
|
+
|
|
75
|
+
const host = hostname();
|
|
76
|
+
if (host) {
|
|
77
|
+
localNames.push(host.toLowerCase());
|
|
78
|
+
// Also consider the bare name without .local suffix
|
|
79
|
+
if (host.toLowerCase().endsWith(".local")) {
|
|
80
|
+
localNames.push(host.toLowerCase().slice(0, -".local".length));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const lanIp = getLocalLanIPv4();
|
|
85
|
+
if (lanIp) {
|
|
86
|
+
localNames.push(lanIp);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (localNames.includes(urlHost)) {
|
|
90
|
+
parsed.hostname = "127.0.0.1";
|
|
91
|
+
return parsed.toString().replace(/\/+$/, "");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return url;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Canonical form of a runtime/base URL used throughout the CLI: trailing
|
|
99
|
+
* slashes stripped, then localhost-swapped. This is exactly the transform
|
|
100
|
+
* `vellum client` applies to the runtime URL it hands the TUI, so comparing two
|
|
101
|
+
* URLs after passing both through this function is a like-for-like comparison.
|
|
102
|
+
*/
|
|
103
|
+
export function normalizeRuntimeUrl(url: string): string {
|
|
104
|
+
return maybeSwapToLocalhost(url.replace(/\/+$/, ""));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* SECURITY: decide whether a guardian-token refresh may be sent to
|
|
109
|
+
* `candidateUrl`, and to which URL it should actually go.
|
|
110
|
+
*
|
|
111
|
+
* `vellum client` lets `--url`/`-u` override the runtime URL while still reusing
|
|
112
|
+
* the selected entry's stored guardian token, so a victim pointed at an
|
|
113
|
+
* attacker-controlled (or poisoned/redirected) URL must NOT cause us to POST the
|
|
114
|
+
* long-lived refreshToken + deviceId there. Refresh is permitted only when
|
|
115
|
+
* `candidateUrl` normalizes to one of the entry's persisted URLs (`localUrl`,
|
|
116
|
+
* which the CLI prefers when present, or `runtimeUrl`).
|
|
117
|
+
*
|
|
118
|
+
* Returns the persisted URL that the candidate matched — never the
|
|
119
|
+
* caller-supplied `candidateUrl` verbatim — so credentials only ever reach a
|
|
120
|
+
* trusted origin even if a caller forgets to use this return value. The matched
|
|
121
|
+
* URL is preferred over always returning `runtimeUrl` so the refresh stays on
|
|
122
|
+
* the same interface the session is using: e.g. a local entry may persist both a
|
|
123
|
+
* loopback `localUrl` (which `vellum client` defaults to) and an externally
|
|
124
|
+
* discovered `runtimeUrl`, and refreshing the loopback session against the
|
|
125
|
+
* external address could be unreachable or needlessly cross the public
|
|
126
|
+
* interface. Returns `null` when the candidate is untrusted (caller must skip
|
|
127
|
+
* the refresh).
|
|
128
|
+
*/
|
|
129
|
+
export function trustedRefreshUrl(
|
|
130
|
+
entry: Pick<AssistantEntry, "runtimeUrl" | "localUrl">,
|
|
131
|
+
candidateUrl: string,
|
|
132
|
+
): string | null {
|
|
133
|
+
const candidate = normalizeRuntimeUrl(candidateUrl);
|
|
134
|
+
// localUrl first: it's what the CLI prefers when present, so the candidate is
|
|
135
|
+
// most likely to match it, and we want to keep the refresh on that interface.
|
|
136
|
+
for (const persisted of [entry.localUrl, entry.runtimeUrl]) {
|
|
137
|
+
if (persisted && normalizeRuntimeUrl(persisted) === candidate) {
|
|
138
|
+
return persisted;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|