@vellumai/cli 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/index.ts",
8
8
  "./package.json": "./package.json",
9
9
  "./src/components/DefaultMainScreen": "./src/components/DefaultMainScreen.tsx",
10
- "./src/lib/constants": "./src/lib/constants.ts"
10
+ "./src/lib/constants": "./src/lib/constants.ts",
11
+ "./src/commands/*": "./src/commands/*.ts"
11
12
  },
12
13
  "bin": {
13
14
  "vellum-cli": "./src/index.ts"
@@ -1,3 +1,6 @@
1
+ import { readFileSync } from "fs";
2
+ import { join } from "path";
3
+
1
4
  import { ANSI, renderChatApp } from "../components/DefaultMainScreen";
2
5
  import { findAssistantByName, loadLatestAssistant } from "../lib/assistant-config";
3
6
  import { GATEWAY_PORT, type Species } from "../lib/constants";
@@ -42,7 +45,19 @@ function parseArgs(): ParsedArgs {
42
45
 
43
46
  let runtimeUrl = process.env.RUNTIME_URL || entry?.runtimeUrl || FALLBACK_RUNTIME_URL;
44
47
  let assistantId = process.env.ASSISTANT_ID || entry?.assistantId || FALLBACK_ASSISTANT_ID;
45
- const bearerToken = process.env.RUNTIME_PROXY_BEARER_TOKEN || entry?.bearerToken || undefined;
48
+ let bearerToken = process.env.RUNTIME_PROXY_BEARER_TOKEN || entry?.bearerToken || undefined;
49
+
50
+ // For local assistants, read the daemon's http-token file as a fallback
51
+ // when the lockfile doesn't include a bearer token.
52
+ if (!bearerToken && entry?.cloud === "local") {
53
+ const tokenDir = entry.baseDataDir ?? join(process.env.HOME ?? "", ".vellum");
54
+ try {
55
+ const token = readFileSync(join(tokenDir, "http-token"), "utf-8").trim();
56
+ if (token) bearerToken = token;
57
+ } catch {
58
+ // Token file may not exist
59
+ }
60
+ }
46
61
  const species: Species = (entry?.species as Species) ?? "vellum";
47
62
 
48
63
  for (let i = 0; i < flagArgs.length; i++) {
@@ -0,0 +1,88 @@
1
+ /**
2
+ * CLI command: `vellum email`
3
+ *
4
+ * Supports:
5
+ * - `vellum email status` — show current email configuration
6
+ * - `vellum email create <username>` — provision a new email inbox
7
+ */
8
+
9
+ import { VellumEmailClient } from "../email/vellum.js";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Helpers
13
+ // ---------------------------------------------------------------------------
14
+
15
+ function output(data: unknown): void {
16
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
17
+ }
18
+
19
+ function exitError(message: string): void {
20
+ output({ ok: false, error: message });
21
+ process.exitCode = 1;
22
+ }
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Usage
26
+ // ---------------------------------------------------------------------------
27
+
28
+ function printUsage(): void {
29
+ console.log(`Usage: vellum email <subcommand> [options]
30
+
31
+ Subcommands:
32
+ status Show email status (address, inboxes, callback URL)
33
+ create <username> Create a new email inbox for the given username
34
+
35
+ Options:
36
+ --help, -h Show this help message
37
+ `);
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Entry point
42
+ // ---------------------------------------------------------------------------
43
+
44
+ export async function email(): Promise<void> {
45
+ const args = process.argv.slice(3); // everything after "email"
46
+
47
+ if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
48
+ printUsage();
49
+ return;
50
+ }
51
+
52
+ const subcommand = args[0];
53
+
54
+ switch (subcommand) {
55
+ case "status": {
56
+ try {
57
+ const client = new VellumEmailClient();
58
+ const status = await client.status();
59
+ output({
60
+ ok: true,
61
+ provider: status.provider,
62
+ inboxes: status.inboxes,
63
+ });
64
+ } catch (err) {
65
+ exitError(err instanceof Error ? err.message : String(err));
66
+ }
67
+ break;
68
+ }
69
+ case "create": {
70
+ const username = args[1];
71
+ if (!username) {
72
+ exitError("Usage: vellum email create <username>");
73
+ return;
74
+ }
75
+ try {
76
+ const client = new VellumEmailClient();
77
+ const inbox = await client.createInbox(username);
78
+ output({ ok: true, inbox });
79
+ } catch (err) {
80
+ exitError(err instanceof Error ? err.message : String(err));
81
+ }
82
+ break;
83
+ }
84
+ default:
85
+ exitError(`Unknown email subcommand: ${subcommand}`);
86
+ printUsage();
87
+ }
88
+ }
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from "crypto";
2
- import { existsSync, unlinkSync, writeFileSync } from "fs";
2
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "fs";
3
3
  import { homedir, tmpdir, userInfo } from "os";
4
4
  import { join } from "path";
5
5
 
@@ -549,10 +549,22 @@ async function hatchLocal(species: Species, name: string | null, daemonOnly: boo
549
549
  }
550
550
 
551
551
  const baseDataDir = join(process.env.BASE_DATA_DIR?.trim() || (process.env.HOME ?? userInfo().homedir), ".vellum");
552
+
553
+ // Read the bearer token written by the daemon so the client can authenticate
554
+ // with the gateway (which requires auth by default).
555
+ let bearerToken: string | undefined;
556
+ try {
557
+ const token = readFileSync(join(baseDataDir, "http-token"), "utf-8").trim();
558
+ if (token) bearerToken = token;
559
+ } catch {
560
+ // Token file may not exist if daemon started without HTTP server
561
+ }
562
+
552
563
  const localEntry: AssistantEntry = {
553
564
  assistantId: instanceName,
554
565
  runtimeUrl,
555
566
  baseDataDir,
567
+ bearerToken,
556
568
  cloud: "local",
557
569
  species,
558
570
  hatchedAt: new Date().toISOString(),
@@ -145,53 +145,59 @@ async function getRemoteProcessesCustom(
145
145
  ]);
146
146
  }
147
147
 
148
- function getLocalProcesses(): TableRow[] {
148
+ function checkPidFile(pidFile: string): { status: string; pid: string | null } {
149
+ if (!existsSync(pidFile)) {
150
+ return { status: "not running", pid: null };
151
+ }
152
+ const pid = readFileSync(pidFile, "utf-8").trim();
153
+ try {
154
+ process.kill(parseInt(pid, 10), 0);
155
+ return { status: "running", pid };
156
+ } catch {
157
+ return { status: "not running", pid };
158
+ }
159
+ }
160
+
161
+ async function getLocalProcesses(entry: AssistantEntry): Promise<TableRow[]> {
162
+ const vellumDir = entry.baseDataDir ?? join(homedir(), ".vellum");
149
163
  const rows: TableRow[] = [];
150
- const vellumDir = join(homedir(), ".vellum");
151
164
 
152
165
  // Check daemon PID
153
- const pidFile = join(vellumDir, "vellum.pid");
154
- if (existsSync(pidFile)) {
155
- const pid = readFileSync(pidFile, "utf-8").trim();
156
- let status = "running";
157
- try {
158
- process.kill(parseInt(pid, 10), 0);
159
- } catch {
160
- status = "not running";
161
- }
162
- rows.push({ name: "daemon", status: withStatusEmoji(status), info: `PID ${pid}` });
163
- } else {
164
- rows.push({ name: "daemon", status: withStatusEmoji("not running"), info: "no PID file" });
165
- }
166
+ const daemon = checkPidFile(join(vellumDir, "vellum.pid"));
167
+ rows.push({
168
+ name: "daemon",
169
+ status: withStatusEmoji(daemon.status),
170
+ info: daemon.pid ? `PID ${daemon.pid}` : "no PID file",
171
+ });
166
172
 
167
173
  // Check qdrant PID
168
- const qdrantPidFile = join(vellumDir, "workspace", "data", "qdrant", "qdrant.pid");
169
- if (existsSync(qdrantPidFile)) {
170
- const pid = readFileSync(qdrantPidFile, "utf-8").trim();
171
- let status = "running";
172
- try {
173
- process.kill(parseInt(pid, 10), 0);
174
- } catch {
175
- status = "not running";
176
- }
177
- rows.push({ name: "qdrant", status: withStatusEmoji(status), info: `PID ${pid} | port 6333` });
178
- } else {
179
- rows.push({ name: "qdrant", status: withStatusEmoji("not running"), info: "no PID file" });
180
- }
174
+ const qdrant = checkPidFile(join(vellumDir, "workspace", "data", "qdrant", "qdrant.pid"));
175
+ rows.push({
176
+ name: "qdrant",
177
+ status: withStatusEmoji(qdrant.status),
178
+ info: qdrant.pid ? `PID ${qdrant.pid} | port 6333` : "no PID file",
179
+ });
181
180
 
182
181
  // Check gateway PID
183
- const gatewayPidFile = join(vellumDir, "gateway.pid");
184
- if (existsSync(gatewayPidFile)) {
185
- const pid = readFileSync(gatewayPidFile, "utf-8").trim();
186
- let status = "running";
187
- try {
188
- process.kill(parseInt(pid, 10), 0);
189
- } catch {
190
- status = "not running";
182
+ const gateway = checkPidFile(join(vellumDir, "gateway.pid"));
183
+ rows.push({
184
+ name: "gateway",
185
+ status: withStatusEmoji(gateway.status),
186
+ info: gateway.pid ? `PID ${gateway.pid} | port 7830` : "no PID file",
187
+ });
188
+
189
+ // If no PID files found, fall back to health check
190
+ const allMissingPid = !daemon.pid && !qdrant.pid && !gateway.pid;
191
+ if (allMissingPid) {
192
+ const health = await checkHealth(entry.runtimeUrl);
193
+ if (health.status === "healthy" || health.status === "ok") {
194
+ rows.length = 0;
195
+ rows.push({
196
+ name: "daemon",
197
+ status: withStatusEmoji("running"),
198
+ info: "no PID file (detected via health check)",
199
+ });
191
200
  }
192
- rows.push({ name: "gateway", status: withStatusEmoji(status), info: `PID ${pid} | port 7830` });
193
- } else {
194
- rows.push({ name: "gateway", status: withStatusEmoji("not running"), info: "no PID file" });
195
201
  }
196
202
 
197
203
  return rows;
@@ -203,7 +209,7 @@ async function showAssistantProcesses(entry: AssistantEntry): Promise<void> {
203
209
  console.log(`Processes for ${entry.assistantId} (${cloud}):\n`);
204
210
 
205
211
  if (cloud === "local") {
206
- const rows = getLocalProcesses();
212
+ const rows = await getLocalProcesses(entry);
207
213
  printTable(rows);
208
214
  return;
209
215
  }
@@ -0,0 +1,111 @@
1
+ import { spawn } from "child_process";
2
+
3
+ import { findAssistantByName, loadLatestAssistant } from "../lib/assistant-config";
4
+ import type { AssistantEntry } from "../lib/assistant-config";
5
+
6
+ const SSH_OPTS = [
7
+ "-o", "StrictHostKeyChecking=no",
8
+ "-o", "UserKnownHostsFile=/dev/null",
9
+ "-o", "ConnectTimeout=10",
10
+ "-o", "LogLevel=ERROR",
11
+ ];
12
+
13
+ function resolveCloud(entry: AssistantEntry): string {
14
+ if (entry.cloud) {
15
+ return entry.cloud;
16
+ }
17
+ if (entry.project) {
18
+ return "gcp";
19
+ }
20
+ if (entry.sshUser) {
21
+ return "custom";
22
+ }
23
+ return "local";
24
+ }
25
+
26
+ function extractHostFromUrl(url: string): string {
27
+ try {
28
+ const parsed = new URL(url);
29
+ return parsed.hostname;
30
+ } catch {
31
+ return url.replace(/^https?:\/\//, "").split(":")[0];
32
+ }
33
+ }
34
+
35
+ export async function ssh(): Promise<void> {
36
+ const name = process.argv[3];
37
+ const entry = name ? findAssistantByName(name) : loadLatestAssistant();
38
+
39
+ if (!entry) {
40
+ if (name) {
41
+ console.error(`No assistant instance found with name '${name}'.`);
42
+ } else {
43
+ console.error("No assistant instance found. Run `vellum-cli hatch` first.");
44
+ }
45
+ process.exit(1);
46
+ }
47
+
48
+ const cloud = resolveCloud(entry);
49
+
50
+ if (cloud === "local") {
51
+ console.error(
52
+ "Cannot SSH into a local assistant. Local assistants run directly on this machine.\n" +
53
+ `Use 'vellum-cli ps ${entry.assistantId}' to check its processes instead.`,
54
+ );
55
+ process.exit(1);
56
+ }
57
+
58
+ if (cloud === "aws") {
59
+ console.error("SSH to AWS instances is not yet supported.");
60
+ process.exit(1);
61
+ }
62
+
63
+ let child;
64
+
65
+ if (cloud === "gcp") {
66
+ const project = entry.project;
67
+ const zone = entry.zone;
68
+ if (!project || !zone) {
69
+ console.error("Error: GCP project and zone not found in assistant config.");
70
+ process.exit(1);
71
+ }
72
+
73
+ const sshTarget = entry.sshUser
74
+ ? `${entry.sshUser}@${entry.assistantId}`
75
+ : entry.assistantId;
76
+
77
+ console.log(`🔗 Connecting to ${entry.assistantId} via gcloud...\n`);
78
+
79
+ child = spawn(
80
+ "gcloud",
81
+ ["compute", "ssh", sshTarget, `--project=${project}`, `--zone=${zone}`],
82
+ { stdio: "inherit" },
83
+ );
84
+ } else if (cloud === "custom") {
85
+ const host = extractHostFromUrl(entry.runtimeUrl);
86
+ const sshUser = entry.sshUser ?? "root";
87
+ const sshTarget = `${sshUser}@${host}`;
88
+
89
+ console.log(`🔗 Connecting to ${entry.assistantId} via ssh...\n`);
90
+
91
+ child = spawn(
92
+ "ssh",
93
+ [...SSH_OPTS, sshTarget],
94
+ { stdio: "inherit" },
95
+ );
96
+ } else {
97
+ console.error(`Error: Unknown cloud type '${cloud}'.`);
98
+ process.exit(1);
99
+ }
100
+
101
+ await new Promise<void>((resolve, reject) => {
102
+ child.on("close", (code) => {
103
+ if (code === 0) {
104
+ resolve();
105
+ } else {
106
+ reject(new Error(`ssh exited with code ${code}`));
107
+ }
108
+ });
109
+ child.on("error", reject);
110
+ });
111
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Vellum email API client — calls the Vellum platform email endpoints.
3
+ */
4
+
5
+ // The domain for the Vellum email API is still being finalized and may change.
6
+ const DEFAULT_VELLUM_API_URL = "https://api.vellum.ai";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Types
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export interface EmailInbox {
13
+ id: string;
14
+ address: string;
15
+ displayName?: string;
16
+ createdAt: string;
17
+ }
18
+
19
+ export interface EmailStatus {
20
+ provider: string;
21
+ ok: boolean;
22
+ inboxes: EmailInbox[];
23
+ }
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // HTTP helper
27
+ // ---------------------------------------------------------------------------
28
+
29
+ async function vellumFetch(
30
+ apiKey: string,
31
+ baseUrl: string,
32
+ path: string,
33
+ opts: { method?: string; body?: unknown } = {},
34
+ ): Promise<unknown> {
35
+ const url = `${baseUrl}${path}`;
36
+ const response = await fetch(url, {
37
+ method: opts.method ?? "GET",
38
+ headers: {
39
+ Authorization: `Bearer ${apiKey}`,
40
+ "Content-Type": "application/json",
41
+ },
42
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
43
+ });
44
+
45
+ if (!response.ok) {
46
+ const text = await response.text().catch(() => "");
47
+ throw new Error(
48
+ `Vellum email API error: ${response.status} ${response.statusText}${text ? ` — ${text}` : ""}`,
49
+ );
50
+ }
51
+
52
+ const contentType = response.headers.get("content-type") ?? "";
53
+ if (contentType.includes("application/json")) {
54
+ return response.json();
55
+ }
56
+ return undefined;
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Client
61
+ // ---------------------------------------------------------------------------
62
+
63
+ export class VellumEmailClient {
64
+ private apiKey: string;
65
+ private baseUrl: string;
66
+
67
+ constructor(apiKey?: string, baseUrl?: string) {
68
+ const resolvedKey = apiKey ?? process.env.VELLUM_API_KEY;
69
+ if (!resolvedKey) {
70
+ throw new Error(
71
+ "No Vellum API key configured. Set the VELLUM_API_KEY environment variable.",
72
+ );
73
+ }
74
+ this.apiKey = resolvedKey;
75
+ this.baseUrl =
76
+ baseUrl ?? process.env.VELLUM_API_URL ?? DEFAULT_VELLUM_API_URL;
77
+ }
78
+
79
+ /** List existing email addresses and check connectivity. */
80
+ async status(): Promise<EmailStatus> {
81
+ const result = await vellumFetch(
82
+ this.apiKey,
83
+ this.baseUrl,
84
+ "/v1/email-addresses",
85
+ );
86
+ const inboxes = (result as { inboxes: EmailInbox[] }).inboxes;
87
+ return { provider: "vellum", ok: true, inboxes };
88
+ }
89
+
90
+ /** Provision a new email address for the given username. */
91
+ async createInbox(username: string): Promise<EmailInbox> {
92
+ const result = await vellumFetch(
93
+ this.apiKey,
94
+ this.baseUrl,
95
+ "/v1/email-addresses",
96
+ {
97
+ method: "POST",
98
+ body: { username },
99
+ },
100
+ );
101
+ return result as EmailInbox;
102
+ }
103
+ }
package/src/index.ts CHANGED
@@ -1,26 +1,58 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
+ import { existsSync } from "node:fs";
3
4
  import { createRequire } from "node:module";
4
5
  import { dirname, join } from "node:path";
5
6
  import { spawn } from "node:child_process";
7
+ import { fileURLToPath } from "node:url";
6
8
  import { client } from "./commands/client";
9
+ import { email } from "./commands/email";
7
10
  import { hatch } from "./commands/hatch";
8
11
  import { ps } from "./commands/ps";
9
12
  import { retire } from "./commands/retire";
10
13
  import { sleep } from "./commands/sleep";
14
+ import { ssh } from "./commands/ssh";
11
15
  import { wake } from "./commands/wake";
12
16
 
13
17
  const commands = {
14
18
  client,
19
+ email,
15
20
  hatch,
16
21
  ps,
17
22
  retire,
18
23
  sleep,
24
+ ssh,
19
25
  wake,
20
26
  } as const;
21
27
 
22
28
  type CommandName = keyof typeof commands;
23
29
 
30
+ function resolveAssistantEntry(): string | undefined {
31
+ // When installed globally, resolve from node_modules
32
+ try {
33
+ const require = createRequire(import.meta.url);
34
+ const assistantPkgPath = require.resolve(
35
+ "@vellumai/assistant/package.json"
36
+ );
37
+ return join(dirname(assistantPkgPath), "src", "index.ts");
38
+ } catch {
39
+ // For local development, resolve from sibling directory
40
+ const __dirname = dirname(fileURLToPath(import.meta.url));
41
+ const localPath = join(
42
+ __dirname,
43
+ "..",
44
+ "..",
45
+ "assistant",
46
+ "src",
47
+ "index.ts"
48
+ );
49
+ if (existsSync(localPath)) {
50
+ return localPath;
51
+ }
52
+ }
53
+ return undefined;
54
+ }
55
+
24
56
  async function main() {
25
57
  const args = process.argv.slice(2);
26
58
  const commandName = args[0];
@@ -30,10 +62,12 @@ async function main() {
30
62
  console.log("");
31
63
  console.log("Commands:");
32
64
  console.log(" client Connect to a hatched assistant");
65
+ console.log(" email Email operations (status, create inbox)");
33
66
  console.log(" hatch Create a new assistant instance");
34
67
  console.log(" ps List assistants (or processes for a specific assistant)");
35
68
  console.log(" retire Delete an assistant instance");
36
69
  console.log(" sleep Stop the daemon process");
70
+ console.log(" ssh SSH into a remote assistant instance");
37
71
  console.log(" wake Start the daemon and gateway");
38
72
  process.exit(0);
39
73
  }
@@ -41,23 +75,15 @@ async function main() {
41
75
  const command = commands[commandName as CommandName];
42
76
 
43
77
  if (!command) {
44
- try {
45
- const require = createRequire(import.meta.url);
46
- const assistantPkgPath = require.resolve(
47
- "@vellumai/assistant/package.json"
48
- );
49
- const assistantEntry = join(
50
- dirname(assistantPkgPath),
51
- "src",
52
- "index.ts"
53
- );
78
+ const assistantEntry = resolveAssistantEntry();
79
+ if (assistantEntry) {
54
80
  const child = spawn("bun", ["run", assistantEntry, ...args], {
55
81
  stdio: "inherit",
56
82
  });
57
83
  child.on("exit", (code) => {
58
84
  process.exit(code ?? 1);
59
85
  });
60
- } catch {
86
+ } else {
61
87
  console.error(`Unknown command: ${commandName}`);
62
88
  console.error(
63
89
  "Install the full stack with: bun install -g vellum"
@@ -22,30 +22,33 @@ interface LockfileData {
22
22
  [key: string]: unknown;
23
23
  }
24
24
 
25
- function getLockfilePath(): string {
26
- return join(homedir(), ".vellum.lock.json");
25
+ function getBaseDir(): string {
26
+ return process.env.BASE_DATA_DIR?.trim() || homedir();
27
27
  }
28
28
 
29
29
  function readLockfile(): LockfileData {
30
- const lockfilePath = getLockfilePath();
31
- if (!existsSync(lockfilePath)) {
32
- return {};
33
- }
34
-
35
- try {
36
- const raw = readFileSync(lockfilePath, "utf-8");
37
- const parsed = JSON.parse(raw) as unknown;
38
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
39
- return parsed as LockfileData;
30
+ const base = getBaseDir();
31
+ const candidates = [
32
+ join(base, ".vellum.lock.json"),
33
+ join(base, ".vellum.lockfile.json"),
34
+ ];
35
+ for (const lockfilePath of candidates) {
36
+ if (!existsSync(lockfilePath)) continue;
37
+ try {
38
+ const raw = readFileSync(lockfilePath, "utf-8");
39
+ const parsed = JSON.parse(raw) as unknown;
40
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
41
+ return parsed as LockfileData;
42
+ }
43
+ } catch {
44
+ // Malformed lockfile; try next
40
45
  }
41
- } catch {
42
- // Malformed lockfile; return empty
43
46
  }
44
47
  return {};
45
48
  }
46
49
 
47
50
  function writeLockfile(data: LockfileData): void {
48
- const lockfilePath = getLockfilePath();
51
+ const lockfilePath = join(getBaseDir(), ".vellum.lock.json");
49
52
  writeFileSync(lockfilePath, JSON.stringify(data, null, 2) + "\n");
50
53
  }
51
54
 
package/src/lib/local.ts CHANGED
@@ -302,13 +302,62 @@ export async function startGateway(): Promise<string> {
302
302
  const assistants = loadAllAssistants();
303
303
  const isSingleAssistant = assistants.length === 1;
304
304
 
305
+ // Read the bearer token so the gateway can authenticate proxied requests
306
+ // (e.g. from paired iOS devices). Respect VELLUM_HTTP_TOKEN_PATH and
307
+ // BASE_DATA_DIR for consistency with gateway/config.ts and the daemon.
308
+ const httpTokenPath = process.env.VELLUM_HTTP_TOKEN_PATH
309
+ ?? join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum", "http-token");
310
+ let runtimeProxyBearerToken: string | undefined;
311
+ try {
312
+ const tok = readFileSync(httpTokenPath, "utf-8").trim();
313
+ if (tok) runtimeProxyBearerToken = tok;
314
+ } catch {
315
+ // Token file doesn't exist yet — daemon hasn't written it.
316
+ }
317
+
318
+ // If no token is available (first startup — daemon hasn't written it yet),
319
+ // poll for the file to appear. The daemon writes the token shortly after
320
+ // startup, so a short wait avoids starting the gateway without auth
321
+ // (which would leave it permanently unauthenticated since the gateway
322
+ // config is loaded once at startup and never reloads).
323
+ if (!runtimeProxyBearerToken) {
324
+ console.log(" Waiting for bearer token file...");
325
+ const maxWait = 10000;
326
+ const pollInterval = 500;
327
+ const start = Date.now();
328
+ while (Date.now() - start < maxWait) {
329
+ await new Promise((r) => setTimeout(r, pollInterval));
330
+ try {
331
+ const tok = readFileSync(httpTokenPath, "utf-8").trim();
332
+ if (tok) {
333
+ runtimeProxyBearerToken = tok;
334
+ break;
335
+ }
336
+ } catch {
337
+ // File still doesn't exist, keep polling.
338
+ }
339
+ }
340
+ }
341
+
342
+ // If the token still isn't available after polling, fall back to starting
343
+ // the gateway without auth so it doesn't block forever. This is a degraded
344
+ // mode — the proxy will be broken because the runtime expects a token.
345
+ const proxyRequireAuth = runtimeProxyBearerToken ? "true" : "false";
346
+ if (!runtimeProxyBearerToken) {
347
+ console.log(" ⚠️ Bearer token not found after 10s — gateway proxy auth disabled");
348
+ }
349
+
305
350
  const gatewayEnv: Record<string, string> = {
306
351
  ...process.env as Record<string, string>,
307
352
  GATEWAY_RUNTIME_PROXY_ENABLED: "true",
308
- GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: "false",
353
+ GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: proxyRequireAuth,
309
354
  RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
310
355
  };
311
356
 
357
+ if (runtimeProxyBearerToken) {
358
+ gatewayEnv.RUNTIME_PROXY_BEARER_TOKEN = runtimeProxyBearerToken;
359
+ }
360
+
312
361
  if (process.env.GATEWAY_UNMAPPED_POLICY) {
313
362
  gatewayEnv.GATEWAY_UNMAPPED_POLICY = process.env.GATEWAY_UNMAPPED_POLICY;
314
363
  } else if (isSingleAssistant) {