@vellumai/cli 0.6.3 → 0.6.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/AGENTS.md +12 -2
- package/README.md +3 -3
- package/bunfig.toml +6 -0
- package/package.json +1 -1
- 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 +172 -0
- package/src/__tests__/multi-local.test.ts +61 -14
- 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/hatch.ts +1 -1
- package/src/commands/login.ts +178 -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-apple-container.ts +162 -0
- package/src/commands/ssh.ts +7 -0
- package/src/commands/teleport.ts +226 -1
- package/src/commands/upgrade.ts +43 -52
- package/src/commands/wake.ts +14 -10
- package/src/components/DefaultMainScreen.tsx +7 -1
- package/src/index.ts +3 -0
- package/src/lib/__tests__/docker.test.ts +78 -0
- package/src/lib/assistant-config.ts +48 -87
- package/src/lib/aws.ts +12 -1
- package/src/lib/constants.ts +0 -10
- package/src/lib/docker.ts +70 -4
- package/src/lib/environments/__tests__/paths.test.ts +234 -0
- package/src/lib/environments/__tests__/resolve.test.ts +226 -0
- package/src/lib/environments/paths.ts +110 -0
- package/src/lib/environments/resolve.ts +96 -0
- package/src/lib/environments/seeds.ts +46 -0
- package/src/lib/environments/types.ts +60 -0
- package/src/lib/gcp.ts +12 -1
- package/src/lib/guardian-token.ts +8 -10
- package/src/lib/hatch-local.ts +24 -19
- package/src/lib/local.ts +46 -5
- package/src/lib/orphan-detection.ts +28 -12
- package/src/lib/platform-client.ts +220 -24
- package/src/lib/retire-apple-container.ts +102 -0
- package/src/lib/upgrade-lifecycle.ts +101 -28
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { EnvironmentDefinition } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Built-in environment definitions. Mirrors Swift's
|
|
5
|
+
* `clients/macos/vellum-assistant/App/VellumEnvironment.swift` enum and is
|
|
6
|
+
* the TS-side source of truth for the set of known environment names.
|
|
7
|
+
* Two other TS sites duplicate the name list:
|
|
8
|
+
* - `assistant/src/util/platform.ts` (`KNOWN_ENVIRONMENTS`)
|
|
9
|
+
* - `clients/chrome-extension/native-host/src/lockfile.ts`
|
|
10
|
+
* (`NON_PRODUCTION_ENVIRONMENTS`, excludes `production`)
|
|
11
|
+
* Drift between these three sites is caught at test time by
|
|
12
|
+
* `cli/src/__tests__/env-drift.test.ts`. Fast follow: hoist the shared
|
|
13
|
+
* list into a `packages/environments` package so all three sites import
|
|
14
|
+
* from one place.
|
|
15
|
+
*
|
|
16
|
+
* Custom environments via a user config file are a future phase — see the
|
|
17
|
+
* "Coexisting environments" design doc. Until then, a call site that needs a
|
|
18
|
+
* new environment must add it here and rebuild.
|
|
19
|
+
*/
|
|
20
|
+
export const SEEDS: Record<string, EnvironmentDefinition> = {
|
|
21
|
+
production: {
|
|
22
|
+
name: "production",
|
|
23
|
+
platformUrl: "https://platform.vellum.ai",
|
|
24
|
+
},
|
|
25
|
+
staging: {
|
|
26
|
+
name: "staging",
|
|
27
|
+
platformUrl: "https://staging-platform.vellum.ai",
|
|
28
|
+
},
|
|
29
|
+
test: {
|
|
30
|
+
name: "test",
|
|
31
|
+
// Non-functional URL — used only by unit tests for URL resolution, never
|
|
32
|
+
// hit in production.
|
|
33
|
+
platformUrl: "https://test-platform.vellum.ai",
|
|
34
|
+
},
|
|
35
|
+
dev: {
|
|
36
|
+
name: "dev",
|
|
37
|
+
platformUrl: "https://dev-platform.vellum.ai",
|
|
38
|
+
},
|
|
39
|
+
local: {
|
|
40
|
+
name: "local",
|
|
41
|
+
platformUrl: "http://localhost:8000",
|
|
42
|
+
// assistantPlatformUrl: "http://host.docker.internal:8000",
|
|
43
|
+
// ^ uncomment this once dockerized hatch path is live.
|
|
44
|
+
// The assistant runs in a different network namespace than the host.
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment type definitions. Environments are deployment targets with
|
|
3
|
+
* their own platform backend and their own isolated on-host state. See the
|
|
4
|
+
* "Coexisting environments" design doc for the full model.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Per-service default port set. Phase 5 (per-environment port offsets) is
|
|
9
|
+
* deferred from MVP, so today every environment uses the same port set. The
|
|
10
|
+
* shape exists so the rest of the stack can call `getDefaultPorts(env)` and
|
|
11
|
+
* gain per-env offsets later without changing any call sites.
|
|
12
|
+
*/
|
|
13
|
+
export interface PortMap {
|
|
14
|
+
daemon: number;
|
|
15
|
+
gateway: number;
|
|
16
|
+
qdrant: number;
|
|
17
|
+
ces: number;
|
|
18
|
+
outboundProxy: number;
|
|
19
|
+
tcp: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A resolved environment definition. Required fields are `name` and
|
|
24
|
+
* `platformUrl`. All other fields are optional and declared upfront — new
|
|
25
|
+
* fields are additive, never breaking. `name` is intentionally typed as
|
|
26
|
+
* `string` (not `keyof SEEDS`) so custom environments can be represented by
|
|
27
|
+
* future layers (user config file, ad-hoc env vars, etc.).
|
|
28
|
+
*/
|
|
29
|
+
export interface EnvironmentDefinition {
|
|
30
|
+
name: string;
|
|
31
|
+
platformUrl: string;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Override for the platform URL the assistant process itself uses. Only
|
|
35
|
+
* differs from `platformUrl` when the assistant runs in a different network
|
|
36
|
+
* namespace than the host (e.g. Docker on macOS, where the host's localhost
|
|
37
|
+
* is reached via `host.docker.internal`). Falls back to `platformUrl` when
|
|
38
|
+
* unset.
|
|
39
|
+
*/
|
|
40
|
+
assistantPlatformUrl?: string;
|
|
41
|
+
|
|
42
|
+
/** Human-readable label for UI surfaces. */
|
|
43
|
+
displayName?: string;
|
|
44
|
+
|
|
45
|
+
/** Hint for UI surfaces that want to tint or badge their display. */
|
|
46
|
+
tintColor?: string;
|
|
47
|
+
|
|
48
|
+
/** Per-service port overrides merged on top of defaults. */
|
|
49
|
+
portsOverride?: Partial<PortMap>;
|
|
50
|
+
|
|
51
|
+
/** Override for the XDG config directory. */
|
|
52
|
+
configDirOverride?: string;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Override for the directory containing the lockfile. Populated by the
|
|
56
|
+
* resolver from `VELLUM_LOCKFILE_DIR` (an existing e2e test escape hatch)
|
|
57
|
+
* so path helpers don't read env vars directly.
|
|
58
|
+
*/
|
|
59
|
+
lockfileDirOverride?: string;
|
|
60
|
+
}
|
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)) {
|
|
@@ -7,9 +7,12 @@ import {
|
|
|
7
7
|
readFileSync,
|
|
8
8
|
writeFileSync,
|
|
9
9
|
} from "fs";
|
|
10
|
-
import {
|
|
10
|
+
import { platform } from "os";
|
|
11
11
|
import { dirname, join } from "path";
|
|
12
12
|
|
|
13
|
+
import { getConfigDir } from "./environments/paths.js";
|
|
14
|
+
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
15
|
+
|
|
13
16
|
const DEVICE_ID_SALT = "vellum-assistant-host-id";
|
|
14
17
|
|
|
15
18
|
export interface GuardianTokenData {
|
|
@@ -24,14 +27,9 @@ export interface GuardianTokenData {
|
|
|
24
27
|
leasedAt: string;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
|
-
function getXdgConfigHome(): string {
|
|
28
|
-
return process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
|
|
29
|
-
}
|
|
30
|
-
|
|
31
30
|
function getGuardianTokenPath(assistantId: string): string {
|
|
32
31
|
return join(
|
|
33
|
-
|
|
34
|
-
"vellum",
|
|
32
|
+
getConfigDir(getCurrentEnvironment()),
|
|
35
33
|
"assistants",
|
|
36
34
|
assistantId,
|
|
37
35
|
"guardian-token.json",
|
|
@@ -39,7 +37,7 @@ function getGuardianTokenPath(assistantId: string): string {
|
|
|
39
37
|
}
|
|
40
38
|
|
|
41
39
|
function getPersistedDeviceIdPath(): string {
|
|
42
|
-
return join(
|
|
40
|
+
return join(getConfigDir(getCurrentEnvironment()), "device-id");
|
|
43
41
|
}
|
|
44
42
|
|
|
45
43
|
function hashWithSalt(input: string): string {
|
|
@@ -82,7 +80,7 @@ function getWindowsMachineGuid(): string | null {
|
|
|
82
80
|
}
|
|
83
81
|
}
|
|
84
82
|
|
|
85
|
-
function getOrCreatePersistedDeviceId(): string {
|
|
83
|
+
export function getOrCreatePersistedDeviceId(): string {
|
|
86
84
|
const path = getPersistedDeviceIdPath();
|
|
87
85
|
try {
|
|
88
86
|
const existing = readFileSync(path, "utf-8").trim();
|
|
@@ -161,7 +159,7 @@ export function saveGuardianToken(
|
|
|
161
159
|
/**
|
|
162
160
|
* Call POST /v1/guardian/init on the remote gateway to bootstrap a JWT
|
|
163
161
|
* credential pair. The returned tokens are persisted locally under
|
|
164
|
-
* `$XDG_CONFIG_HOME/vellum/assistants/<assistantId>/guardian-token.json`.
|
|
162
|
+
* `$XDG_CONFIG_HOME/vellum{-env}/assistants/<assistantId>/guardian-token.json`.
|
|
165
163
|
*/
|
|
166
164
|
export async function leaseGuardianToken(
|
|
167
165
|
gatewayUrl: string,
|
package/src/lib/hatch-local.ts
CHANGED
|
@@ -312,20 +312,9 @@ export async function hatchLocal(
|
|
|
312
312
|
}
|
|
313
313
|
|
|
314
314
|
// 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
|
-
|
|
315
|
+
// Set BASE_DATA_DIR so ngrok reads the correct instance config. Keep the
|
|
316
|
+
// lockfile save/sync inside the same scope so syncConfigToLockfile() reads
|
|
317
|
+
// this instance's workspace/config.json rather than a stale default path.
|
|
329
318
|
const localEntry: AssistantEntry = {
|
|
330
319
|
assistantId: instanceName,
|
|
331
320
|
runtimeUrl,
|
|
@@ -333,13 +322,29 @@ export async function hatchLocal(
|
|
|
333
322
|
cloud: "local",
|
|
334
323
|
species,
|
|
335
324
|
hatchedAt: new Date().toISOString(),
|
|
336
|
-
serviceGroupVersion: cliPkg.version ? `v${cliPkg.version}` : undefined,
|
|
337
325
|
resources: { ...resources, signingKey },
|
|
338
326
|
};
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
327
|
+
|
|
328
|
+
const prevBaseDataDir = process.env.BASE_DATA_DIR;
|
|
329
|
+
process.env.BASE_DATA_DIR = resources.instanceDir;
|
|
330
|
+
try {
|
|
331
|
+
const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
|
|
332
|
+
if (ngrokChild?.pid) {
|
|
333
|
+
const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
|
|
334
|
+
writeFileSync(ngrokPidFile, String(ngrokChild.pid));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
emitProgress(7, 7, "Saving configuration...");
|
|
338
|
+
saveAssistantEntry(localEntry);
|
|
339
|
+
setActiveAssistant(instanceName);
|
|
340
|
+
syncConfigToLockfile();
|
|
341
|
+
} finally {
|
|
342
|
+
if (prevBaseDataDir !== undefined) {
|
|
343
|
+
process.env.BASE_DATA_DIR = prevBaseDataDir;
|
|
344
|
+
} else {
|
|
345
|
+
delete process.env.BASE_DATA_DIR;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
343
348
|
|
|
344
349
|
if (process.env.VELLUM_DESKTOP_APP) {
|
|
345
350
|
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,29 @@ 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
|
+
VELLUM_WORKSPACE_DIR: join(
|
|
1080
|
+
resources.instanceDir,
|
|
1081
|
+
".vellum",
|
|
1082
|
+
"workspace",
|
|
1083
|
+
),
|
|
1084
|
+
GATEWAY_SECURITY_DIR: join(
|
|
1085
|
+
resources.instanceDir,
|
|
1086
|
+
".vellum",
|
|
1087
|
+
"protected",
|
|
1088
|
+
),
|
|
1089
|
+
}
|
|
1090
|
+
: {}),
|
|
1050
1091
|
};
|
|
1051
1092
|
if (publicUrl) {
|
|
1052
1093
|
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
|
|
|
@@ -6,36 +6,37 @@ import {
|
|
|
6
6
|
existsSync,
|
|
7
7
|
mkdirSync,
|
|
8
8
|
} from "fs";
|
|
9
|
-
import { homedir } from "os";
|
|
10
9
|
import { join, dirname } from "path";
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
11
|
+
import { getLockfilePlatformBaseUrl } from "./assistant-config.js";
|
|
12
|
+
import { getConfigDir } from "./environments/paths.js";
|
|
13
|
+
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
15
14
|
|
|
16
15
|
function getPlatformTokenPath(): string {
|
|
17
|
-
return join(
|
|
16
|
+
return join(getConfigDir(getCurrentEnvironment()), "platform-token");
|
|
18
17
|
}
|
|
19
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the platform API base URL. Resolution order:
|
|
21
|
+
* 1. `platformBaseUrl` persisted on the lockfile by
|
|
22
|
+
* {@link syncConfigToLockfile} when the active assistant was last
|
|
23
|
+
* hatched/waked. This is the source of truth for "what URL does the
|
|
24
|
+
* currently-active assistant target" — reading the workspace
|
|
25
|
+
* `config.json` directly is incorrect for multi-instance and
|
|
26
|
+
* non-production XDG layouts because the CLI process has no way to
|
|
27
|
+
* know which instance to read from without first consulting the
|
|
28
|
+
* lockfile anyway.
|
|
29
|
+
* 2. `VELLUM_PLATFORM_URL` env var (explicit override, e.g. in CI).
|
|
30
|
+
* 3. The current environment's seed URL (e.g. `https://dev-platform.vellum.ai`
|
|
31
|
+
* for `VELLUM_ENVIRONMENT=dev`, `https://platform.vellum.ai` for prod).
|
|
32
|
+
* This makes the CLI environment-aware when no lockfile entry exists yet.
|
|
33
|
+
*/
|
|
20
34
|
export function getPlatformUrl(): string {
|
|
21
|
-
|
|
22
|
-
try {
|
|
23
|
-
const base = process.env.BASE_DATA_DIR?.trim() || homedir();
|
|
24
|
-
const configPath = join(base, ".vellum", "workspace", "config.json");
|
|
25
|
-
if (existsSync(configPath)) {
|
|
26
|
-
const raw = JSON.parse(readFileSync(configPath, "utf-8")) as Record<
|
|
27
|
-
string,
|
|
28
|
-
unknown
|
|
29
|
-
>;
|
|
30
|
-
const val = (raw.platform as Record<string, unknown> | undefined)
|
|
31
|
-
?.baseUrl;
|
|
32
|
-
if (typeof val === "string" && val.trim()) configUrl = val.trim();
|
|
33
|
-
}
|
|
34
|
-
} catch {
|
|
35
|
-
// Config not available — fall through
|
|
36
|
-
}
|
|
35
|
+
const lockfileUrl = getLockfilePlatformBaseUrl();
|
|
37
36
|
return (
|
|
38
|
-
|
|
37
|
+
lockfileUrl ||
|
|
38
|
+
process.env.VELLUM_PLATFORM_URL?.trim() ||
|
|
39
|
+
getCurrentEnvironment().platformUrl
|
|
39
40
|
);
|
|
40
41
|
}
|
|
41
42
|
|
|
@@ -146,10 +147,173 @@ export interface HatchedAssistant {
|
|
|
146
147
|
status: string;
|
|
147
148
|
}
|
|
148
149
|
|
|
150
|
+
export interface HatchAssistantResult {
|
|
151
|
+
assistant: HatchedAssistant;
|
|
152
|
+
/** true when the platform returned an existing assistant (HTTP 200) */
|
|
153
|
+
reusedExisting: boolean;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Self-hosted local assistant registration
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
export interface EnsureRegistrationResponse {
|
|
161
|
+
assistant: { id: string; name: string };
|
|
162
|
+
registration: {
|
|
163
|
+
client_installation_id: string;
|
|
164
|
+
runtime_assistant_id: string;
|
|
165
|
+
client_platform: string;
|
|
166
|
+
};
|
|
167
|
+
assistant_api_key: string | null;
|
|
168
|
+
webhook_secret: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Register (or re-confirm) a self-hosted local assistant with the platform.
|
|
173
|
+
*
|
|
174
|
+
* Calls `POST /v1/assistants/self-hosted-local/ensure-registration/`.
|
|
175
|
+
* The endpoint is idempotent: the first call provisions an API key;
|
|
176
|
+
* subsequent calls return `assistant_api_key: null`.
|
|
177
|
+
*/
|
|
178
|
+
export async function ensureSelfHostedLocalRegistration(
|
|
179
|
+
token: string,
|
|
180
|
+
organizationId: string,
|
|
181
|
+
clientInstallationId: string,
|
|
182
|
+
runtimeAssistantId: string,
|
|
183
|
+
clientPlatform: string,
|
|
184
|
+
assistantVersion?: string,
|
|
185
|
+
platformUrl?: string,
|
|
186
|
+
): Promise<EnsureRegistrationResponse> {
|
|
187
|
+
const resolvedUrl = platformUrl || getPlatformUrl();
|
|
188
|
+
const body: Record<string, string> = {
|
|
189
|
+
client_installation_id: clientInstallationId,
|
|
190
|
+
runtime_assistant_id: runtimeAssistantId,
|
|
191
|
+
client_platform: clientPlatform,
|
|
192
|
+
};
|
|
193
|
+
if (assistantVersion) {
|
|
194
|
+
body.assistant_version = assistantVersion;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const response = await fetch(
|
|
198
|
+
`${resolvedUrl}/v1/assistants/self-hosted-local/ensure-registration/`,
|
|
199
|
+
{
|
|
200
|
+
method: "POST",
|
|
201
|
+
headers: {
|
|
202
|
+
"Content-Type": "application/json",
|
|
203
|
+
Accept: "application/json",
|
|
204
|
+
"X-Session-Token": token,
|
|
205
|
+
"Vellum-Organization-Id": organizationId,
|
|
206
|
+
},
|
|
207
|
+
body: JSON.stringify(body),
|
|
208
|
+
},
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
if (response.status === 401 || response.status === 403) {
|
|
212
|
+
throw new Error("Authentication required for assistant registration.");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!response.ok) {
|
|
216
|
+
const detail = await response.text().catch(() => "");
|
|
217
|
+
throw new Error(
|
|
218
|
+
`Registration failed (${response.status}): ${detail || response.statusText}`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return (await response.json()) as EnsureRegistrationResponse;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// Credential injection into running assistant via gateway
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Inject a single credential into the assistant's secret store via the
|
|
231
|
+
* gateway's `POST /v1/secrets` endpoint.
|
|
232
|
+
*
|
|
233
|
+
* Mirrors the desktop app's `GatewayHTTPClient.post(path: "secrets", …)`
|
|
234
|
+
* calls in `LocalAssistantBootstrapService.swift`.
|
|
235
|
+
*/
|
|
236
|
+
async function injectGatewayCredential(
|
|
237
|
+
gatewayUrl: string,
|
|
238
|
+
name: string,
|
|
239
|
+
value: string,
|
|
240
|
+
bearerToken?: string,
|
|
241
|
+
): Promise<boolean> {
|
|
242
|
+
const headers: Record<string, string> = {
|
|
243
|
+
"Content-Type": "application/json",
|
|
244
|
+
Accept: "application/json",
|
|
245
|
+
};
|
|
246
|
+
if (bearerToken) {
|
|
247
|
+
headers["Authorization"] = `Bearer ${bearerToken}`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const response = await fetch(`${gatewayUrl}/v1/secrets`, {
|
|
251
|
+
method: "POST",
|
|
252
|
+
headers,
|
|
253
|
+
body: JSON.stringify({ type: "credential", name, value }),
|
|
254
|
+
signal: AbortSignal.timeout(10_000),
|
|
255
|
+
});
|
|
256
|
+
return response.ok;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export interface CredentialInjectionParams {
|
|
260
|
+
gatewayUrl: string;
|
|
261
|
+
bearerToken?: string;
|
|
262
|
+
assistantApiKey?: string | null;
|
|
263
|
+
platformAssistantId: string;
|
|
264
|
+
platformBaseUrl: string;
|
|
265
|
+
organizationId: string;
|
|
266
|
+
userId?: string;
|
|
267
|
+
webhookSecret?: string | null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Inject platform credentials into a running assistant via the gateway,
|
|
272
|
+
* mirroring `LocalAssistantBootstrapService.injectKeyIntoAssistant` et al.
|
|
273
|
+
*
|
|
274
|
+
* Each credential is posted individually. Failures are collected but do
|
|
275
|
+
* not prevent the remaining credentials from being injected.
|
|
276
|
+
*
|
|
277
|
+
* Returns true if all injections succeeded.
|
|
278
|
+
*/
|
|
279
|
+
export async function injectCredentialsIntoAssistant(
|
|
280
|
+
params: CredentialInjectionParams,
|
|
281
|
+
): Promise<boolean> {
|
|
282
|
+
const inject = (name: string, value: string) =>
|
|
283
|
+
injectGatewayCredential(params.gatewayUrl, name, value, params.bearerToken);
|
|
284
|
+
|
|
285
|
+
const promises: Promise<boolean>[] = [];
|
|
286
|
+
|
|
287
|
+
if (params.assistantApiKey) {
|
|
288
|
+
promises.push(inject("vellum:assistant_api_key", params.assistantApiKey));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
promises.push(
|
|
292
|
+
inject("vellum:platform_assistant_id", params.platformAssistantId),
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
promises.push(inject("vellum:platform_base_url", params.platformBaseUrl));
|
|
296
|
+
|
|
297
|
+
promises.push(
|
|
298
|
+
inject("vellum:platform_organization_id", params.organizationId),
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
if (params.userId) {
|
|
302
|
+
promises.push(inject("vellum:platform_user_id", params.userId));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (params.webhookSecret) {
|
|
306
|
+
promises.push(inject("vellum:webhook_secret", params.webhookSecret));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const results = await Promise.all(promises);
|
|
310
|
+
return results.every(Boolean);
|
|
311
|
+
}
|
|
312
|
+
|
|
149
313
|
export async function hatchAssistant(
|
|
150
314
|
token: string,
|
|
151
315
|
platformUrl?: string,
|
|
152
|
-
): Promise<
|
|
316
|
+
): Promise<HatchAssistantResult> {
|
|
153
317
|
const resolvedUrl = platformUrl || getPlatformUrl();
|
|
154
318
|
const url = `${resolvedUrl}/v1/assistants/hatch/`;
|
|
155
319
|
|
|
@@ -160,7 +324,8 @@ export async function hatchAssistant(
|
|
|
160
324
|
});
|
|
161
325
|
|
|
162
326
|
if (response.ok) {
|
|
163
|
-
|
|
327
|
+
const assistant = (await response.json()) as HatchedAssistant;
|
|
328
|
+
return { assistant, reusedExisting: response.status === 200 };
|
|
164
329
|
}
|
|
165
330
|
|
|
166
331
|
if (response.status === 401 || response.status === 403) {
|
|
@@ -186,6 +351,37 @@ export async function hatchAssistant(
|
|
|
186
351
|
);
|
|
187
352
|
}
|
|
188
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Lightweight pre-check: returns the first active managed assistant for the
|
|
356
|
+
* authenticated user, or `null` if none exists. Calls `GET /v1/assistants/`
|
|
357
|
+
* and looks for any assistant with status "active".
|
|
358
|
+
*
|
|
359
|
+
* Used by the teleport flow to block BEFORE the expensive GCS upload when
|
|
360
|
+
* the user already has a platform assistant.
|
|
361
|
+
*/
|
|
362
|
+
export async function checkExistingPlatformAssistant(
|
|
363
|
+
token: string,
|
|
364
|
+
platformUrl?: string,
|
|
365
|
+
): Promise<HatchedAssistant | null> {
|
|
366
|
+
const resolvedUrl = platformUrl || getPlatformUrl();
|
|
367
|
+
const url = `${resolvedUrl}/v1/assistants/`;
|
|
368
|
+
|
|
369
|
+
const response = await fetch(url, {
|
|
370
|
+
headers: await authHeaders(token, platformUrl),
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
if (!response.ok) {
|
|
374
|
+
// Non-fatal: if the list call fails, fall through and let hatch handle it.
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const body = (await response.json()) as {
|
|
379
|
+
results?: HatchedAssistant[];
|
|
380
|
+
};
|
|
381
|
+
const active = body.results?.find((a) => a.status === "active");
|
|
382
|
+
return active ?? null;
|
|
383
|
+
}
|
|
384
|
+
|
|
189
385
|
export interface PlatformUser {
|
|
190
386
|
id: string;
|
|
191
387
|
email: string;
|