@vellumai/cli 0.6.3 → 0.6.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/AGENTS.md +12 -2
- package/README.md +3 -3
- package/bun.lock +17 -17
- package/bunfig.toml +6 -0
- package/package.json +18 -18
- package/src/__tests__/assistant-config.test.ts +124 -0
- package/src/__tests__/env-drift.test.ts +87 -0
- package/src/__tests__/guardian-token.test.ts +225 -0
- package/src/__tests__/llm-provider-env-var-parity.test.ts +64 -0
- package/src/__tests__/multi-local.test.ts +90 -13
- package/src/__tests__/orphan-detection.test.ts +214 -0
- package/src/__tests__/platform-client.test.ts +204 -0
- package/src/__tests__/preload.ts +27 -0
- package/src/__tests__/ssh-user-guard.test.ts +28 -0
- package/src/__tests__/teleport.test.ts +1073 -56
- package/src/commands/backup.ts +8 -0
- package/src/commands/exec.ts +186 -0
- package/src/commands/hatch.ts +1 -1
- package/src/commands/login.ts +209 -9
- package/src/commands/logs.ts +652 -0
- package/src/commands/pair.ts +9 -1
- package/src/commands/ps.ts +37 -7
- package/src/commands/recover.ts +8 -4
- package/src/commands/restore.ts +8 -0
- package/src/commands/retire.ts +16 -9
- package/src/commands/rollback.ts +32 -33
- package/src/commands/ssh.ts +7 -0
- package/src/commands/teleport.ts +253 -1
- package/src/commands/upgrade.ts +43 -52
- package/src/commands/wake.ts +25 -10
- package/src/components/DefaultMainScreen.tsx +7 -1
- package/src/index.ts +6 -0
- package/src/lib/__tests__/docker.test.ts +168 -0
- package/src/lib/assistant-config.ts +82 -108
- package/src/lib/aws.ts +12 -1
- package/src/lib/config-utils.ts +4 -4
- package/src/lib/constants.ts +0 -10
- package/src/lib/docker.ts +158 -8
- package/src/lib/environments/__tests__/paths.test.ts +228 -0
- package/src/lib/environments/__tests__/resolve.test.ts +226 -0
- package/src/lib/environments/__tests__/seeds.test.ts +72 -0
- package/src/lib/environments/paths.ts +109 -0
- package/src/lib/environments/resolve.ts +96 -0
- package/src/lib/environments/seeds.ts +74 -0
- package/src/lib/environments/types.ts +60 -0
- package/src/lib/exec-apple-container.ts +122 -0
- package/src/lib/gcp.ts +12 -1
- package/src/lib/guardian-token.ts +71 -10
- package/src/lib/hatch-local.ts +44 -23
- package/src/lib/local.ts +47 -5
- package/src/lib/orphan-detection.ts +28 -12
- package/src/lib/platform-client.ts +354 -24
- package/src/lib/retire-apple-container.ts +102 -0
- package/src/lib/ssh-apple-container.ts +166 -0
- package/src/lib/upgrade-lifecycle.ts +101 -28
- package/src/shared/provider-env-vars.ts +30 -6
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createConnection } from "net";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
|
|
4
|
+
import type { AssistantEntry } from "./assistant-config";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Execute a command inside an Apple Container assistant via the management
|
|
8
|
+
* socket. Non-interactive: sends the command, streams stdout/stderr to the
|
|
9
|
+
* terminal, and exits with the appropriate code.
|
|
10
|
+
*/
|
|
11
|
+
export async function execAppleContainer(
|
|
12
|
+
entry: AssistantEntry,
|
|
13
|
+
command: string[],
|
|
14
|
+
service: string,
|
|
15
|
+
): Promise<void> {
|
|
16
|
+
const mgmtSocket = entry.mgmtSocket as string | undefined;
|
|
17
|
+
if (!mgmtSocket) {
|
|
18
|
+
console.error(
|
|
19
|
+
`No management socket found for '${entry.assistantId}'.\n` +
|
|
20
|
+
"The assistant may not be running.",
|
|
21
|
+
);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!existsSync(mgmtSocket)) {
|
|
26
|
+
console.error(
|
|
27
|
+
`Management socket not found at ${mgmtSocket}.\n` +
|
|
28
|
+
"The assistant may have been stopped.",
|
|
29
|
+
);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const handshake =
|
|
34
|
+
JSON.stringify({
|
|
35
|
+
command,
|
|
36
|
+
service,
|
|
37
|
+
cols: process.stdout.columns || 120,
|
|
38
|
+
rows: process.stdout.rows || 40,
|
|
39
|
+
}) + "\n";
|
|
40
|
+
|
|
41
|
+
return new Promise<void>((resolve, reject) => {
|
|
42
|
+
const socket = createConnection({ path: mgmtSocket }, () => {
|
|
43
|
+
socket.write(handshake);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const HANDSHAKE_TIMEOUT_MS = 10_000;
|
|
47
|
+
let handshakeComplete = false;
|
|
48
|
+
const handshakeChunks: Buffer[] = [];
|
|
49
|
+
let handshakeLen = 0;
|
|
50
|
+
|
|
51
|
+
socket.setTimeout(HANDSHAKE_TIMEOUT_MS);
|
|
52
|
+
socket.on("timeout", () => {
|
|
53
|
+
if (!handshakeComplete) {
|
|
54
|
+
console.error("Timed out waiting for response from management socket.");
|
|
55
|
+
socket.destroy();
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
socket.on("data", (data: Buffer) => {
|
|
61
|
+
if (!handshakeComplete) {
|
|
62
|
+
handshakeChunks.push(data);
|
|
63
|
+
handshakeLen += data.length;
|
|
64
|
+
const accumulated = Buffer.concat(handshakeChunks, handshakeLen);
|
|
65
|
+
const nlIndex = accumulated.indexOf(0x0a);
|
|
66
|
+
if (nlIndex === -1) return;
|
|
67
|
+
|
|
68
|
+
const responseLine = accumulated.slice(0, nlIndex).toString("utf-8");
|
|
69
|
+
const remainder = accumulated.slice(nlIndex + 1);
|
|
70
|
+
handshakeComplete = true;
|
|
71
|
+
socket.setTimeout(0);
|
|
72
|
+
|
|
73
|
+
let response: { status: string; message?: string };
|
|
74
|
+
try {
|
|
75
|
+
response = JSON.parse(responseLine) as {
|
|
76
|
+
status: string;
|
|
77
|
+
message?: string;
|
|
78
|
+
};
|
|
79
|
+
} catch {
|
|
80
|
+
console.error("Invalid response from management socket.");
|
|
81
|
+
socket.destroy();
|
|
82
|
+
process.exit(1);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (response.status !== "ok") {
|
|
87
|
+
console.error(`Exec failed: ${response.message || "unknown error"}`);
|
|
88
|
+
socket.destroy();
|
|
89
|
+
process.exit(1);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Write any bytes that arrived after the handshake newline.
|
|
94
|
+
if (remainder.length > 0) {
|
|
95
|
+
process.stdout.write(remainder);
|
|
96
|
+
}
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Stream command output to stdout.
|
|
101
|
+
process.stdout.write(data);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
socket.on("end", () => {
|
|
105
|
+
if (handshakeComplete) {
|
|
106
|
+
resolve();
|
|
107
|
+
} else {
|
|
108
|
+
reject(new Error("Connection closed before handshake completed."));
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
socket.on("error", (err) => {
|
|
113
|
+
reject(new Error(`Management socket error: ${err.message}`));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
socket.on("close", () => {
|
|
117
|
+
if (handshakeComplete) {
|
|
118
|
+
resolve();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
package/src/lib/gcp.ts
CHANGED
|
@@ -503,7 +503,18 @@ export async function hatchGcp(
|
|
|
503
503
|
}
|
|
504
504
|
}
|
|
505
505
|
|
|
506
|
-
|
|
506
|
+
let sshUser: string;
|
|
507
|
+
try {
|
|
508
|
+
sshUser = userInfo().username;
|
|
509
|
+
} catch {
|
|
510
|
+
sshUser = process.env.USER ?? "";
|
|
511
|
+
}
|
|
512
|
+
if (!sshUser) {
|
|
513
|
+
console.error(
|
|
514
|
+
"Error: Could not determine SSH username. Set the USER environment variable and try again.",
|
|
515
|
+
);
|
|
516
|
+
process.exit(1);
|
|
517
|
+
}
|
|
507
518
|
const hatchedBy = process.env.VELLUM_HATCHED_BY;
|
|
508
519
|
const providerApiKeys: Record<string, string> = {};
|
|
509
520
|
for (const [, envVar] of Object.entries(PROVIDER_ENV_VAR_NAMES)) {
|
|
@@ -5,11 +5,16 @@ import {
|
|
|
5
5
|
existsSync,
|
|
6
6
|
mkdirSync,
|
|
7
7
|
readFileSync,
|
|
8
|
+
statSync,
|
|
8
9
|
writeFileSync,
|
|
9
10
|
} from "fs";
|
|
10
|
-
import {
|
|
11
|
+
import { platform } from "os";
|
|
11
12
|
import { dirname, join } from "path";
|
|
12
13
|
|
|
14
|
+
import { getConfigDir } from "./environments/paths.js";
|
|
15
|
+
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
16
|
+
import { SEEDS } from "./environments/seeds.js";
|
|
17
|
+
|
|
13
18
|
const DEVICE_ID_SALT = "vellum-assistant-host-id";
|
|
14
19
|
|
|
15
20
|
export interface GuardianTokenData {
|
|
@@ -24,14 +29,9 @@ export interface GuardianTokenData {
|
|
|
24
29
|
leasedAt: string;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
|
-
function getXdgConfigHome(): string {
|
|
28
|
-
return process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
|
|
29
|
-
}
|
|
30
|
-
|
|
31
32
|
function getGuardianTokenPath(assistantId: string): string {
|
|
32
33
|
return join(
|
|
33
|
-
|
|
34
|
-
"vellum",
|
|
34
|
+
getConfigDir(getCurrentEnvironment()),
|
|
35
35
|
"assistants",
|
|
36
36
|
assistantId,
|
|
37
37
|
"guardian-token.json",
|
|
@@ -39,7 +39,7 @@ function getGuardianTokenPath(assistantId: string): string {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function getPersistedDeviceIdPath(): string {
|
|
42
|
-
return join(
|
|
42
|
+
return join(getConfigDir(getCurrentEnvironment()), "device-id");
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
function hashWithSalt(input: string): string {
|
|
@@ -82,7 +82,7 @@ function getWindowsMachineGuid(): string | null {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
function getOrCreatePersistedDeviceId(): string {
|
|
85
|
+
export function getOrCreatePersistedDeviceId(): string {
|
|
86
86
|
const path = getPersistedDeviceIdPath();
|
|
87
87
|
try {
|
|
88
88
|
const existing = readFileSync(path, "utf-8").trim();
|
|
@@ -161,7 +161,7 @@ export function saveGuardianToken(
|
|
|
161
161
|
/**
|
|
162
162
|
* Call POST /v1/guardian/init on the remote gateway to bootstrap a JWT
|
|
163
163
|
* credential pair. The returned tokens are persisted locally under
|
|
164
|
-
* `$XDG_CONFIG_HOME/vellum/assistants/<assistantId>/guardian-token.json`.
|
|
164
|
+
* `$XDG_CONFIG_HOME/vellum{-env}/assistants/<assistantId>/guardian-token.json`.
|
|
165
165
|
*/
|
|
166
166
|
export async function leaseGuardianToken(
|
|
167
167
|
gatewayUrl: string,
|
|
@@ -202,3 +202,64 @@ export async function leaseGuardianToken(
|
|
|
202
202
|
saveGuardianToken(assistantId, tokenData);
|
|
203
203
|
return tokenData;
|
|
204
204
|
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Copy a guardian token from a sibling environment's config directory into
|
|
208
|
+
* the current environment's dir when the current one is missing it.
|
|
209
|
+
*
|
|
210
|
+
* The CLI's per-environment config layout (`~/.config/vellum{-env}/`) scopes
|
|
211
|
+
* the lockfile and the guardian token by VELLUM_ENVIRONMENT. Lockfiles are
|
|
212
|
+
* cross-written at hatch time, but a guardian token is only written under
|
|
213
|
+
* the env the assistant was hatched in. If the user later wakes the same
|
|
214
|
+
* assistant under a different env (e.g. a freshly built desktop app ships
|
|
215
|
+
* with VELLUM_ENVIRONMENT=local while the original hatch was under dev),
|
|
216
|
+
* the app cannot locate a bearer token and falls into a 401 → auth-rate-
|
|
217
|
+
* limit → 429 cascade against the local gateway.
|
|
218
|
+
*
|
|
219
|
+
* Returns true if a token was seeded, false if a token was already present
|
|
220
|
+
* or no sibling env had one to copy.
|
|
221
|
+
*/
|
|
222
|
+
export function seedGuardianTokenFromSiblingEnv(assistantId: string): boolean {
|
|
223
|
+
if (loadGuardianToken(assistantId) !== null) return false;
|
|
224
|
+
|
|
225
|
+
const currentEnvName = getCurrentEnvironment().name;
|
|
226
|
+
const destPath = getGuardianTokenPath(assistantId);
|
|
227
|
+
|
|
228
|
+
const candidates: { path: string; mtimeMs: number }[] = [];
|
|
229
|
+
for (const env of Object.values(SEEDS)) {
|
|
230
|
+
if (env.name === currentEnvName) continue;
|
|
231
|
+
const sibling = join(
|
|
232
|
+
getConfigDir(env),
|
|
233
|
+
"assistants",
|
|
234
|
+
assistantId,
|
|
235
|
+
"guardian-token.json",
|
|
236
|
+
);
|
|
237
|
+
try {
|
|
238
|
+
const stat = statSync(sibling);
|
|
239
|
+
candidates.push({ path: sibling, mtimeMs: stat.mtimeMs });
|
|
240
|
+
} catch {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
245
|
+
|
|
246
|
+
const now = Date.now();
|
|
247
|
+
for (const { path: sibling } of candidates) {
|
|
248
|
+
try {
|
|
249
|
+
const raw = readFileSync(sibling);
|
|
250
|
+
const parsed = JSON.parse(raw.toString("utf-8")) as GuardianTokenData;
|
|
251
|
+
const refreshExpiry = Date.parse(parsed.refreshTokenExpiresAt);
|
|
252
|
+
if (!Number.isFinite(refreshExpiry) || refreshExpiry <= now) continue;
|
|
253
|
+
const dir = dirname(destPath);
|
|
254
|
+
if (!existsSync(dir)) {
|
|
255
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
256
|
+
}
|
|
257
|
+
writeFileSync(destPath, raw, { mode: 0o600 });
|
|
258
|
+
chmodSync(destPath, 0o600);
|
|
259
|
+
return true;
|
|
260
|
+
} catch {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return false;
|
|
265
|
+
}
|
package/src/lib/hatch-local.ts
CHANGED
|
@@ -305,27 +305,32 @@ export async function hatchLocal(
|
|
|
305
305
|
// IP which the daemon rejects as non-loopback.
|
|
306
306
|
emitProgress(6, 7, "Securing connection...");
|
|
307
307
|
const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`;
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
308
|
+
const maxLeaseAttempts = 3;
|
|
309
|
+
for (let attempt = 1; attempt <= maxLeaseAttempts; attempt++) {
|
|
310
|
+
try {
|
|
311
|
+
await leaseGuardianToken(loopbackUrl, instanceName);
|
|
312
|
+
break;
|
|
313
|
+
} catch (err) {
|
|
314
|
+
if (attempt < maxLeaseAttempts) {
|
|
315
|
+
const delayMs = 2000 * 2 ** (attempt - 1);
|
|
316
|
+
console.error(
|
|
317
|
+
`⚠️ Guardian token lease attempt ${attempt}/${maxLeaseAttempts} failed — retrying in ${delayMs / 1000}s: ${err}`,
|
|
318
|
+
);
|
|
319
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
320
|
+
} else {
|
|
321
|
+
console.error(
|
|
322
|
+
`⚠️ Guardian token lease failed after ${maxLeaseAttempts} attempts: ${err}\n` +
|
|
323
|
+
` The assistant is running but guardian-token.json was not written.\n` +
|
|
324
|
+
` If the desktop app loses its stored credentials, re-hatch to recover.`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
312
328
|
}
|
|
313
329
|
|
|
314
330
|
// Auto-start ngrok if webhook integrations (e.g. Telegram, Twilio) are configured.
|
|
315
|
-
// Set BASE_DATA_DIR so ngrok reads the correct instance config.
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
|
|
319
|
-
if (ngrokChild?.pid) {
|
|
320
|
-
const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
|
|
321
|
-
writeFileSync(ngrokPidFile, String(ngrokChild.pid));
|
|
322
|
-
}
|
|
323
|
-
if (prevBaseDataDir !== undefined) {
|
|
324
|
-
process.env.BASE_DATA_DIR = prevBaseDataDir;
|
|
325
|
-
} else {
|
|
326
|
-
delete process.env.BASE_DATA_DIR;
|
|
327
|
-
}
|
|
328
|
-
|
|
331
|
+
// Set BASE_DATA_DIR so ngrok reads the correct instance config. Keep the
|
|
332
|
+
// lockfile save/sync inside the same scope so syncConfigToLockfile() reads
|
|
333
|
+
// this instance's workspace/config.json rather than a stale default path.
|
|
329
334
|
const localEntry: AssistantEntry = {
|
|
330
335
|
assistantId: instanceName,
|
|
331
336
|
runtimeUrl,
|
|
@@ -333,13 +338,29 @@ export async function hatchLocal(
|
|
|
333
338
|
cloud: "local",
|
|
334
339
|
species,
|
|
335
340
|
hatchedAt: new Date().toISOString(),
|
|
336
|
-
serviceGroupVersion: cliPkg.version ? `v${cliPkg.version}` : undefined,
|
|
337
341
|
resources: { ...resources, signingKey },
|
|
338
342
|
};
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
+
|
|
344
|
+
const prevBaseDataDir = process.env.BASE_DATA_DIR;
|
|
345
|
+
process.env.BASE_DATA_DIR = resources.instanceDir;
|
|
346
|
+
try {
|
|
347
|
+
const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
|
|
348
|
+
if (ngrokChild?.pid) {
|
|
349
|
+
const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
|
|
350
|
+
writeFileSync(ngrokPidFile, String(ngrokChild.pid));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
emitProgress(7, 7, "Saving configuration...");
|
|
354
|
+
saveAssistantEntry(localEntry);
|
|
355
|
+
setActiveAssistant(instanceName);
|
|
356
|
+
syncConfigToLockfile();
|
|
357
|
+
} finally {
|
|
358
|
+
if (prevBaseDataDir !== undefined) {
|
|
359
|
+
process.env.BASE_DATA_DIR = prevBaseDataDir;
|
|
360
|
+
} else {
|
|
361
|
+
delete process.env.BASE_DATA_DIR;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
343
364
|
|
|
344
365
|
if (process.env.VELLUM_DESKTOP_APP) {
|
|
345
366
|
installCLISymlink();
|
package/src/lib/local.ts
CHANGED
|
@@ -283,12 +283,18 @@ async function startDaemonFromSource(
|
|
|
283
283
|
RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
|
|
284
284
|
VELLUM_CLOUD: "local",
|
|
285
285
|
VELLUM_DEV: "1",
|
|
286
|
+
VELLUM_ENVIRONMENT: process.env.VELLUM_ENVIRONMENT || "local",
|
|
286
287
|
...(options?.signingKey
|
|
287
288
|
? { ACTOR_TOKEN_SIGNING_KEY: options.signingKey }
|
|
288
289
|
: {}),
|
|
289
290
|
};
|
|
290
291
|
if (resources) {
|
|
291
292
|
env.BASE_DATA_DIR = resources.instanceDir;
|
|
293
|
+
env.GATEWAY_SECURITY_DIR = join(
|
|
294
|
+
resources.instanceDir,
|
|
295
|
+
".vellum",
|
|
296
|
+
"protected",
|
|
297
|
+
);
|
|
292
298
|
env.RUNTIME_HTTP_PORT = String(resources.daemonPort);
|
|
293
299
|
env.GATEWAY_PORT = String(resources.gatewayPort);
|
|
294
300
|
env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
|
|
@@ -404,12 +410,18 @@ async function startDaemonWatchFromSource(
|
|
|
404
410
|
...process.env,
|
|
405
411
|
RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
|
|
406
412
|
VELLUM_DEV: "1",
|
|
413
|
+
VELLUM_ENVIRONMENT: process.env.VELLUM_ENVIRONMENT || "local",
|
|
407
414
|
...(options?.signingKey
|
|
408
415
|
? { ACTOR_TOKEN_SIGNING_KEY: options.signingKey }
|
|
409
416
|
: {}),
|
|
410
417
|
};
|
|
411
418
|
if (resources) {
|
|
412
419
|
env.BASE_DATA_DIR = resources.instanceDir;
|
|
420
|
+
env.GATEWAY_SECURITY_DIR = join(
|
|
421
|
+
resources.instanceDir,
|
|
422
|
+
".vellum",
|
|
423
|
+
"protected",
|
|
424
|
+
);
|
|
413
425
|
env.RUNTIME_HTTP_PORT = String(resources.daemonPort);
|
|
414
426
|
env.GATEWAY_PORT = String(resources.gatewayPort);
|
|
415
427
|
env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
|
|
@@ -855,11 +867,16 @@ export async function startLocalDaemon(
|
|
|
855
867
|
HOME: process.env.HOME || home,
|
|
856
868
|
PATH: [...extraDirs, basePath].filter(Boolean).join(":"),
|
|
857
869
|
};
|
|
858
|
-
// Forward optional config env vars the daemon may need
|
|
870
|
+
// Forward optional config env vars the daemon may need.
|
|
871
|
+
// `VELLUM_ENVIRONMENT` must be forwarded so the daemon resolves
|
|
872
|
+
// env-scoped paths (device ID, platform/guardian tokens, XDG
|
|
873
|
+
// config dir) to the same location as the CLI that spawned it.
|
|
859
874
|
for (const key of [
|
|
860
875
|
"ANTHROPIC_API_KEY",
|
|
861
876
|
"APP_VERSION",
|
|
862
877
|
"BASE_DATA_DIR",
|
|
878
|
+
"GATEWAY_SECURITY_DIR",
|
|
879
|
+
"VELLUM_ENVIRONMENT",
|
|
863
880
|
"VELLUM_PLATFORM_URL",
|
|
864
881
|
"QDRANT_HTTP_PORT",
|
|
865
882
|
"QDRANT_URL",
|
|
@@ -885,6 +902,11 @@ export async function startLocalDaemon(
|
|
|
885
902
|
// all paths under the instance directory and listens on its own port.
|
|
886
903
|
if (resources) {
|
|
887
904
|
daemonEnv.BASE_DATA_DIR = resources.instanceDir;
|
|
905
|
+
daemonEnv.GATEWAY_SECURITY_DIR = join(
|
|
906
|
+
resources.instanceDir,
|
|
907
|
+
".vellum",
|
|
908
|
+
"protected",
|
|
909
|
+
);
|
|
888
910
|
daemonEnv.RUNTIME_HTTP_PORT = String(resources.daemonPort);
|
|
889
911
|
daemonEnv.GATEWAY_PORT = String(resources.gatewayPort);
|
|
890
912
|
daemonEnv.QDRANT_HTTP_PORT = String(resources.qdrantPort);
|
|
@@ -1043,10 +1065,30 @@ export async function startGateway(
|
|
|
1043
1065
|
...(options?.signingKey
|
|
1044
1066
|
? { ACTOR_TOKEN_SIGNING_KEY: options.signingKey }
|
|
1045
1067
|
: {}),
|
|
1046
|
-
...(watch
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1068
|
+
...(watch
|
|
1069
|
+
? {
|
|
1070
|
+
VELLUM_DEV: "1",
|
|
1071
|
+
VELLUM_ENVIRONMENT: process.env.VELLUM_ENVIRONMENT || "local",
|
|
1072
|
+
}
|
|
1073
|
+
: {}),
|
|
1074
|
+
// Set VELLUM_WORKSPACE_DIR and GATEWAY_SECURITY_DIR so the gateway
|
|
1075
|
+
// loads the correct credentials and workspace config for this instance
|
|
1076
|
+
// (mirrors the daemon env setup).
|
|
1077
|
+
...(resources
|
|
1078
|
+
? {
|
|
1079
|
+
BASE_DATA_DIR: resources.instanceDir,
|
|
1080
|
+
VELLUM_WORKSPACE_DIR: join(
|
|
1081
|
+
resources.instanceDir,
|
|
1082
|
+
".vellum",
|
|
1083
|
+
"workspace",
|
|
1084
|
+
),
|
|
1085
|
+
GATEWAY_SECURITY_DIR: join(
|
|
1086
|
+
resources.instanceDir,
|
|
1087
|
+
".vellum",
|
|
1088
|
+
"protected",
|
|
1089
|
+
),
|
|
1090
|
+
}
|
|
1091
|
+
: {}),
|
|
1050
1092
|
};
|
|
1051
1093
|
if (publicUrl) {
|
|
1052
1094
|
console.log(` Ingress URL: ${publicUrl}`);
|
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "fs";
|
|
|
2
2
|
import { homedir } from "os";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
|
|
5
|
+
import { loadAllAssistants } from "./assistant-config.js";
|
|
5
6
|
import { execOutput } from "./step-runner";
|
|
6
7
|
|
|
7
8
|
export interface RemoteProcess {
|
|
@@ -72,20 +73,35 @@ export interface OrphanedProcess {
|
|
|
72
73
|
export async function detectOrphanedProcesses(): Promise<OrphanedProcess[]> {
|
|
73
74
|
const results: OrphanedProcess[] = [];
|
|
74
75
|
const seenPids = new Set<string>();
|
|
75
|
-
const vellumDir = join(homedir(), ".vellum");
|
|
76
76
|
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
77
|
+
// Collect every known local instance's `.vellum/` directory from the
|
|
78
|
+
// lockfile so orphan detection scans all containers under the current
|
|
79
|
+
// multi-instance data layout, not just the legacy `~/.vellum/` root.
|
|
80
|
+
const dirs = new Set<string>();
|
|
81
|
+
for (const entry of loadAllAssistants()) {
|
|
82
|
+
if (entry.cloud !== "local" || !entry.resources) continue;
|
|
83
|
+
dirs.add(join(entry.resources.instanceDir, ".vellum"));
|
|
84
|
+
}
|
|
85
|
+
// Preserve the legacy root scan for installs that predate multi-instance
|
|
86
|
+
// tracking. This catches orphans from a pre-upgrade `~/.vellum/` that
|
|
87
|
+
// may not have a lockfile entry at all.
|
|
88
|
+
dirs.add(join(homedir(), ".vellum"));
|
|
89
|
+
|
|
90
|
+
// Strategy 1: PID file scan — check every known data directory.
|
|
91
|
+
for (const dir of dirs) {
|
|
92
|
+
const pidFiles: Array<{ file: string; name: string }> = [
|
|
93
|
+
{ file: join(dir, "vellum.pid"), name: "assistant" },
|
|
94
|
+
{ file: join(dir, "gateway.pid"), name: "gateway" },
|
|
95
|
+
{ file: join(dir, "qdrant.pid"), name: "qdrant" },
|
|
96
|
+
];
|
|
83
97
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
98
|
+
for (const { file, name } of pidFiles) {
|
|
99
|
+
const pid = readPidFile(file);
|
|
100
|
+
if (!pid || seenPids.has(pid)) continue;
|
|
101
|
+
if (isProcessAlive(pid)) {
|
|
102
|
+
results.push({ name, pid, source: "pid file" });
|
|
103
|
+
seenPids.add(pid);
|
|
104
|
+
}
|
|
89
105
|
}
|
|
90
106
|
}
|
|
91
107
|
|