@vellumai/cli 0.6.4 → 0.6.6
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/bun.lock +17 -17
- package/package.json +18 -18
- package/src/__tests__/guardian-token.test.ts +56 -3
- package/src/__tests__/llm-provider-env-var-parity.test.ts +64 -0
- package/src/__tests__/multi-local.test.ts +30 -0
- package/src/commands/exec.ts +186 -0
- package/src/commands/login.ts +32 -1
- package/src/commands/retire.ts +23 -0
- package/src/commands/ssh.ts +1 -1
- package/src/commands/teleport.ts +28 -1
- package/src/commands/terminal.ts +437 -0
- package/src/commands/wake.ts +11 -0
- package/src/index.ts +6 -0
- package/src/lib/__tests__/docker.test.ts +91 -1
- package/src/lib/assistant-config.ts +35 -22
- package/src/lib/config-utils.ts +4 -4
- package/src/lib/docker.ts +88 -4
- package/src/lib/environments/__tests__/paths.test.ts +3 -9
- package/src/lib/environments/__tests__/seeds.test.ts +72 -0
- package/src/lib/environments/paths.ts +4 -5
- package/src/lib/environments/seeds.ts +29 -1
- package/src/lib/exec-apple-container.ts +122 -0
- package/src/lib/guardian-token.ts +63 -0
- package/src/lib/hatch-local.ts +20 -4
- package/src/lib/local.ts +1 -0
- package/src/lib/platform-client.ts +134 -0
- package/src/{commands → lib}/ssh-apple-container.ts +8 -4
- package/src/lib/terminal-client.ts +177 -0
- package/src/shared/provider-env-vars.ts +30 -6
|
@@ -10,14 +10,9 @@ import {
|
|
|
10
10
|
import { homedir } from "os";
|
|
11
11
|
import { dirname, join } from "path";
|
|
12
12
|
|
|
13
|
+
import { DAEMON_INTERNAL_ASSISTANT_ID } from "./constants.js";
|
|
13
14
|
import {
|
|
14
|
-
|
|
15
|
-
DEFAULT_CES_PORT,
|
|
16
|
-
DEFAULT_DAEMON_PORT,
|
|
17
|
-
DEFAULT_GATEWAY_PORT,
|
|
18
|
-
DEFAULT_QDRANT_PORT,
|
|
19
|
-
} from "./constants.js";
|
|
20
|
-
import {
|
|
15
|
+
getDefaultPorts,
|
|
21
16
|
getLockfilePath,
|
|
22
17
|
getLockfilePaths,
|
|
23
18
|
getMultiInstanceDir,
|
|
@@ -187,6 +182,7 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
|
|
|
187
182
|
}
|
|
188
183
|
|
|
189
184
|
const env = getCurrentEnvironment();
|
|
185
|
+
const defaultPorts = getDefaultPorts(env);
|
|
190
186
|
let mutated = false;
|
|
191
187
|
|
|
192
188
|
// Migrate top-level `baseDataDir` → `resources.instanceDir`
|
|
@@ -206,7 +202,7 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
|
|
|
206
202
|
// Synthesise missing `resources` for local entries
|
|
207
203
|
if (!raw.resources || typeof raw.resources !== "object") {
|
|
208
204
|
const gatewayPort =
|
|
209
|
-
parsePortFromUrl(raw.runtimeUrl) ??
|
|
205
|
+
parsePortFromUrl(raw.runtimeUrl) ?? defaultPorts.gateway;
|
|
210
206
|
const instanceDir = join(
|
|
211
207
|
getMultiInstanceDir(env),
|
|
212
208
|
typeof raw.assistantId === "string"
|
|
@@ -215,10 +211,10 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
|
|
|
215
211
|
);
|
|
216
212
|
raw.resources = {
|
|
217
213
|
instanceDir,
|
|
218
|
-
daemonPort:
|
|
214
|
+
daemonPort: defaultPorts.daemon,
|
|
219
215
|
gatewayPort,
|
|
220
|
-
qdrantPort:
|
|
221
|
-
cesPort:
|
|
216
|
+
qdrantPort: defaultPorts.qdrant,
|
|
217
|
+
cesPort: defaultPorts.ces,
|
|
222
218
|
pidFile: join(instanceDir, ".vellum", "vellum.pid"),
|
|
223
219
|
};
|
|
224
220
|
mutated = true;
|
|
@@ -235,20 +231,20 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
|
|
|
235
231
|
mutated = true;
|
|
236
232
|
}
|
|
237
233
|
if (typeof res.daemonPort !== "number") {
|
|
238
|
-
res.daemonPort =
|
|
234
|
+
res.daemonPort = defaultPorts.daemon;
|
|
239
235
|
mutated = true;
|
|
240
236
|
}
|
|
241
237
|
if (typeof res.gatewayPort !== "number") {
|
|
242
238
|
res.gatewayPort =
|
|
243
|
-
parsePortFromUrl(raw.runtimeUrl) ??
|
|
239
|
+
parsePortFromUrl(raw.runtimeUrl) ?? defaultPorts.gateway;
|
|
244
240
|
mutated = true;
|
|
245
241
|
}
|
|
246
242
|
if (typeof res.qdrantPort !== "number") {
|
|
247
|
-
res.qdrantPort =
|
|
243
|
+
res.qdrantPort = defaultPorts.qdrant;
|
|
248
244
|
mutated = true;
|
|
249
245
|
}
|
|
250
246
|
if (typeof res.cesPort !== "number") {
|
|
251
|
-
res.cesPort =
|
|
247
|
+
res.cesPort = defaultPorts.ces;
|
|
252
248
|
mutated = true;
|
|
253
249
|
}
|
|
254
250
|
if (typeof res.pidFile !== "string") {
|
|
@@ -378,6 +374,22 @@ export function resolveTargetAssistant(nameArg?: string): AssistantEntry {
|
|
|
378
374
|
process.exit(1);
|
|
379
375
|
}
|
|
380
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Determine which cloud topology an assistant entry is running on.
|
|
379
|
+
*/
|
|
380
|
+
export function resolveCloud(entry: AssistantEntry): string {
|
|
381
|
+
if (entry.cloud) {
|
|
382
|
+
return entry.cloud;
|
|
383
|
+
}
|
|
384
|
+
if (entry.project) {
|
|
385
|
+
return "gcp";
|
|
386
|
+
}
|
|
387
|
+
if (entry.sshUser) {
|
|
388
|
+
return "custom";
|
|
389
|
+
}
|
|
390
|
+
return "local";
|
|
391
|
+
}
|
|
392
|
+
|
|
381
393
|
export function saveAssistantEntry(entry: AssistantEntry): void {
|
|
382
394
|
const entries = readAssistants().filter(
|
|
383
395
|
(e) => e.assistantId !== entry.assistantId,
|
|
@@ -435,20 +447,21 @@ export async function allocateLocalResources(
|
|
|
435
447
|
);
|
|
436
448
|
}
|
|
437
449
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
);
|
|
442
|
-
const
|
|
450
|
+
// Env-aware bases: non-prod envs sit in their own 1000-port window so
|
|
451
|
+
// running prod and staging assistants side-by-side doesn't collide. See
|
|
452
|
+
// `environments/seeds.ts:portBlock` for the layout.
|
|
453
|
+
const basePorts = getDefaultPorts(env);
|
|
454
|
+
const daemonPort = await findAvailablePort(basePorts.daemon, reservedPorts);
|
|
455
|
+
const gatewayPort = await findAvailablePort(basePorts.gateway, [
|
|
443
456
|
...reservedPorts,
|
|
444
457
|
daemonPort,
|
|
445
458
|
]);
|
|
446
|
-
const qdrantPort = await findAvailablePort(
|
|
459
|
+
const qdrantPort = await findAvailablePort(basePorts.qdrant, [
|
|
447
460
|
...reservedPorts,
|
|
448
461
|
daemonPort,
|
|
449
462
|
gatewayPort,
|
|
450
463
|
]);
|
|
451
|
-
const cesPort = await findAvailablePort(
|
|
464
|
+
const cesPort = await findAvailablePort(basePorts.ces, [
|
|
452
465
|
...reservedPorts,
|
|
453
466
|
daemonPort,
|
|
454
467
|
gatewayPort,
|
package/src/lib/config-utils.ts
CHANGED
|
@@ -5,8 +5,8 @@ import { join } from "path";
|
|
|
5
5
|
/**
|
|
6
6
|
* Convert flat dot-notation key=value pairs into a nested config object.
|
|
7
7
|
*
|
|
8
|
-
* e.g. {"
|
|
9
|
-
* → {
|
|
8
|
+
* e.g. {"llm.default.provider": "anthropic", "llm.default.model": "claude-opus-4-6"}
|
|
9
|
+
* → {llm: {default: {provider: "anthropic", model: "claude-opus-4-6"}}}
|
|
10
10
|
*/
|
|
11
11
|
export function buildNestedConfig(
|
|
12
12
|
configValues: Record<string, string>,
|
|
@@ -39,8 +39,8 @@ export function buildNestedConfig(
|
|
|
39
39
|
* values into its workspace config on first boot.
|
|
40
40
|
*
|
|
41
41
|
* Keys use dot-notation to address nested fields. For example:
|
|
42
|
-
* "
|
|
43
|
-
* "
|
|
42
|
+
* "llm.default.provider" → {llm: {default: {provider: ...}}}
|
|
43
|
+
* "llm.default.model" → {llm: {default: {model: ...}}}
|
|
44
44
|
*
|
|
45
45
|
* Returns undefined when configValues is empty (nothing to write).
|
|
46
46
|
*/
|
package/src/lib/docker.ts
CHANGED
|
@@ -6,6 +6,13 @@ import { dirname, join } from "path";
|
|
|
6
6
|
// Direct import — bun embeds this at compile time so it works in compiled binaries.
|
|
7
7
|
import cliPkg from "../../package.json";
|
|
8
8
|
|
|
9
|
+
// Pulled from skills/ — the Meet avatar device-path default is owned by the
|
|
10
|
+
// meet-join skill; importing here keeps the CLI's Docker wiring locked to the
|
|
11
|
+
// same value the bot and config schema use. The shared module is deliberately
|
|
12
|
+
// zero-dep so this import cannot drag unrelated surface into the compiled CLI
|
|
13
|
+
// binary.
|
|
14
|
+
import { AVATAR_DEVICE_PATH_DEFAULT } from "../../../skills/meet-join/shared/avatar-device-path.js";
|
|
15
|
+
|
|
9
16
|
import {
|
|
10
17
|
findAssistantByName,
|
|
11
18
|
saveAssistantEntry,
|
|
@@ -13,9 +20,10 @@ import {
|
|
|
13
20
|
} from "./assistant-config";
|
|
14
21
|
import type { AssistantEntry } from "./assistant-config";
|
|
15
22
|
import { writeInitialConfig } from "./config-utils";
|
|
16
|
-
import { DEFAULT_GATEWAY_PORT } from "./constants";
|
|
17
23
|
import { PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
|
|
18
24
|
import type { Species } from "./constants";
|
|
25
|
+
import { getDefaultPorts } from "./environments/paths.js";
|
|
26
|
+
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
19
27
|
import { leaseGuardianToken } from "./guardian-token";
|
|
20
28
|
import { isVellumProcess, stopProcess } from "./process";
|
|
21
29
|
import { generateInstanceName } from "./random-name";
|
|
@@ -45,6 +53,64 @@ export const GATEWAY_INTERNAL_PORT = 7830;
|
|
|
45
53
|
/** Max time to wait for the assistant container to emit the readiness sentinel. */
|
|
46
54
|
export const DOCKER_READY_TIMEOUT_MS = 3 * 60 * 1000;
|
|
47
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Default virtual-camera device path when the Meet avatar feature is
|
|
58
|
+
* enabled. Re-exports the shared
|
|
59
|
+
* {@link ../../../skills/meet-join/shared/avatar-device-path.js AVATAR_DEVICE_PATH_DEFAULT}
|
|
60
|
+
* so the CLI's device-passthrough wiring cannot drift from the bot's
|
|
61
|
+
* Chrome-flag wiring or the workspace config default. Matches the
|
|
62
|
+
* `video_nr=10` value in the README's host-setup section
|
|
63
|
+
* (`skills/meet-join/bot/README.md`). Operators can override the path by
|
|
64
|
+
* setting `VELLUM_MEET_AVATAR_DEVICE` to something other than the default
|
|
65
|
+
* (e.g. `/dev/video11` if a different `video_nr` was used).
|
|
66
|
+
*/
|
|
67
|
+
export const DEFAULT_MEET_AVATAR_DEVICE_PATH = AVATAR_DEVICE_PATH_DEFAULT;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Env-var opt-in for bind-mounting the v4l2loopback virtual-camera device
|
|
71
|
+
* into the assistant container. Set to a truthy value (`1`, `true`, `yes`)
|
|
72
|
+
* to enable; unset or falsy disables the passthrough entirely.
|
|
73
|
+
*
|
|
74
|
+
* Kept as an env-var rather than a config-schema field because the Meet
|
|
75
|
+
* avatar config schema lands in a later PR (PR 5 of the phase-4 plan) —
|
|
76
|
+
* threading the config through the CLI's boot flow now would force a
|
|
77
|
+
* forward dependency. Once the schema lands, the CLI can either keep this
|
|
78
|
+
* env-var as a pre-config override or move the opt-in into the workspace
|
|
79
|
+
* config.
|
|
80
|
+
*/
|
|
81
|
+
export const MEET_AVATAR_ENV_VAR = "VELLUM_MEET_AVATAR";
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Override for the virtual-camera device path. Defaults to
|
|
85
|
+
* {@link DEFAULT_MEET_AVATAR_DEVICE_PATH}.
|
|
86
|
+
*/
|
|
87
|
+
export const MEET_AVATAR_DEVICE_ENV_VAR = "VELLUM_MEET_AVATAR_DEVICE";
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve the Meet avatar device path to pass through to the assistant
|
|
91
|
+
* container, or `null` if the feature is not opted into. Exported so tests
|
|
92
|
+
* can assert against the env-var parsing without reaching into the shell.
|
|
93
|
+
*/
|
|
94
|
+
export function resolveMeetAvatarDevicePath(
|
|
95
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
96
|
+
): string | null {
|
|
97
|
+
const flag = env[MEET_AVATAR_ENV_VAR];
|
|
98
|
+
if (!flag) return null;
|
|
99
|
+
const normalized = flag.trim().toLowerCase();
|
|
100
|
+
if (
|
|
101
|
+
normalized === "" ||
|
|
102
|
+
normalized === "0" ||
|
|
103
|
+
normalized === "false" ||
|
|
104
|
+
normalized === "no"
|
|
105
|
+
) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const override = env[MEET_AVATAR_DEVICE_ENV_VAR];
|
|
109
|
+
return override && override.length > 0
|
|
110
|
+
? override
|
|
111
|
+
: DEFAULT_MEET_AVATAR_DEVICE_PATH;
|
|
112
|
+
}
|
|
113
|
+
|
|
48
114
|
/** Default memory (GiB) allocated to the Colima VM. */
|
|
49
115
|
const COLIMA_DEFAULT_MEMORY_GIB = 8;
|
|
50
116
|
|
|
@@ -515,8 +581,8 @@ function serviceImageConfigs(
|
|
|
515
581
|
tag: imageTags["credential-executor"],
|
|
516
582
|
},
|
|
517
583
|
gateway: {
|
|
518
|
-
context:
|
|
519
|
-
dockerfile: "Dockerfile",
|
|
584
|
+
context: repoRoot,
|
|
585
|
+
dockerfile: "gateway/Dockerfile",
|
|
520
586
|
tag: imageTags.gateway,
|
|
521
587
|
},
|
|
522
588
|
};
|
|
@@ -660,6 +726,24 @@ export function serviceDockerRunArgs(opts: {
|
|
|
660
726
|
args.push("-e", `${key}=${value}`);
|
|
661
727
|
}
|
|
662
728
|
}
|
|
729
|
+
// Optional Meet avatar (v4l2loopback) passthrough. When
|
|
730
|
+
// `VELLUM_MEET_AVATAR=1` is set in the caller's environment, bind the
|
|
731
|
+
// virtual-camera device node (default `/dev/video10`) into the
|
|
732
|
+
// assistant container so the nested `dockerd` can in turn pass it
|
|
733
|
+
// through to the Meet-bot container. The daemon-side equivalent of
|
|
734
|
+
// this opt-in lives on `DockerRunner.run()`'s `avatarDevicePath`
|
|
735
|
+
// option (see `skills/meet-join/daemon/docker-runner.ts`).
|
|
736
|
+
const avatarDevice = resolveMeetAvatarDevicePath();
|
|
737
|
+
if (avatarDevice) {
|
|
738
|
+
args.push(
|
|
739
|
+
"--device",
|
|
740
|
+
`${avatarDevice}:${avatarDevice}`,
|
|
741
|
+
"-e",
|
|
742
|
+
`${MEET_AVATAR_ENV_VAR}=1`,
|
|
743
|
+
"-e",
|
|
744
|
+
`${MEET_AVATAR_DEVICE_ENV_VAR}=${avatarDevice}`,
|
|
745
|
+
);
|
|
746
|
+
}
|
|
663
747
|
args.push(imageTags.assistant);
|
|
664
748
|
return args;
|
|
665
749
|
},
|
|
@@ -1074,7 +1158,7 @@ export async function hatchDocker(
|
|
|
1074
1158
|
await ensureDockerInstalled();
|
|
1075
1159
|
|
|
1076
1160
|
const instanceName = generateInstanceName(species, name);
|
|
1077
|
-
const gatewayPort =
|
|
1161
|
+
const gatewayPort = getDefaultPorts(getCurrentEnvironment()).gateway;
|
|
1078
1162
|
|
|
1079
1163
|
const imageTags: Record<ServiceName, string> = {
|
|
1080
1164
|
assistant: "",
|
|
@@ -200,18 +200,12 @@ describe("path helpers", () => {
|
|
|
200
200
|
expect(ports.tcp).toBe(8765);
|
|
201
201
|
});
|
|
202
202
|
|
|
203
|
-
test("returns
|
|
203
|
+
test("returns base defaults for a bare env with no portsOverride", () => {
|
|
204
|
+
// Bare env literal (no portsOverride) falls through to DEFAULT_PORTS.
|
|
205
|
+
// Real non-prod seeds populate portsOverride — see seeds.test cases.
|
|
204
206
|
expect(getDefaultPorts(dev)).toEqual(getDefaultPorts(prod));
|
|
205
207
|
});
|
|
206
208
|
|
|
207
|
-
test("returns identical defaults for staging", () => {
|
|
208
|
-
const staging: EnvironmentDefinition = {
|
|
209
|
-
name: "staging",
|
|
210
|
-
platformUrl: "https://staging-platform.vellum.ai",
|
|
211
|
-
};
|
|
212
|
-
expect(getDefaultPorts(staging)).toEqual(getDefaultPorts(prod));
|
|
213
|
-
});
|
|
214
|
-
|
|
215
209
|
test("merges env.portsOverride on top of defaults", () => {
|
|
216
210
|
const env: EnvironmentDefinition = {
|
|
217
211
|
...dev,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { getDefaultPorts } from "../paths.js";
|
|
4
|
+
import { SEEDS } from "../seeds.js";
|
|
5
|
+
|
|
6
|
+
describe("SEEDS port blocks", () => {
|
|
7
|
+
test("production uses the legacy (pre-MVP) port layout", () => {
|
|
8
|
+
const ports = getDefaultPorts(SEEDS.production!);
|
|
9
|
+
expect(ports).toEqual({
|
|
10
|
+
daemon: 7821,
|
|
11
|
+
gateway: 7830,
|
|
12
|
+
qdrant: 6333,
|
|
13
|
+
ces: 8090,
|
|
14
|
+
outboundProxy: 8080,
|
|
15
|
+
tcp: 8765,
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test.each([
|
|
20
|
+
["staging", 17000],
|
|
21
|
+
["dev", 18000],
|
|
22
|
+
["test", 19000],
|
|
23
|
+
["local", 20000],
|
|
24
|
+
] as const)("%s block starts at %i with 100-apart services", (name, base) => {
|
|
25
|
+
const ports = getDefaultPorts(SEEDS[name]!);
|
|
26
|
+
expect(ports).toEqual({
|
|
27
|
+
daemon: base,
|
|
28
|
+
gateway: base + 100,
|
|
29
|
+
qdrant: base + 200,
|
|
30
|
+
ces: base + 300,
|
|
31
|
+
outboundProxy: base + 400,
|
|
32
|
+
tcp: base + 500,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("non-prod blocks are disjoint across environments", () => {
|
|
37
|
+
// 100 instances per service is the scan headroom in findAvailablePort,
|
|
38
|
+
// so a block "occupies" base…base+599 from daemon through tcp. Verify no
|
|
39
|
+
// two blocks overlap for any service.
|
|
40
|
+
const blocks = (["staging", "dev", "test", "local"] as const).map(
|
|
41
|
+
(name) => ({
|
|
42
|
+
name,
|
|
43
|
+
ports: getDefaultPorts(SEEDS[name]!),
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
const allPorts = new Set<number>();
|
|
47
|
+
for (const { name, ports } of blocks) {
|
|
48
|
+
for (const port of Object.values(ports)) {
|
|
49
|
+
// Within each block, each service has 100 slots (base…base+99).
|
|
50
|
+
for (let offset = 0; offset < 100; offset++) {
|
|
51
|
+
const p = port + offset;
|
|
52
|
+
if (allPorts.has(p)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`port ${p} (in ${name}'s block) overlaps another env's block`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
allPorts.add(p);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("non-prod blocks sit below Linux's default ephemeral range (32768)", () => {
|
|
64
|
+
for (const name of ["staging", "dev", "test", "local"] as const) {
|
|
65
|
+
const ports = getDefaultPorts(SEEDS[name]!);
|
|
66
|
+
for (const port of Object.values(ports)) {
|
|
67
|
+
// Max port we'll ever scan to is base+99 for daemon/gateway/etc.
|
|
68
|
+
expect(port + 99).toBeLessThan(32768);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -86,11 +86,10 @@ export function getMultiInstanceDir(env: EnvironmentDefinition): string {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/**
|
|
89
|
-
* Default port set for an environment.
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* merged on top of the defaults when set.
|
|
89
|
+
* Default port set for an environment.
|
|
90
|
+
* Seed entries for non-prod environments come with separate port ranges
|
|
91
|
+
* to avoid collisions in multi-env / multi-instance setups.
|
|
92
|
+
* Longer term, consider allocating ports dynamically at hatch/wake time.
|
|
94
93
|
*/
|
|
95
94
|
export function getDefaultPorts(env: EnvironmentDefinition): PortMap {
|
|
96
95
|
return {
|
|
@@ -1,4 +1,28 @@
|
|
|
1
|
-
import type { EnvironmentDefinition } from "./types.js";
|
|
1
|
+
import type { EnvironmentDefinition, PortMap } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Non-prod port blocks. Each environment gets a 1000-port window in the
|
|
5
|
+
* 17000–21000 band. Within a block, services are spaced 100 apart so up to
|
|
6
|
+
* 100 assistants can coexist without the scan (`findAvailablePort`) running
|
|
7
|
+
* one service's range into the next. Band chosen to sit below Linux's
|
|
8
|
+
* default ephemeral start (32768) and macOS's (49152), and away from the
|
|
9
|
+
* 3000/5000/8000/9000 dev-tool swamp. Production keeps its legacy,
|
|
10
|
+
* non-contiguous port set (7821/7830/6333/8090/8080/8765): cross-env
|
|
11
|
+
* collision is the only problem this change targets, prod is unaffected
|
|
12
|
+
* because only one env's assistants compete on a given machine, and
|
|
13
|
+
* churning it would leave existing hatches on 7821 while new ones
|
|
14
|
+
* allocated elsewhere.
|
|
15
|
+
*/
|
|
16
|
+
function portBlock(base: number): PortMap {
|
|
17
|
+
return {
|
|
18
|
+
daemon: base,
|
|
19
|
+
gateway: base + 100,
|
|
20
|
+
qdrant: base + 200,
|
|
21
|
+
ces: base + 300,
|
|
22
|
+
outboundProxy: base + 400,
|
|
23
|
+
tcp: base + 500,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
2
26
|
|
|
3
27
|
/**
|
|
4
28
|
* Built-in environment definitions. Mirrors Swift's
|
|
@@ -25,16 +49,19 @@ export const SEEDS: Record<string, EnvironmentDefinition> = {
|
|
|
25
49
|
staging: {
|
|
26
50
|
name: "staging",
|
|
27
51
|
platformUrl: "https://staging-platform.vellum.ai",
|
|
52
|
+
portsOverride: portBlock(17000),
|
|
28
53
|
},
|
|
29
54
|
test: {
|
|
30
55
|
name: "test",
|
|
31
56
|
// Non-functional URL — used only by unit tests for URL resolution, never
|
|
32
57
|
// hit in production.
|
|
33
58
|
platformUrl: "https://test-platform.vellum.ai",
|
|
59
|
+
portsOverride: portBlock(19000),
|
|
34
60
|
},
|
|
35
61
|
dev: {
|
|
36
62
|
name: "dev",
|
|
37
63
|
platformUrl: "https://dev-platform.vellum.ai",
|
|
64
|
+
portsOverride: portBlock(18000),
|
|
38
65
|
},
|
|
39
66
|
local: {
|
|
40
67
|
name: "local",
|
|
@@ -42,5 +69,6 @@ export const SEEDS: Record<string, EnvironmentDefinition> = {
|
|
|
42
69
|
// assistantPlatformUrl: "http://host.docker.internal:8000",
|
|
43
70
|
// ^ uncomment this once dockerized hatch path is live.
|
|
44
71
|
// The assistant runs in a different network namespace than the host.
|
|
72
|
+
portsOverride: portBlock(20000),
|
|
45
73
|
},
|
|
46
74
|
};
|
|
@@ -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
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
existsSync,
|
|
6
6
|
mkdirSync,
|
|
7
7
|
readFileSync,
|
|
8
|
+
statSync,
|
|
8
9
|
writeFileSync,
|
|
9
10
|
} from "fs";
|
|
10
11
|
import { platform } from "os";
|
|
@@ -12,6 +13,7 @@ import { dirname, join } from "path";
|
|
|
12
13
|
|
|
13
14
|
import { getConfigDir } from "./environments/paths.js";
|
|
14
15
|
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
16
|
+
import { SEEDS } from "./environments/seeds.js";
|
|
15
17
|
|
|
16
18
|
const DEVICE_ID_SALT = "vellum-assistant-host-id";
|
|
17
19
|
|
|
@@ -200,3 +202,64 @@ export async function leaseGuardianToken(
|
|
|
200
202
|
saveGuardianToken(assistantId, tokenData);
|
|
201
203
|
return tokenData;
|
|
202
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,10 +305,26 @@ 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.
|