@vellumai/cli 0.8.8-dev.202606081714.5590368 → 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.202606081714.5590368",
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;
@@ -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
+ }