@vellumai/cli 0.4.6 → 0.4.8
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 +1 -1
- package/src/adapters/install.sh +41 -11
- package/src/commands/hatch.ts +29 -0
- package/src/commands/retire.ts +36 -0
- package/src/components/DefaultMainScreen.tsx +136 -50
- package/src/lib/local.ts +40 -33
- package/src/lib/xdg-log.ts +37 -0
package/package.json
CHANGED
package/src/adapters/install.sh
CHANGED
|
@@ -28,17 +28,47 @@ ensure_git() {
|
|
|
28
28
|
# Try installing CLT first before falling back to Homebrew.
|
|
29
29
|
if ! xcode-select -p >/dev/null 2>&1; then
|
|
30
30
|
info "Installing Xcode Command Line Tools (includes git)..."
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
31
|
+
|
|
32
|
+
# Use softwareupdate to install CLT non-interactively instead of
|
|
33
|
+
# xcode-select --install which opens a GUI dialog requiring manual
|
|
34
|
+
# confirmation.
|
|
35
|
+
touch /tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress
|
|
36
|
+
local clt_package
|
|
37
|
+
# softwareupdate -l output has two relevant lines per update:
|
|
38
|
+
# * Label: Command Line Tools for Xcode-16.0 <-- label (what -i expects)
|
|
39
|
+
# Title: Command Line Tools for Xcode, ... <-- description
|
|
40
|
+
# We need the label, which is on lines starting with "* ".
|
|
41
|
+
# Use the same parsing approach as Homebrew's installer.
|
|
42
|
+
clt_package=$(softwareupdate -l 2>/dev/null \
|
|
43
|
+
| grep -B 1 -E 'Command Line Tools' \
|
|
44
|
+
| awk -F'*' '/^\*/{print $2}' \
|
|
45
|
+
| sed -e 's/^ Label: //' -e 's/^ *//' \
|
|
46
|
+
| sort -V \
|
|
47
|
+
| tail -1)
|
|
48
|
+
|
|
49
|
+
if [ -n "$clt_package" ]; then
|
|
50
|
+
info "Found package: $clt_package"
|
|
51
|
+
softwareupdate -i "$clt_package" --verbose 2>&1 | while IFS= read -r line; do
|
|
52
|
+
printf " %s\n" "$line"
|
|
53
|
+
done
|
|
54
|
+
else
|
|
55
|
+
# Fallback: if softwareupdate can't find the package, try
|
|
56
|
+
# xcode-select --install and wait for user interaction.
|
|
57
|
+
info "Could not find CLT package via softwareupdate, falling back to xcode-select..."
|
|
58
|
+
xcode-select --install 2>/dev/null || true
|
|
59
|
+
info "Please follow the on-screen dialog to install. Waiting..."
|
|
60
|
+
local waited=0
|
|
61
|
+
while ! xcode-select -p >/dev/null 2>&1; do
|
|
62
|
+
sleep 5
|
|
63
|
+
waited=$((waited + 5))
|
|
64
|
+
if [ "$waited" -ge 600 ]; then
|
|
65
|
+
error "Timed out waiting for Xcode Command Line Tools. Please install manually and re-run."
|
|
66
|
+
exit 1
|
|
67
|
+
fi
|
|
68
|
+
done
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
rm -f /tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress
|
|
42
72
|
hash -r 2>/dev/null || true
|
|
43
73
|
fi
|
|
44
74
|
|
package/src/commands/hatch.ts
CHANGED
|
@@ -504,11 +504,40 @@ function installCLISymlink(): void {
|
|
|
504
504
|
console.log(` ⚠ Could not create symlink for vellum CLI (tried ${preferredPath} and ${fallbackPath})`);
|
|
505
505
|
}
|
|
506
506
|
|
|
507
|
+
async function waitForDaemonReady(runtimeUrl: string, bearerToken: string | undefined, timeoutMs = 15000): Promise<boolean> {
|
|
508
|
+
const start = Date.now();
|
|
509
|
+
const pollInterval = 1000;
|
|
510
|
+
while (Date.now() - start < timeoutMs) {
|
|
511
|
+
try {
|
|
512
|
+
const res = await fetch(`${runtimeUrl}/v1/health`, {
|
|
513
|
+
method: "GET",
|
|
514
|
+
headers: bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {},
|
|
515
|
+
signal: AbortSignal.timeout(2000),
|
|
516
|
+
});
|
|
517
|
+
if (res.ok) return true;
|
|
518
|
+
} catch {
|
|
519
|
+
// Daemon not ready yet
|
|
520
|
+
}
|
|
521
|
+
await new Promise((r) => setTimeout(r, pollInterval));
|
|
522
|
+
}
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
|
|
507
526
|
async function displayPairingQRCode(runtimeUrl: string, bearerToken: string | undefined): Promise<void> {
|
|
508
527
|
try {
|
|
509
528
|
const pairingRequestId = randomUUID();
|
|
510
529
|
const pairingSecret = randomBytes(32).toString("hex");
|
|
511
530
|
|
|
531
|
+
// The daemon's HTTP server may not be fully ready even though the gateway
|
|
532
|
+
// health check passed (the gateway is up, but the upstream daemon HTTP
|
|
533
|
+
// endpoint it proxies to may still be initializing). Poll the daemon's
|
|
534
|
+
// health endpoint through the gateway to ensure it's reachable.
|
|
535
|
+
const daemonReady = await waitForDaemonReady(runtimeUrl, bearerToken);
|
|
536
|
+
if (!daemonReady) {
|
|
537
|
+
console.warn("⚠ Daemon health check did not pass within 15s. Run `vellum pair` to try again.\n");
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
512
541
|
const registerRes = await fetch(`${runtimeUrl}/pairing/register`, {
|
|
513
542
|
method: "POST",
|
|
514
543
|
headers: {
|
package/src/commands/retire.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { retireInstance as retireGcpInstance } from "../lib/gcp";
|
|
|
10
10
|
import { stopProcessByPidFile } from "../lib/process";
|
|
11
11
|
import { getArchivePath, getMetadataPath } from "../lib/retire-archive";
|
|
12
12
|
import { exec } from "../lib/step-runner";
|
|
13
|
+
import { openLogFile, closeLogFile, writeToLogFile } from "../lib/xdg-log";
|
|
13
14
|
|
|
14
15
|
function resolveCloud(entry: AssistantEntry): string {
|
|
15
16
|
if (entry.cloud) {
|
|
@@ -120,7 +121,42 @@ function parseSource(): string | undefined {
|
|
|
120
121
|
return undefined;
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
/** Patch console methods to also append output to the given log file descriptor. */
|
|
125
|
+
function teeConsoleToLogFile(fd: number | "ignore"): void {
|
|
126
|
+
if (fd === "ignore") return;
|
|
127
|
+
|
|
128
|
+
const origLog = console.log.bind(console);
|
|
129
|
+
const origWarn = console.warn.bind(console);
|
|
130
|
+
const origError = console.error.bind(console);
|
|
131
|
+
|
|
132
|
+
const timestamp = () => new Date().toISOString();
|
|
133
|
+
|
|
134
|
+
console.log = (...args: unknown[]) => {
|
|
135
|
+
origLog(...args);
|
|
136
|
+
writeToLogFile(fd, `[${timestamp()}] ${args.map(String).join(" ")}\n`);
|
|
137
|
+
};
|
|
138
|
+
console.warn = (...args: unknown[]) => {
|
|
139
|
+
origWarn(...args);
|
|
140
|
+
writeToLogFile(fd, `[${timestamp()}] WARN: ${args.map(String).join(" ")}\n`);
|
|
141
|
+
};
|
|
142
|
+
console.error = (...args: unknown[]) => {
|
|
143
|
+
origError(...args);
|
|
144
|
+
writeToLogFile(fd, `[${timestamp()}] ERROR: ${args.map(String).join(" ")}\n`);
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
123
148
|
export async function retire(): Promise<void> {
|
|
149
|
+
const logFd = process.env.VELLUM_DESKTOP_APP ? openLogFile("retire.log") : "ignore";
|
|
150
|
+
teeConsoleToLogFile(logFd);
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
await retireInner();
|
|
154
|
+
} finally {
|
|
155
|
+
closeLogFile(logFd);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function retireInner(): Promise<void> {
|
|
124
160
|
const args = process.argv.slice(3);
|
|
125
161
|
if (args.includes("--help") || args.includes("-h")) {
|
|
126
162
|
console.log("Usage: vellum retire <name> [--source <source>]");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
2
|
import { createHash, randomBytes, randomUUID } from "crypto";
|
|
3
|
-
import { hostname, userInfo } from "os";
|
|
3
|
+
import { hostname, platform, userInfo } from "os";
|
|
4
4
|
import { basename } from "path";
|
|
5
5
|
import qrcode from "qrcode-terminal";
|
|
6
6
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react";
|
|
@@ -135,6 +135,7 @@ async function runtimeRequest<T>(
|
|
|
135
135
|
path: string,
|
|
136
136
|
init?: RequestInit,
|
|
137
137
|
bearerToken?: string,
|
|
138
|
+
actorToken?: string,
|
|
138
139
|
): Promise<T> {
|
|
139
140
|
const url = `${baseUrl}/v1/assistants/${assistantId}${path}`;
|
|
140
141
|
const response = await fetch(url, {
|
|
@@ -142,6 +143,7 @@ async function runtimeRequest<T>(
|
|
|
142
143
|
headers: {
|
|
143
144
|
"Content-Type": "application/json",
|
|
144
145
|
...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
|
|
146
|
+
...(actorToken ? { "X-Actor-Token": actorToken } : {}),
|
|
145
147
|
...(init?.headers as Record<string, string> | undefined),
|
|
146
148
|
},
|
|
147
149
|
});
|
|
@@ -177,10 +179,47 @@ async function checkHealthRuntime(baseUrl: string): Promise<HealthResponse> {
|
|
|
177
179
|
return response.json() as Promise<HealthResponse>;
|
|
178
180
|
}
|
|
179
181
|
|
|
182
|
+
async function bootstrapActorToken(baseUrl: string, bearerToken?: string): Promise<string> {
|
|
183
|
+
if (!bearerToken) {
|
|
184
|
+
throw new Error("Missing bearer token; cannot bootstrap actor identity");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const deviceId = `vellum-cli:${platform()}:${hostname()}:${userInfo().username}`;
|
|
188
|
+
const url = `${baseUrl}/v1/integrations/guardian/vellum/bootstrap`;
|
|
189
|
+
|
|
190
|
+
const response = await fetch(url, {
|
|
191
|
+
method: "POST",
|
|
192
|
+
headers: {
|
|
193
|
+
"Content-Type": "application/json",
|
|
194
|
+
Authorization: `Bearer ${bearerToken}`,
|
|
195
|
+
},
|
|
196
|
+
body: JSON.stringify({ platform: "cli", deviceId }),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (!response.ok) {
|
|
200
|
+
const body = await response.text().catch(() => "");
|
|
201
|
+
throw new Error(`HTTP ${response.status}: ${body || response.statusText}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const json: unknown = await response.json();
|
|
205
|
+
|
|
206
|
+
if (typeof json !== "object" || json === null) {
|
|
207
|
+
throw new Error("Invalid bootstrap response from gateway/runtime");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const actorToken = (json as Record<string, unknown>).actorToken;
|
|
211
|
+
if (typeof actorToken !== "string" || actorToken.length === 0) {
|
|
212
|
+
throw new Error("Invalid bootstrap response from gateway/runtime");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return actorToken;
|
|
216
|
+
}
|
|
217
|
+
|
|
180
218
|
async function pollMessages(
|
|
181
219
|
baseUrl: string,
|
|
182
220
|
assistantId: string,
|
|
183
221
|
bearerToken?: string,
|
|
222
|
+
actorToken?: string,
|
|
184
223
|
): Promise<ListMessagesResponse> {
|
|
185
224
|
const params = new URLSearchParams({ conversationKey: assistantId });
|
|
186
225
|
return runtimeRequest<ListMessagesResponse>(
|
|
@@ -189,6 +228,7 @@ async function pollMessages(
|
|
|
189
228
|
`/messages?${params.toString()}`,
|
|
190
229
|
undefined,
|
|
191
230
|
bearerToken,
|
|
231
|
+
actorToken,
|
|
192
232
|
);
|
|
193
233
|
}
|
|
194
234
|
|
|
@@ -198,6 +238,7 @@ async function sendMessage(
|
|
|
198
238
|
content: string,
|
|
199
239
|
signal?: AbortSignal,
|
|
200
240
|
bearerToken?: string,
|
|
241
|
+
actorToken?: string,
|
|
201
242
|
): Promise<SendMessageResponse> {
|
|
202
243
|
return runtimeRequest<SendMessageResponse>(
|
|
203
244
|
baseUrl,
|
|
@@ -209,6 +250,7 @@ async function sendMessage(
|
|
|
209
250
|
signal,
|
|
210
251
|
},
|
|
211
252
|
bearerToken,
|
|
253
|
+
actorToken,
|
|
212
254
|
);
|
|
213
255
|
}
|
|
214
256
|
|
|
@@ -218,6 +260,7 @@ async function submitDecision(
|
|
|
218
260
|
requestId: string,
|
|
219
261
|
decision: "allow" | "deny",
|
|
220
262
|
bearerToken?: string,
|
|
263
|
+
actorToken?: string,
|
|
221
264
|
): Promise<SubmitDecisionResponse> {
|
|
222
265
|
return runtimeRequest<SubmitDecisionResponse>(
|
|
223
266
|
baseUrl,
|
|
@@ -228,6 +271,7 @@ async function submitDecision(
|
|
|
228
271
|
body: JSON.stringify({ requestId, decision }),
|
|
229
272
|
},
|
|
230
273
|
bearerToken,
|
|
274
|
+
actorToken,
|
|
231
275
|
);
|
|
232
276
|
}
|
|
233
277
|
|
|
@@ -239,6 +283,7 @@ async function addTrustRule(
|
|
|
239
283
|
scope: string,
|
|
240
284
|
decision: "allow" | "deny",
|
|
241
285
|
bearerToken?: string,
|
|
286
|
+
actorToken?: string,
|
|
242
287
|
): Promise<AddTrustRuleResponse> {
|
|
243
288
|
return runtimeRequest<AddTrustRuleResponse>(
|
|
244
289
|
baseUrl,
|
|
@@ -249,6 +294,7 @@ async function addTrustRule(
|
|
|
249
294
|
body: JSON.stringify({ requestId, pattern, scope, decision }),
|
|
250
295
|
},
|
|
251
296
|
bearerToken,
|
|
297
|
+
actorToken,
|
|
252
298
|
);
|
|
253
299
|
}
|
|
254
300
|
|
|
@@ -256,6 +302,7 @@ async function pollPendingInteractions(
|
|
|
256
302
|
baseUrl: string,
|
|
257
303
|
assistantId: string,
|
|
258
304
|
bearerToken?: string,
|
|
305
|
+
actorToken?: string,
|
|
259
306
|
): Promise<PendingInteractionsResponse> {
|
|
260
307
|
const params = new URLSearchParams({ conversationKey: assistantId });
|
|
261
308
|
return runtimeRequest<PendingInteractionsResponse>(
|
|
@@ -264,6 +311,7 @@ async function pollPendingInteractions(
|
|
|
264
311
|
`/pending-interactions?${params.toString()}`,
|
|
265
312
|
undefined,
|
|
266
313
|
bearerToken,
|
|
314
|
+
actorToken,
|
|
267
315
|
);
|
|
268
316
|
}
|
|
269
317
|
|
|
@@ -301,6 +349,7 @@ async function handleConfirmationPrompt(
|
|
|
301
349
|
confirmation: PendingConfirmation,
|
|
302
350
|
chatApp: ChatAppHandle,
|
|
303
351
|
bearerToken?: string,
|
|
352
|
+
actorToken?: string,
|
|
304
353
|
): Promise<void> {
|
|
305
354
|
const preview = formatConfirmationPreview(confirmation.toolName, confirmation.input);
|
|
306
355
|
const allowlistOptions = confirmation.allowlistOptions ?? [];
|
|
@@ -320,7 +369,7 @@ async function handleConfirmationPrompt(
|
|
|
320
369
|
const index = await chatApp.showSelection("Tool Approval", options);
|
|
321
370
|
|
|
322
371
|
if (index === 0) {
|
|
323
|
-
await submitDecision(baseUrl, assistantId, requestId, "allow", bearerToken);
|
|
372
|
+
await submitDecision(baseUrl, assistantId, requestId, "allow", bearerToken, actorToken);
|
|
324
373
|
chatApp.addStatus("\u2714 Allowed", "green");
|
|
325
374
|
return;
|
|
326
375
|
}
|
|
@@ -333,6 +382,7 @@ async function handleConfirmationPrompt(
|
|
|
333
382
|
chatApp,
|
|
334
383
|
"always_allow",
|
|
335
384
|
bearerToken,
|
|
385
|
+
actorToken,
|
|
336
386
|
);
|
|
337
387
|
return;
|
|
338
388
|
}
|
|
@@ -345,11 +395,12 @@ async function handleConfirmationPrompt(
|
|
|
345
395
|
chatApp,
|
|
346
396
|
"always_deny",
|
|
347
397
|
bearerToken,
|
|
398
|
+
actorToken,
|
|
348
399
|
);
|
|
349
400
|
return;
|
|
350
401
|
}
|
|
351
402
|
|
|
352
|
-
await submitDecision(baseUrl, assistantId, requestId, "deny", bearerToken);
|
|
403
|
+
await submitDecision(baseUrl, assistantId, requestId, "deny", bearerToken, actorToken);
|
|
353
404
|
chatApp.addStatus("\u2718 Denied", "yellow");
|
|
354
405
|
}
|
|
355
406
|
|
|
@@ -361,6 +412,7 @@ async function handlePatternSelection(
|
|
|
361
412
|
chatApp: ChatAppHandle,
|
|
362
413
|
trustDecision: TrustDecision,
|
|
363
414
|
bearerToken?: string,
|
|
415
|
+
actorToken?: string,
|
|
364
416
|
): Promise<void> {
|
|
365
417
|
const allowlistOptions = confirmation.allowlistOptions ?? [];
|
|
366
418
|
const label = trustDecision === "always_deny" ? "Denylist" : "Allowlist";
|
|
@@ -379,11 +431,12 @@ async function handlePatternSelection(
|
|
|
379
431
|
selectedPattern,
|
|
380
432
|
trustDecision,
|
|
381
433
|
bearerToken,
|
|
434
|
+
actorToken,
|
|
382
435
|
);
|
|
383
436
|
return;
|
|
384
437
|
}
|
|
385
438
|
|
|
386
|
-
await submitDecision(baseUrl, assistantId, requestId, "deny", bearerToken);
|
|
439
|
+
await submitDecision(baseUrl, assistantId, requestId, "deny", bearerToken, actorToken);
|
|
387
440
|
chatApp.addStatus("\u2718 Denied", "yellow");
|
|
388
441
|
}
|
|
389
442
|
|
|
@@ -396,6 +449,7 @@ async function handleScopeSelection(
|
|
|
396
449
|
selectedPattern: string,
|
|
397
450
|
trustDecision: TrustDecision,
|
|
398
451
|
bearerToken?: string,
|
|
452
|
+
actorToken?: string,
|
|
399
453
|
): Promise<void> {
|
|
400
454
|
const scopeOptions = confirmation.scopeOptions ?? [];
|
|
401
455
|
const label = trustDecision === "always_deny" ? "Denylist" : "Allowlist";
|
|
@@ -413,6 +467,7 @@ async function handleScopeSelection(
|
|
|
413
467
|
scopeOptions[index].scope,
|
|
414
468
|
ruleDecision,
|
|
415
469
|
bearerToken,
|
|
470
|
+
actorToken,
|
|
416
471
|
);
|
|
417
472
|
await submitDecision(
|
|
418
473
|
baseUrl,
|
|
@@ -420,6 +475,7 @@ async function handleScopeSelection(
|
|
|
420
475
|
requestId,
|
|
421
476
|
ruleDecision === "deny" ? "deny" : "allow",
|
|
422
477
|
bearerToken,
|
|
478
|
+
actorToken,
|
|
423
479
|
);
|
|
424
480
|
const ruleLabel = trustDecision === "always_deny" ? "Denylisted" : "Allowlisted";
|
|
425
481
|
const ruleColor = trustDecision === "always_deny" ? "yellow" : "green";
|
|
@@ -430,7 +486,7 @@ async function handleScopeSelection(
|
|
|
430
486
|
return;
|
|
431
487
|
}
|
|
432
488
|
|
|
433
|
-
await submitDecision(baseUrl, assistantId, requestId, "deny", bearerToken);
|
|
489
|
+
await submitDecision(baseUrl, assistantId, requestId, "deny", bearerToken, actorToken);
|
|
434
490
|
chatApp.addStatus("\u2718 Denied", "yellow");
|
|
435
491
|
}
|
|
436
492
|
|
|
@@ -1025,6 +1081,7 @@ function ChatApp({
|
|
|
1025
1081
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
1026
1082
|
const doctorSessionIdRef = useRef(randomUUID());
|
|
1027
1083
|
const handleRef_ = useRef<ChatAppHandle | null>(null);
|
|
1084
|
+
const actorTokenRef = useRef<string | undefined>(undefined);
|
|
1028
1085
|
|
|
1029
1086
|
const { stdout } = useStdout();
|
|
1030
1087
|
const terminalRows = stdout.rows || DEFAULT_TERMINAL_ROWS;
|
|
@@ -1250,6 +1307,9 @@ function ChatApp({
|
|
|
1250
1307
|
|
|
1251
1308
|
try {
|
|
1252
1309
|
const health = await checkHealthRuntime(runtimeUrl);
|
|
1310
|
+
if (!actorTokenRef.current) {
|
|
1311
|
+
actorTokenRef.current = await bootstrapActorToken(runtimeUrl, bearerToken);
|
|
1312
|
+
}
|
|
1253
1313
|
h.hideSpinner();
|
|
1254
1314
|
h.updateHealthStatus(health.status);
|
|
1255
1315
|
if (health.status === "healthy" || health.status === "ok") {
|
|
@@ -1265,7 +1325,12 @@ function ChatApp({
|
|
|
1265
1325
|
h.showSpinner("Loading conversation history...");
|
|
1266
1326
|
|
|
1267
1327
|
try {
|
|
1268
|
-
const historyResponse = await pollMessages(
|
|
1328
|
+
const historyResponse = await pollMessages(
|
|
1329
|
+
runtimeUrl,
|
|
1330
|
+
assistantId,
|
|
1331
|
+
bearerToken,
|
|
1332
|
+
actorTokenRef.current,
|
|
1333
|
+
);
|
|
1269
1334
|
h.hideSpinner();
|
|
1270
1335
|
if (historyResponse.messages.length > 0) {
|
|
1271
1336
|
for (const msg of historyResponse.messages) {
|
|
@@ -1279,7 +1344,12 @@ function ChatApp({
|
|
|
1279
1344
|
|
|
1280
1345
|
pollTimerRef.current = setInterval(async () => {
|
|
1281
1346
|
try {
|
|
1282
|
-
const response = await pollMessages(
|
|
1347
|
+
const response = await pollMessages(
|
|
1348
|
+
runtimeUrl,
|
|
1349
|
+
assistantId,
|
|
1350
|
+
bearerToken,
|
|
1351
|
+
actorTokenRef.current,
|
|
1352
|
+
);
|
|
1283
1353
|
for (const msg of response.messages) {
|
|
1284
1354
|
if (!seenMessageIdsRef.current.has(msg.id)) {
|
|
1285
1355
|
seenMessageIdsRef.current.add(msg.id);
|
|
@@ -1296,11 +1366,12 @@ function ChatApp({
|
|
|
1296
1366
|
connectedRef.current = true;
|
|
1297
1367
|
connectingRef.current = false;
|
|
1298
1368
|
return true;
|
|
1299
|
-
} catch {
|
|
1369
|
+
} catch (err) {
|
|
1300
1370
|
h.hideSpinner();
|
|
1301
1371
|
connectingRef.current = false;
|
|
1302
1372
|
h.updateHealthStatus("unreachable");
|
|
1303
|
-
|
|
1373
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1374
|
+
h.addStatus(`${statusEmoji("unreachable")} Failed to connect: ${msg}`, "red");
|
|
1304
1375
|
return false;
|
|
1305
1376
|
}
|
|
1306
1377
|
}, [runtimeUrl, assistantId, bearerToken]);
|
|
@@ -1561,6 +1632,7 @@ function ChatApp({
|
|
|
1561
1632
|
trimmed,
|
|
1562
1633
|
controller.signal,
|
|
1563
1634
|
bearerToken,
|
|
1635
|
+
actorTokenRef.current,
|
|
1564
1636
|
);
|
|
1565
1637
|
clearTimeout(timeoutId);
|
|
1566
1638
|
if (!sendResult.accepted) {
|
|
@@ -1586,7 +1658,12 @@ function ChatApp({
|
|
|
1586
1658
|
|
|
1587
1659
|
// Check for pending confirmations/secrets
|
|
1588
1660
|
try {
|
|
1589
|
-
const pending = await pollPendingInteractions(
|
|
1661
|
+
const pending = await pollPendingInteractions(
|
|
1662
|
+
runtimeUrl,
|
|
1663
|
+
assistantId,
|
|
1664
|
+
bearerToken,
|
|
1665
|
+
actorTokenRef.current,
|
|
1666
|
+
);
|
|
1590
1667
|
|
|
1591
1668
|
if (pending.pendingConfirmation) {
|
|
1592
1669
|
h.hideSpinner();
|
|
@@ -1597,6 +1674,7 @@ function ChatApp({
|
|
|
1597
1674
|
pending.pendingConfirmation,
|
|
1598
1675
|
h,
|
|
1599
1676
|
bearerToken,
|
|
1677
|
+
actorTokenRef.current,
|
|
1600
1678
|
);
|
|
1601
1679
|
h.showSpinner("Working...");
|
|
1602
1680
|
continue;
|
|
@@ -1615,6 +1693,7 @@ function ChatApp({
|
|
|
1615
1693
|
body: JSON.stringify({ requestId: secretRequestId, value, delivery }),
|
|
1616
1694
|
},
|
|
1617
1695
|
bearerToken,
|
|
1696
|
+
actorTokenRef.current,
|
|
1618
1697
|
);
|
|
1619
1698
|
});
|
|
1620
1699
|
h.showSpinner("Working...");
|
|
@@ -1626,7 +1705,12 @@ function ChatApp({
|
|
|
1626
1705
|
|
|
1627
1706
|
// Poll for new messages to detect completion
|
|
1628
1707
|
try {
|
|
1629
|
-
const pollResult = await pollMessages(
|
|
1708
|
+
const pollResult = await pollMessages(
|
|
1709
|
+
runtimeUrl,
|
|
1710
|
+
assistantId,
|
|
1711
|
+
bearerToken,
|
|
1712
|
+
actorTokenRef.current,
|
|
1713
|
+
);
|
|
1630
1714
|
for (const msg of pollResult.messages) {
|
|
1631
1715
|
if (!seenMessageIdsRef.current.has(msg.id)) {
|
|
1632
1716
|
seenMessageIdsRef.current.add(msg.id);
|
|
@@ -1800,46 +1884,48 @@ function ChatApp({
|
|
|
1800
1884
|
healthStatus={healthStatus}
|
|
1801
1885
|
/>
|
|
1802
1886
|
|
|
1803
|
-
{
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
<
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
{item.
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
{
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1887
|
+
<Box flexDirection="column" flexGrow={1} overflow="hidden">
|
|
1888
|
+
{visibleWindow.hiddenAbove > 0 ? (
|
|
1889
|
+
<Text dimColor>
|
|
1890
|
+
{"\u2191"} {visibleWindow.hiddenAbove} more above (Shift+\u2191/Cmd+\u2191)
|
|
1891
|
+
</Text>
|
|
1892
|
+
) : null}
|
|
1893
|
+
|
|
1894
|
+
{visibleWindow.items.map((item, i) => {
|
|
1895
|
+
const feedIndex = visibleWindow.startIndex + i;
|
|
1896
|
+
if (isRuntimeMessage(item)) {
|
|
1897
|
+
return (
|
|
1898
|
+
<Box key={feedIndex} flexDirection="column" marginBottom={1}>
|
|
1899
|
+
<MessageDisplay msg={item} />
|
|
1900
|
+
</Box>
|
|
1901
|
+
);
|
|
1902
|
+
}
|
|
1903
|
+
if (item.type === "status") {
|
|
1904
|
+
return (
|
|
1905
|
+
<Text key={feedIndex} color={item.color as "green" | "yellow" | "red" | undefined}>
|
|
1906
|
+
{item.text}
|
|
1907
|
+
</Text>
|
|
1908
|
+
);
|
|
1909
|
+
}
|
|
1910
|
+
if (item.type === "help") {
|
|
1911
|
+
return <HelpDisplay key={feedIndex} />;
|
|
1912
|
+
}
|
|
1913
|
+
if (item.type === "error") {
|
|
1914
|
+
return (
|
|
1915
|
+
<Text key={feedIndex} color="red">
|
|
1916
|
+
{item.text}
|
|
1917
|
+
</Text>
|
|
1918
|
+
);
|
|
1919
|
+
}
|
|
1920
|
+
return null;
|
|
1921
|
+
})}
|
|
1837
1922
|
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1923
|
+
{visibleWindow.hiddenBelow > 0 ? (
|
|
1924
|
+
<Text dimColor>
|
|
1925
|
+
{"\u2193"} {visibleWindow.hiddenBelow} more below (Shift+\u2193/Cmd+\u2193)
|
|
1926
|
+
</Text>
|
|
1927
|
+
) : null}
|
|
1928
|
+
</Box>
|
|
1843
1929
|
|
|
1844
1930
|
{spinnerText ? <SpinnerDisplay text={spinnerText} /> : null}
|
|
1845
1931
|
|
package/src/lib/local.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync, spawn } from "child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
3
3
|
import { createRequire } from "module";
|
|
4
4
|
import { createConnection } from "net";
|
|
5
5
|
import { homedir } from "os";
|
|
@@ -8,35 +8,10 @@ import { dirname, join } from "path";
|
|
|
8
8
|
import { loadLatestAssistant } from "./assistant-config.js";
|
|
9
9
|
import { GATEWAY_PORT } from "./constants.js";
|
|
10
10
|
import { stopProcessByPidFile } from "./process.js";
|
|
11
|
+
import { openLogFile, closeLogFile } from "./xdg-log.js";
|
|
11
12
|
|
|
12
13
|
const _require = createRequire(import.meta.url);
|
|
13
14
|
|
|
14
|
-
/** Returns the XDG-compatible log directory for Vellum hatch logs. */
|
|
15
|
-
function getHatchLogDir(): string {
|
|
16
|
-
const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
17
|
-
return join(configHome, "vellum", "logs");
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Open (or create) a log file in append mode, returning the file descriptor.
|
|
21
|
-
* Creates the parent directory if it doesn't exist. Returns "ignore" if the
|
|
22
|
-
* directory or file cannot be created (permissions, read-only filesystem, etc.)
|
|
23
|
-
* so that spawn falls back to discarding output instead of aborting startup. */
|
|
24
|
-
function openHatchLogFile(name: string): number | "ignore" {
|
|
25
|
-
try {
|
|
26
|
-
const dir = getHatchLogDir();
|
|
27
|
-
mkdirSync(dir, { recursive: true });
|
|
28
|
-
return openSync(join(dir, name), "a");
|
|
29
|
-
} catch {
|
|
30
|
-
return "ignore";
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Close a file descriptor returned by openHatchLogFile (no-op for "ignore"). */
|
|
35
|
-
function closeHatchLogFile(fd: number | "ignore"): void {
|
|
36
|
-
if (typeof fd === "number") {
|
|
37
|
-
try { closeSync(fd); } catch { /* best-effort */ }
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
15
|
|
|
41
16
|
function isAssistantSourceDir(dir: string): boolean {
|
|
42
17
|
const pkgPath = join(dir, "package.json");
|
|
@@ -307,6 +282,12 @@ async function discoverPublicUrl(): Promise<string | undefined> {
|
|
|
307
282
|
// metadata service not reachable
|
|
308
283
|
}
|
|
309
284
|
|
|
285
|
+
// For custom hardware or when cloud-specific metadata didn't resolve,
|
|
286
|
+
// fall back to a public IP discovery service.
|
|
287
|
+
if (!externalIp) {
|
|
288
|
+
externalIp = await discoverPublicIpFallback();
|
|
289
|
+
}
|
|
290
|
+
|
|
310
291
|
if (externalIp) {
|
|
311
292
|
console.log(` Discovered external IP: ${externalIp}`);
|
|
312
293
|
return `http://${externalIp}:${GATEWAY_PORT}`;
|
|
@@ -314,6 +295,32 @@ async function discoverPublicUrl(): Promise<string | undefined> {
|
|
|
314
295
|
return undefined;
|
|
315
296
|
}
|
|
316
297
|
|
|
298
|
+
/** Try to discover the machine's public IP using external services.
|
|
299
|
+
* Attempts multiple providers for resilience. */
|
|
300
|
+
async function discoverPublicIpFallback(): Promise<string | undefined> {
|
|
301
|
+
const services = [
|
|
302
|
+
"https://api.ipify.org",
|
|
303
|
+
"https://ifconfig.me/ip",
|
|
304
|
+
"https://icanhazip.com",
|
|
305
|
+
];
|
|
306
|
+
|
|
307
|
+
for (const url of services) {
|
|
308
|
+
try {
|
|
309
|
+
const resp = await fetch(url, { signal: AbortSignal.timeout(3000) });
|
|
310
|
+
if (resp.ok) {
|
|
311
|
+
const ip = (await resp.text()).trim();
|
|
312
|
+
// Basic validation: must look like an IPv4 or IPv6 address
|
|
313
|
+
if (ip && /^[\d.:a-fA-F]+$/.test(ip)) {
|
|
314
|
+
return ip;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
} catch {
|
|
318
|
+
// Service unreachable, try the next one
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
|
|
317
324
|
export async function startLocalDaemon(): Promise<void> {
|
|
318
325
|
if (process.env.VELLUM_DESKTOP_APP) {
|
|
319
326
|
// When running inside the desktop app, the CLI owns the daemon lifecycle.
|
|
@@ -403,7 +410,7 @@ export async function startLocalDaemon(): Promise<void> {
|
|
|
403
410
|
}
|
|
404
411
|
}
|
|
405
412
|
|
|
406
|
-
const daemonLogFd =
|
|
413
|
+
const daemonLogFd = openLogFile("daemon.log");
|
|
407
414
|
let daemonPid: number | undefined;
|
|
408
415
|
try {
|
|
409
416
|
const child = spawn(daemonBinary, [], {
|
|
@@ -415,7 +422,7 @@ export async function startLocalDaemon(): Promise<void> {
|
|
|
415
422
|
child.unref();
|
|
416
423
|
daemonPid = child.pid;
|
|
417
424
|
} finally {
|
|
418
|
-
|
|
425
|
+
closeLogFile(daemonLogFd);
|
|
419
426
|
}
|
|
420
427
|
|
|
421
428
|
// Write PID file immediately so the health monitor can find the process
|
|
@@ -568,7 +575,7 @@ export async function startGateway(assistantId?: string): Promise<string> {
|
|
|
568
575
|
);
|
|
569
576
|
}
|
|
570
577
|
|
|
571
|
-
const gatewayLogFd =
|
|
578
|
+
const gatewayLogFd = openLogFile("gateway.log");
|
|
572
579
|
try {
|
|
573
580
|
gateway = spawn(gatewayBinary, [], {
|
|
574
581
|
detached: true,
|
|
@@ -576,12 +583,12 @@ export async function startGateway(assistantId?: string): Promise<string> {
|
|
|
576
583
|
env: gatewayEnv,
|
|
577
584
|
});
|
|
578
585
|
} finally {
|
|
579
|
-
|
|
586
|
+
closeLogFile(gatewayLogFd);
|
|
580
587
|
}
|
|
581
588
|
} else {
|
|
582
589
|
// Source tree / bunx: resolve the gateway source directory and run via bun.
|
|
583
590
|
const gatewayDir = resolveGatewayDir();
|
|
584
|
-
const gwLogFd =
|
|
591
|
+
const gwLogFd = openLogFile("gateway.log");
|
|
585
592
|
try {
|
|
586
593
|
gateway = spawn("bun", ["run", "src/index.ts"], {
|
|
587
594
|
cwd: gatewayDir,
|
|
@@ -590,7 +597,7 @@ export async function startGateway(assistantId?: string): Promise<string> {
|
|
|
590
597
|
env: gatewayEnv,
|
|
591
598
|
});
|
|
592
599
|
} finally {
|
|
593
|
-
|
|
600
|
+
closeLogFile(gwLogFd);
|
|
594
601
|
}
|
|
595
602
|
}
|
|
596
603
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { closeSync, mkdirSync, openSync, writeSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
5
|
+
/** Returns the XDG-compatible log directory for Vellum CLI logs. */
|
|
6
|
+
export function getLogDir(): string {
|
|
7
|
+
const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
8
|
+
return join(configHome, "vellum", "logs");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Open (or create) a log file in append mode, returning the file descriptor.
|
|
12
|
+
* Creates the parent directory if it doesn't exist. Returns "ignore" if the
|
|
13
|
+
* directory or file cannot be created (permissions, read-only filesystem, etc.)
|
|
14
|
+
* so that callers can fall back to discarding output instead of aborting. */
|
|
15
|
+
export function openLogFile(name: string): number | "ignore" {
|
|
16
|
+
try {
|
|
17
|
+
const dir = getLogDir();
|
|
18
|
+
mkdirSync(dir, { recursive: true });
|
|
19
|
+
return openSync(join(dir, name), "a");
|
|
20
|
+
} catch {
|
|
21
|
+
return "ignore";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Close a file descriptor returned by openLogFile (no-op for "ignore"). */
|
|
26
|
+
export function closeLogFile(fd: number | "ignore"): void {
|
|
27
|
+
if (typeof fd === "number") {
|
|
28
|
+
try { closeSync(fd); } catch { /* best-effort */ }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Write a string to a file descriptor returned by openLogFile (no-op for "ignore"). */
|
|
33
|
+
export function writeToLogFile(fd: number | "ignore", msg: string): void {
|
|
34
|
+
if (typeof fd === "number") {
|
|
35
|
+
try { writeSync(fd, msg); } catch { /* best-effort */ }
|
|
36
|
+
}
|
|
37
|
+
}
|