@vellumai/cli 0.3.2 → 0.3.4

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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,8 +1,10 @@
1
1
  import { randomBytes } from "crypto";
2
2
  import { existsSync, readFileSync, unlinkSync, writeFileSync } from "fs";
3
- import { createRequire } from "module";
4
3
  import { homedir, tmpdir, userInfo } from "os";
5
- import { dirname, join } from "path";
4
+ import { join } from "path";
5
+
6
+ // Direct import — bun embeds this at compile time so it works in compiled binaries.
7
+ import cliPkg from "../../package.json";
6
8
 
7
9
  import { buildOpenclawStartupScript } from "../adapters/openclaw";
8
10
  import { loadAllAssistants, saveAssistantEntry } from "../lib/assistant-config";
@@ -547,10 +549,22 @@ async function hatchLocal(species: Species, name: string | null, daemonOnly: boo
547
549
  }
548
550
 
549
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
+
550
563
  const localEntry: AssistantEntry = {
551
564
  assistantId: instanceName,
552
565
  runtimeUrl,
553
566
  baseDataDir,
567
+ bearerToken,
554
568
  cloud: "local",
555
569
  species,
556
570
  hatchedAt: new Date().toISOString(),
@@ -569,27 +583,7 @@ async function hatchLocal(species: Species, name: string | null, daemonOnly: boo
569
583
  }
570
584
 
571
585
  function getCliVersion(): string {
572
- // Strategy 1: createRequire — works in Bun dev (source tree).
573
- try {
574
- const require = createRequire(import.meta.url);
575
- const pkg = require("../../package.json") as { version?: string };
576
- if (pkg.version) return pkg.version;
577
- } catch {
578
- // Fall through to next strategy.
579
- }
580
-
581
- // Strategy 2: Read package.json adjacent to the compiled binary.
582
- try {
583
- const binPkg = join(dirname(process.execPath), "package.json");
584
- if (existsSync(binPkg)) {
585
- const pkg = JSON.parse(readFileSync(binPkg, "utf-8")) as { version?: string };
586
- if (pkg.version) return pkg.version;
587
- }
588
- } catch {
589
- // Fall through.
590
- }
591
-
592
- return "unknown";
586
+ return cliPkg.version ?? "unknown";
593
587
  }
594
588
 
595
589
  export async function hatch(): Promise<void> {
@@ -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,36 +1,73 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
+ import { existsSync } from "node:fs";
4
+ import { createRequire } from "node:module";
5
+ import { dirname, join } from "node:path";
6
+ import { spawn } from "node:child_process";
7
+ import { fileURLToPath } from "node:url";
3
8
  import { client } from "./commands/client";
9
+ import { email } from "./commands/email";
4
10
  import { hatch } from "./commands/hatch";
5
11
  import { ps } from "./commands/ps";
6
12
  import { retire } from "./commands/retire";
7
13
  import { sleep } from "./commands/sleep";
14
+ import { ssh } from "./commands/ssh";
8
15
  import { wake } from "./commands/wake";
9
16
 
10
17
  const commands = {
11
18
  client,
19
+ email,
12
20
  hatch,
13
21
  ps,
14
22
  retire,
15
23
  sleep,
24
+ ssh,
16
25
  wake,
17
26
  } as const;
18
27
 
19
28
  type CommandName = keyof typeof commands;
20
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
+
21
56
  async function main() {
22
57
  const args = process.argv.slice(2);
23
58
  const commandName = args[0];
24
59
 
25
60
  if (!commandName || commandName === "--help" || commandName === "-h") {
26
- console.log("Usage: vellum-cli <command> [options]");
61
+ console.log("Usage: vellum <command> [options]");
27
62
  console.log("");
28
63
  console.log("Commands:");
29
64
  console.log(" client Connect to a hatched assistant");
65
+ console.log(" email Email operations (status, create inbox)");
30
66
  console.log(" hatch Create a new assistant instance");
31
67
  console.log(" ps List assistants (or processes for a specific assistant)");
32
68
  console.log(" retire Delete an assistant instance");
33
69
  console.log(" sleep Stop the daemon process");
70
+ console.log(" ssh SSH into a remote assistant instance");
34
71
  console.log(" wake Start the daemon and gateway");
35
72
  process.exit(0);
36
73
  }
@@ -38,9 +75,22 @@ async function main() {
38
75
  const command = commands[commandName as CommandName];
39
76
 
40
77
  if (!command) {
41
- console.error(`Error: Unknown command '${commandName}'`);
42
- console.error("Run 'vellum-cli --help' for usage information.");
43
- process.exit(1);
78
+ const assistantEntry = resolveAssistantEntry();
79
+ if (assistantEntry) {
80
+ const child = spawn("bun", ["run", assistantEntry, ...args], {
81
+ stdio: "inherit",
82
+ });
83
+ child.on("exit", (code) => {
84
+ process.exit(code ?? 1);
85
+ });
86
+ } else {
87
+ console.error(`Unknown command: ${commandName}`);
88
+ console.error(
89
+ "Install the full stack with: bun install -g vellum"
90
+ );
91
+ process.exit(1);
92
+ }
93
+ return;
44
94
  }
45
95
 
46
96
  try {
package/src/lib/local.ts CHANGED
@@ -294,7 +294,7 @@ export async function startGateway(): Promise<string> {
294
294
  }
295
295
 
296
296
  console.log("🌐 Starting gateway...");
297
- const gatewayDir = resolveGatewayDir();
297
+
298
298
  // Only auto-configure default routing when the workspace has exactly one
299
299
  // assistant. In multi-assistant deployments, falling back to "default"
300
300
  // would silently deliver unmapped Telegram chats to whichever assistant was
@@ -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) {
@@ -331,12 +380,34 @@ export async function startGateway(): Promise<string> {
331
380
  console.log(` Ingress URL: ${ingressPublicBaseUrl}`);
332
381
  }
333
382
 
334
- const gateway = spawn("bun", ["run", "src/index.ts"], {
335
- cwd: gatewayDir,
336
- detached: true,
337
- stdio: "ignore",
338
- env: gatewayEnv,
339
- });
383
+ let gateway;
384
+
385
+ if (process.env.VELLUM_DESKTOP_APP) {
386
+ // Desktop app: spawn the compiled gateway binary directly (mirrors daemon pattern).
387
+ const gatewayBinary = join(dirname(process.execPath), "vellum-gateway");
388
+ if (!existsSync(gatewayBinary)) {
389
+ throw new Error(
390
+ `vellum-gateway binary not found at ${gatewayBinary}.\n` +
391
+ " Ensure the gateway binary is bundled alongside the CLI in the app bundle.",
392
+ );
393
+ }
394
+
395
+ gateway = spawn(gatewayBinary, [], {
396
+ detached: true,
397
+ stdio: "ignore",
398
+ env: gatewayEnv,
399
+ });
400
+ } else {
401
+ // Source tree / bunx: resolve the gateway source directory and run via bun.
402
+ const gatewayDir = resolveGatewayDir();
403
+ gateway = spawn("bun", ["run", "src/index.ts"], {
404
+ cwd: gatewayDir,
405
+ detached: true,
406
+ stdio: "ignore",
407
+ env: gatewayEnv,
408
+ });
409
+ }
410
+
340
411
  gateway.unref();
341
412
 
342
413
  if (gateway.pid) {