@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.
@@ -0,0 +1,88 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, test } from "bun:test";
5
+
6
+ import { headerHostIsLoopback, originIsAllowed } from "../util";
7
+ import { isActiveAssistant } from "../lockfile";
8
+
9
+ describe("headerHostIsLoopback", () => {
10
+ test("rejects DNS-rebound hosts", () => {
11
+ expect(headerHostIsLoopback("attacker.example")).toBe(false);
12
+ expect(headerHostIsLoopback("evil.com:3000")).toBe(false);
13
+ });
14
+
15
+ test("accepts loopback hosts", () => {
16
+ expect(headerHostIsLoopback("127.0.0.1:3000")).toBe(true);
17
+ expect(headerHostIsLoopback("localhost:3000")).toBe(true);
18
+ expect(headerHostIsLoopback("localhost")).toBe(true);
19
+ expect(headerHostIsLoopback("[::1]:3000")).toBe(true);
20
+ });
21
+
22
+ test("rejects undefined/empty", () => {
23
+ expect(headerHostIsLoopback(undefined)).toBe(false);
24
+ expect(headerHostIsLoopback("")).toBe(false);
25
+ });
26
+ });
27
+
28
+ describe("originIsAllowed", () => {
29
+ test("rejects cross-origin requests", () => {
30
+ expect(originIsAllowed("https://attacker.example")).toBe(false);
31
+ expect(originIsAllowed("http://evil.com")).toBe(false);
32
+ });
33
+
34
+ test("accepts localhost origins", () => {
35
+ expect(originIsAllowed("http://localhost:3000")).toBe(true);
36
+ expect(originIsAllowed("http://127.0.0.1:3000")).toBe(true);
37
+ });
38
+
39
+ test("allows absent origin (non-browser clients)", () => {
40
+ expect(originIsAllowed(undefined)).toBe(true);
41
+ });
42
+ });
43
+
44
+ describe("isActiveAssistant", () => {
45
+ const tempDirs: string[] = [];
46
+
47
+ afterEach(() => {
48
+ for (const dir of tempDirs.splice(0)) {
49
+ fs.rmSync(dir, { recursive: true, force: true });
50
+ }
51
+ });
52
+
53
+ function makeTempDir(): string {
54
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vellum-local-mode-test-"));
55
+ tempDirs.push(dir);
56
+ return dir;
57
+ }
58
+
59
+ test("returns true for the active assistant", () => {
60
+ const dir = makeTempDir();
61
+ const lockfilePath = path.join(dir, "lockfile.json");
62
+ fs.writeFileSync(
63
+ lockfilePath,
64
+ JSON.stringify({
65
+ assistants: [{ assistantId: "active" }, { assistantId: "inactive" }],
66
+ activeAssistant: "active",
67
+ }),
68
+ );
69
+ expect(isActiveAssistant([lockfilePath], "active")).toBe(true);
70
+ });
71
+
72
+ test("returns false for a non-active assistant", () => {
73
+ const dir = makeTempDir();
74
+ const lockfilePath = path.join(dir, "lockfile.json");
75
+ fs.writeFileSync(
76
+ lockfilePath,
77
+ JSON.stringify({
78
+ assistants: [{ assistantId: "active" }, { assistantId: "inactive" }],
79
+ activeAssistant: "active",
80
+ }),
81
+ );
82
+ expect(isActiveAssistant([lockfilePath], "inactive")).toBe(false);
83
+ });
84
+
85
+ test("returns false when lockfile does not exist", () => {
86
+ expect(isActiveAssistant(["/nonexistent/lockfile.json"], "any")).toBe(false);
87
+ });
88
+ });
@@ -9,6 +9,8 @@
9
9
  export {
10
10
  stripSensitiveFields,
11
11
  isLoopbackAddr,
12
+ headerHostIsLoopback,
13
+ originIsAllowed,
12
14
  resolveDevCliInvocation,
13
15
  } from "./util";
14
16
  export type { CliInvocation } from "./util";
@@ -19,6 +21,7 @@ export {
19
21
  getLockfileData,
20
22
  upsertLockfileAssistant,
21
23
  replacePlatformAssistants,
24
+ isActiveAssistant,
22
25
  } from "./lockfile";
23
26
  export type { LockfileResult, WriteResult } from "./lockfile";
24
27
  export { parseLockfile } from "./lockfile-contract";
@@ -88,6 +88,21 @@ export function upsertLockfileAssistant(
88
88
  return { ok: true, lockfile: parseLockfile(stripped) };
89
89
  }
90
90
 
91
+ export function isActiveAssistant(
92
+ lockfilePaths: string[],
93
+ assistantId: string,
94
+ ): boolean {
95
+ for (const candidate of lockfilePaths) {
96
+ try {
97
+ const data = JSON.parse(fs.readFileSync(candidate, "utf-8")) as Record<string, unknown>;
98
+ return data.activeAssistant === assistantId;
99
+ } catch {
100
+ continue;
101
+ }
102
+ }
103
+ return false;
104
+ }
105
+
91
106
  export function replacePlatformAssistants(
92
107
  lockfilePaths: string[],
93
108
  platformAssistants: Array<Record<string, unknown>>,
@@ -42,6 +42,39 @@ export function stripSensitiveFields(data: Record<string, unknown>): void {
42
42
  }
43
43
  }
44
44
 
45
+ function isLoopbackHostname(hostname: string): boolean {
46
+ const normalized = hostname.toLowerCase();
47
+ return (
48
+ normalized === "localhost" ||
49
+ normalized === "[::1]" ||
50
+ normalized === "::1" ||
51
+ normalized === "0:0:0:0:0:0:0:1" ||
52
+ /^127(?:\.\d{1,3}){3}$/.test(normalized)
53
+ );
54
+ }
55
+
56
+ export function headerHostIsLoopback(hostHeader: string | undefined): boolean {
57
+ if (!hostHeader) return false;
58
+ try {
59
+ return isLoopbackHostname(new URL(`http://${hostHeader}`).hostname);
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ export function originIsAllowed(originHeader: string | undefined): boolean {
66
+ if (!originHeader) return true;
67
+ try {
68
+ const origin = new URL(originHeader);
69
+ return (
70
+ (origin.protocol === "http:" || origin.protocol === "https:") &&
71
+ isLoopbackHostname(origin.hostname)
72
+ );
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
45
78
  export function isLoopbackAddr(addr: string): boolean {
46
79
  const v4Mapped = addr.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
47
80
  const normalized = v4Mapped ? v4Mapped[1]! : addr;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.8.8-dev.202606081515.c77a9b6",
3
+ "version": "0.8.8-dev.202606081859.f7bdc00",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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): { hit: () => boolean } {
42
- let called = false;
55
+ /** Stub global fetch; returns whether the refresh endpoint was hit and where. */
56
+ function stubRefresh(ok: boolean): {
57
+ hit: () => boolean;
58
+ url: () => string | undefined;
59
+ } {
60
+ let calledUrl: string | undefined;
43
61
  globalThis.fetch = (async (url: unknown, _init?: RequestInit) => {
44
62
  if (String(url).includes("/v1/guardian/refresh")) {
45
- called = true;
63
+ calledUrl = String(url);
46
64
  return new Response(
47
65
  ok ? JSON.stringify({ accessToken: "new-acc" }) : "nope",
48
66
  {
@@ -53,7 +71,7 @@ function stubRefresh(ok: boolean): { hit: () => boolean } {
53
71
  }
54
72
  return new Response("", { status: 200 });
55
73
  }) as typeof fetch;
56
- return { hit: () => called };
74
+ return { hit: () => calledUrl !== undefined, url: () => calledUrl };
57
75
  }
58
76
 
59
77
  describe("resolveFreshBearerToken", () => {
@@ -62,6 +80,9 @@ describe("resolveFreshBearerToken", () => {
62
80
  beforeEach(() => {
63
81
  tempHome = mkdtempSync(join(tmpdir(), "client-tui-refresh-test-"));
64
82
  process.env.XDG_CONFIG_HOME = tempHome;
83
+ // Isolate the lockfile too — saveAssistantEntry writes the prod lockfile
84
+ // (~/.vellum.lock.json) unless VELLUM_LOCKFILE_DIR is set.
85
+ process.env.VELLUM_LOCKFILE_DIR = tempHome;
65
86
  delete process.env.VELLUM_ENVIRONMENT; // prod config dir
66
87
  });
67
88
 
@@ -69,12 +90,16 @@ describe("resolveFreshBearerToken", () => {
69
90
  globalThis.fetch = ORIGINAL_FETCH;
70
91
  if (ORIGINAL_XDG === undefined) delete process.env.XDG_CONFIG_HOME;
71
92
  else process.env.XDG_CONFIG_HOME = ORIGINAL_XDG;
93
+ if (ORIGINAL_LOCKFILE_DIR === undefined)
94
+ delete process.env.VELLUM_LOCKFILE_DIR;
95
+ else process.env.VELLUM_LOCKFILE_DIR = ORIGINAL_LOCKFILE_DIR;
72
96
  if (ORIGINAL_ENV === undefined) delete process.env.VELLUM_ENVIRONMENT;
73
97
  else process.env.VELLUM_ENVIRONMENT = ORIGINAL_ENV;
74
98
  rmSync(tempHome, { recursive: true, force: true });
75
99
  });
76
100
 
77
101
  test("refreshes a stale stored token and returns the new access token", async () => {
102
+ seedEntry("paired");
78
103
  seed({ accessToken: "old-acc", refreshToken: "ref", refreshAfter: past() });
79
104
  const refresh = stubRefresh(true);
80
105
 
@@ -89,6 +114,24 @@ describe("resolveFreshBearerToken", () => {
89
114
  expect(refresh.hit()).toBe(true);
90
115
  });
91
116
 
117
+ test("does NOT refresh against an overridden/poisoned runtime URL (no credential leak)", async () => {
118
+ // --url can override the runtime URL while still reusing the stored guardian
119
+ // token; a stale token must NOT be refreshed against an attacker origin.
120
+ seedEntry("paired"); // persisted runtimeUrl = RUNTIME
121
+ seed({ accessToken: "old-acc", refreshToken: "ref", refreshAfter: past() });
122
+ const refresh = stubRefresh(true);
123
+
124
+ const token = await resolveFreshBearerToken(
125
+ "http://attacker.example:7830",
126
+ "px",
127
+ "old-acc",
128
+ "paired",
129
+ );
130
+
131
+ expect(token).toBe("old-acc"); // unchanged
132
+ expect(refresh.hit()).toBe(false); // no refresh POST anywhere
133
+ });
134
+
92
135
  test("leaves a still-fresh stored token unchanged (no refresh)", async () => {
93
136
  seed({
94
137
  accessToken: "old-acc",
@@ -140,6 +183,7 @@ describe("resolveFreshBearerToken", () => {
140
183
  });
141
184
 
142
185
  test("falls back to the existing token when refresh fails", async () => {
186
+ seedEntry("paired");
143
187
  seed({ accessToken: "old-acc", refreshToken: "ref", refreshAfter: past() });
144
188
  stubRefresh(false); // refresh endpoint returns non-ok
145
189
 
@@ -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): { hit: () => boolean } {
50
- let called = false;
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
- called = true;
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: () => called };
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
@@ -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 { getLocalLanIPv4 } from "../lib/local";
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: maybeSwapToLocalhost(runtimeUrl.replace(/\/+$/, "")),
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
- const refreshed = await refreshGuardianToken(runtimeUrl, assistantId);
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
 
@@ -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
- export async function message(): Promise<void> {
35
- const rawArgs = process.argv.slice(3);
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
- if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
38
- printUsage();
39
- return;
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, filteredArgs] = extractFlag(
64
+ const [conversationKey, afterConversationKey] = extractFlag(
46
65
  args,
47
66
  "--conversation-key",
48
67
  );
49
- args = filteredArgs;
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
- let assistantId: string | undefined;
52
- let messageContent: string | undefined;
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
- assistantId = args[0];
57
- messageContent = args[1];
58
- } else if (args.length === 1) {
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
- messageContent = args[0];
110
+ return {
111
+ ok: true,
112
+ value: { conversationKey, jsonOutput, inlineMessage: args[0] },
113
+ };
61
114
  }
62
115
 
63
- if (!messageContent) {
64
- console.error("Error: message content is required.");
65
- console.error("");
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
- process.exit(1);
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(baseUrl, assistantId);
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(relDir: string, { src = true, pkg = true } = {}): void {
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
- mkdirSync(join(repoRoot, relDir), { recursive: true });
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 each service's src/ tree and package.json", () => {
305
- // GIVEN the three services plus a couple of shared packages, each with a
306
- // src/ directory and a package.json manifest
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 only the package.json manifests are watched as files
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 and `package.json` manifest.
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/` (plus the manifest), so watching those paths preserves hot-reload
803
- * without walking into symlinked or generated trees.
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 manifest = join(root, "package.json");
832
- if (existsSync(manifest)) files.push(manifest);
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 and
872
- * `package.json` (see `collectWatchTargets`). When changes are detected,
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 and <service>/package.json for");
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 () => {
@@ -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
+ }