@vellumai/cli 0.8.6 → 0.8.7-dev.202606052118.34cd356

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.
Files changed (79) hide show
  1. package/bun.lock +8 -0
  2. package/knip.json +5 -1
  3. package/node_modules/@vellumai/environments/bun.lock +24 -0
  4. package/node_modules/@vellumai/environments/package.json +18 -0
  5. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
  6. package/node_modules/@vellumai/environments/src/index.ts +11 -0
  7. package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
  8. package/node_modules/@vellumai/environments/tsconfig.json +20 -0
  9. package/node_modules/@vellumai/local-mode/bun.lock +29 -0
  10. package/node_modules/@vellumai/local-mode/package.json +22 -0
  11. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
  12. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
  13. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +108 -0
  14. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
  15. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
  16. package/node_modules/@vellumai/local-mode/src/config.ts +66 -0
  17. package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
  18. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +109 -0
  19. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
  20. package/node_modules/@vellumai/local-mode/src/hatch.ts +92 -0
  21. package/node_modules/@vellumai/local-mode/src/index.ts +48 -0
  22. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
  23. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
  24. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
  25. package/node_modules/@vellumai/local-mode/src/lockfile.ts +133 -0
  26. package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
  27. package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
  28. package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
  29. package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
  30. package/package.json +12 -1
  31. package/src/__tests__/assistant-client-refresh.test.ts +182 -0
  32. package/src/__tests__/clean.test.ts +179 -0
  33. package/src/__tests__/client-token.test.ts +87 -0
  34. package/src/__tests__/client-tui-refresh.test.ts +170 -0
  35. package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
  36. package/src/__tests__/connect-import.test.ts +317 -0
  37. package/src/__tests__/devices.test.ts +272 -0
  38. package/src/__tests__/env-drift.test.ts +32 -44
  39. package/src/__tests__/flags.test.ts +248 -0
  40. package/src/__tests__/guardian-token.test.ts +126 -2
  41. package/src/__tests__/multi-local.test.ts +1 -1
  42. package/src/__tests__/orphan-detection.test.ts +8 -6
  43. package/src/__tests__/pair.test.ts +271 -0
  44. package/src/__tests__/paired-lifecycle.test.ts +116 -0
  45. package/src/__tests__/segments-to-plain-text.test.ts +37 -0
  46. package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
  47. package/src/__tests__/unpair.test.ts +163 -0
  48. package/src/commands/client.ts +511 -11
  49. package/src/commands/connect/import.ts +217 -0
  50. package/src/commands/connect.ts +31 -0
  51. package/src/commands/devices.ts +247 -0
  52. package/src/commands/env.ts +1 -1
  53. package/src/commands/flags.ts +89 -17
  54. package/src/commands/pair.ts +222 -0
  55. package/src/commands/ps.ts +16 -0
  56. package/src/commands/retire.ts +20 -47
  57. package/src/commands/sleep.ts +7 -0
  58. package/src/commands/tunnel.ts +46 -2
  59. package/src/commands/unpair.ts +118 -0
  60. package/src/commands/wake.ts +7 -0
  61. package/src/components/DefaultMainScreen.tsx +100 -14
  62. package/src/index.ts +16 -0
  63. package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
  64. package/src/lib/assistant-client.ts +58 -37
  65. package/src/lib/assistant-config.ts +15 -3
  66. package/src/lib/cloudflare-tunnel.ts +276 -0
  67. package/src/lib/confirm-action.ts +57 -0
  68. package/src/lib/docker.ts +25 -1
  69. package/src/lib/environments/__tests__/paths.test.ts +2 -1
  70. package/src/lib/environments/__tests__/seeds.test.ts +2 -1
  71. package/src/lib/environments/paths.ts +1 -1
  72. package/src/lib/environments/resolve.ts +11 -35
  73. package/src/lib/guardian-token.ts +132 -9
  74. package/src/lib/hatch-local.ts +73 -33
  75. package/src/lib/lifecycle-reporter.ts +31 -0
  76. package/src/lib/local.ts +20 -6
  77. package/src/lib/retire-local.ts +28 -14
  78. package/src/lib/segments-to-plain-text.ts +35 -0
  79. /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Package boundary tests for @vellumai/local-mode.
3
+ *
4
+ * This package is the shared local-assistant host surface. It sits one layer
5
+ * above @vellumai/environments (its only allowed @vellumai dependency) and
6
+ * uses node builtins for filesystem, child-process, and network work.
7
+ *
8
+ * Enforces that the package:
9
+ * 1. Imports only node builtins, its own relative modules, and
10
+ * `@vellumai/environments` — no other `@vellumai/*` packages and no
11
+ * third-party runtime imports (so any bundler host can inline it).
12
+ * 2. Declares exactly one runtime dependency: `@vellumai/environments`.
13
+ * 3. Is marked `private`.
14
+ */
15
+
16
+ import { describe, expect, test } from "bun:test";
17
+ import { readFileSync, readdirSync, statSync } from "node:fs";
18
+ import { join, resolve } from "node:path";
19
+
20
+ const PACKAGE_ROOT = resolve(import.meta.dirname, "../..");
21
+ const SRC_DIR = join(PACKAGE_ROOT, "src");
22
+
23
+ const ALLOWED_PACKAGES = new Set(["@vellumai/environments"]);
24
+
25
+ function collectSourceFiles(dir: string): string[] {
26
+ const files: string[] = [];
27
+ for (const entry of readdirSync(dir)) {
28
+ const full = join(dir, entry);
29
+ const stat = statSync(full);
30
+ if (stat.isDirectory()) {
31
+ if (entry === "node_modules" || entry === "__tests__") continue;
32
+ files.push(...collectSourceFiles(full));
33
+ } else if (
34
+ entry.endsWith(".ts") &&
35
+ !entry.endsWith(".test.ts") &&
36
+ !entry.endsWith(".d.ts")
37
+ ) {
38
+ files.push(full);
39
+ }
40
+ }
41
+ return files;
42
+ }
43
+
44
+ /**
45
+ * Matches the module specifier of any `import ... from "<spec>"` /
46
+ * `export ... from "<spec>"` / `require("<spec>")` statement.
47
+ */
48
+ const IMPORT_SPEC = /(?:from|require\s*\(\s*)["']([^"']+)["']/g;
49
+
50
+ /**
51
+ * A specifier is forbidden when it is neither relative, a node builtin, nor
52
+ * one of the explicitly allowed `@vellumai/*` packages.
53
+ */
54
+ function isForbiddenSpecifier(spec: string): boolean {
55
+ if (spec.startsWith(".") || spec.startsWith("/")) return false;
56
+ if (spec.startsWith("node:")) return false;
57
+ if (ALLOWED_PACKAGES.has(spec)) return false;
58
+ return true;
59
+ }
60
+
61
+ describe("package boundary", () => {
62
+ const sourceFiles = collectSourceFiles(SRC_DIR);
63
+
64
+ test("has source files to validate", () => {
65
+ expect(sourceFiles.length).toBeGreaterThan(0);
66
+ });
67
+
68
+ test("imports only node builtins, relative modules, and @vellumai/environments", () => {
69
+ const violations: string[] = [];
70
+
71
+ for (const file of sourceFiles) {
72
+ const lines = readFileSync(file, "utf-8").split("\n");
73
+ for (let i = 0; i < lines.length; i++) {
74
+ for (const match of lines[i]!.matchAll(IMPORT_SPEC)) {
75
+ const spec = match[1]!;
76
+ if (isForbiddenSpecifier(spec)) {
77
+ const relative = file.replace(PACKAGE_ROOT + "/", "");
78
+ violations.push(`${relative}:${i + 1}: ${spec}`);
79
+ }
80
+ }
81
+ }
82
+ }
83
+
84
+ if (violations.length > 0) {
85
+ throw new Error(
86
+ `Found ${violations.length} forbidden import(s) in @vellumai/local-mode:\n` +
87
+ violations.map((v) => ` - ${v}`).join("\n") +
88
+ "\n\n@vellumai/local-mode may import only node builtins, its own\n" +
89
+ "relative modules, and @vellumai/environments. Any other dependency\n" +
90
+ "would break bundler hosts that inline this source-only package.",
91
+ );
92
+ }
93
+ });
94
+
95
+ test("package.json declares it as private with only the @vellumai/environments dependency", () => {
96
+ const pkg = JSON.parse(
97
+ readFileSync(join(PACKAGE_ROOT, "package.json"), "utf-8"),
98
+ );
99
+ expect(pkg.private).toBe(true);
100
+ expect(pkg.dependencies ?? {}).toEqual({
101
+ "@vellumai/environments": "file:../environments",
102
+ });
103
+ });
104
+ });
@@ -0,0 +1,66 @@
1
+ import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test";
2
+ import { EventEmitter } from "node:events";
3
+
4
+ import type { CliInvocation } from "../util";
5
+
6
+ class FakeChild extends EventEmitter {
7
+ stdout = new EventEmitter();
8
+ stderr = new EventEmitter();
9
+ kill = mock(() => true);
10
+ }
11
+
12
+ let lastChild: FakeChild;
13
+ const spawnArgs: Array<[string, string[]]> = [];
14
+ const spawnMock = mock((command: string, args: string[]) => {
15
+ spawnArgs.push([command, args]);
16
+ lastChild = new FakeChild();
17
+ return lastChild;
18
+ });
19
+
20
+ mock.module("node:child_process", () => ({ spawn: spawnMock }));
21
+
22
+ let runWake: typeof import("../wake").runWake;
23
+
24
+ beforeAll(async () => {
25
+ ({ runWake } = await import("../wake"));
26
+ });
27
+
28
+ afterEach(() => {
29
+ spawnArgs.length = 0;
30
+ spawnMock.mockClear();
31
+ });
32
+
33
+ const invocation: CliInvocation = { command: "bun", baseArgs: ["run", "cli"] };
34
+
35
+ describe("runWake", () => {
36
+ test("spawns the CLI wake command for the assistant and resolves ok on exit 0", async () => {
37
+ const pending = runWake(invocation, "asst-42");
38
+ lastChild.emit("close", 0);
39
+
40
+ expect(await pending).toEqual({ ok: true });
41
+ expect(spawnArgs[0]).toEqual(["bun", ["run", "cli", "wake", "asst-42"]]);
42
+ });
43
+
44
+ test("a non-zero exit resolves to a failure carrying the CLI's output", async () => {
45
+ const pending = runWake(invocation, "asst-42");
46
+ lastChild.stderr.emit("data", Buffer.from("no sibling environment to seed from"));
47
+ lastChild.emit("close", 1);
48
+
49
+ expect(await pending).toEqual({
50
+ ok: false,
51
+ status: 500,
52
+ error: "no sibling environment to seed from",
53
+ });
54
+ });
55
+
56
+ test("a spawn failure resolves to a failure rather than rejecting", async () => {
57
+ const pending = runWake(invocation, "asst-42");
58
+ lastChild.emit("error", new Error("ENOENT"));
59
+
60
+ const result = await pending;
61
+ expect(result.ok).toBe(false);
62
+ if (!result.ok) {
63
+ expect(result.error).toContain("ENOENT");
64
+ }
65
+ });
66
+ });
@@ -0,0 +1,66 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
4
+ import { SEEDS } from "@vellumai/environments";
5
+
6
+ import { resolveEnvironmentName } from "./environment";
7
+
8
+ const PRODUCTION_ENVIRONMENT_NAME = "production";
9
+
10
+ export interface LocalEndpointConfig {
11
+ lockfilePaths: string[];
12
+ configDir: string;
13
+ webUrl: string;
14
+ platformUrl: string;
15
+ }
16
+
17
+ /**
18
+ * Resolve config from environment variables. Accepts any environment-shaped
19
+ * map, including `process.env` (whose values are `string | undefined`) and the
20
+ * Vite plugin's `loadEnv` result.
21
+ */
22
+ export function resolveLocalConfigFromEnv(
23
+ env: Record<string, string | undefined>,
24
+ ): LocalEndpointConfig {
25
+ const vellumEnv = resolveEnvironmentName(env);
26
+ const seed = SEEDS[vellumEnv] ?? SEEDS[PRODUCTION_ENVIRONMENT_NAME]!;
27
+
28
+ return {
29
+ lockfilePaths: resolveLockfilePaths(env),
30
+ configDir: resolveConfigDir(env),
31
+ webUrl: env.VELLUM_WEB_URL || seed.webUrl,
32
+ platformUrl: env.VELLUM_PLATFORM_URL || seed.platformUrl,
33
+ };
34
+ }
35
+
36
+ export function resolveLockfilePaths(
37
+ env: Record<string, string | undefined>,
38
+ ): string[] {
39
+ const vellumEnv = resolveEnvironmentName(env);
40
+ const lockfileDir = env.VELLUM_LOCKFILE_DIR;
41
+
42
+ if (vellumEnv === PRODUCTION_ENVIRONMENT_NAME) {
43
+ const dir = lockfileDir ?? os.homedir();
44
+ return [
45
+ path.join(dir, ".vellum.lock.json"),
46
+ path.join(dir, ".vellum.lockfile.json"),
47
+ ];
48
+ }
49
+
50
+ const xdgConfigHome =
51
+ env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
52
+ const dir = lockfileDir ?? path.join(xdgConfigHome, `vellum-${vellumEnv}`);
53
+ return [path.join(dir, "lockfile.json")];
54
+ }
55
+
56
+ export function resolveConfigDir(
57
+ env: Record<string, string | undefined>,
58
+ ): string {
59
+ const vellumEnv = resolveEnvironmentName(env);
60
+ const xdgConfigHome =
61
+ env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
62
+ if (vellumEnv === PRODUCTION_ENVIRONMENT_NAME) {
63
+ return path.join(xdgConfigHome, "vellum");
64
+ }
65
+ return path.join(xdgConfigHome, `vellum-${vellumEnv}`);
66
+ }
@@ -0,0 +1,62 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ const PRODUCTION_ENVIRONMENT_NAME = "production";
6
+
7
+ /**
8
+ * Location of the persisted default-environment file, written by
9
+ * `vellum env set`. It lives at a fixed, environment-agnostic path so it can
10
+ * be read before the environment is known. Honors `XDG_CONFIG_HOME`, falling
11
+ * back to `~/.config`.
12
+ */
13
+ export function defaultEnvironmentFilePath(
14
+ env: Record<string, string | undefined>,
15
+ ): string {
16
+ const xdgConfigHome =
17
+ env.XDG_CONFIG_HOME?.trim() || path.join(os.homedir(), ".config");
18
+ return path.join(xdgConfigHome, "vellum", "environment");
19
+ }
20
+
21
+ /**
22
+ * Read the persisted default environment name, or `undefined` when no default
23
+ * has been set (no file, empty file, or unreadable).
24
+ */
25
+ export function readDefaultEnvironment(
26
+ env: Record<string, string | undefined>,
27
+ ): string | undefined {
28
+ const filePath = defaultEnvironmentFilePath(env);
29
+ try {
30
+ if (!existsSync(filePath)) return undefined;
31
+ const content = readFileSync(filePath, "utf-8").trim();
32
+ return content.length > 0 ? content : undefined;
33
+ } catch {
34
+ return undefined;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Resolve the active environment name from a single source of truth so every
40
+ * local-mode host — the CLI `client` server, the web app's Vite dev
41
+ * middleware, and the Electron main process — reads and writes the same
42
+ * lockfile and config directory.
43
+ *
44
+ * Resolution order matches the CLI's `resolveEnvironmentSource`:
45
+ * 1. `VELLUM_ENVIRONMENT` (explicit override, also inherited by the
46
+ * hatch/retire child processes the CLI spawns)
47
+ * 2. the persisted default written by `vellum env set`
48
+ * 3. `production`
49
+ *
50
+ * Resolving the persisted default here — rather than only inspecting
51
+ * `VELLUM_ENVIRONMENT` — is what keeps a host that did not go through the CLI's
52
+ * resolver (the Electron main) pointed at the same environment the CLI uses.
53
+ */
54
+ export function resolveEnvironmentName(
55
+ env: Record<string, string | undefined>,
56
+ ): string {
57
+ return (
58
+ env.VELLUM_ENVIRONMENT?.trim() ||
59
+ readDefaultEnvironment(env) ||
60
+ PRODUCTION_ENVIRONMENT_NAME
61
+ );
62
+ }
@@ -0,0 +1,109 @@
1
+ import fs from "node:fs";
2
+
3
+ const GATEWAY_PATTERN = /^(?:\/assistant)?\/__gateway\/(\d+)(\/.*)?$/;
4
+
5
+ export interface GatewayTarget {
6
+ port: number;
7
+ path: string;
8
+ }
9
+
10
+ export type GatewayParseResult =
11
+ | { match: true; valid: true; target: GatewayTarget }
12
+ | { match: true; valid: false }
13
+ | { match: false };
14
+
15
+ export function parseGatewayUrl(pathname: string): GatewayParseResult {
16
+ const match = pathname.match(GATEWAY_PATTERN);
17
+ if (!match) return { match: false };
18
+
19
+ const port = parseInt(match[1]!, 10);
20
+ if (port < 1024 || port > 65535) return { match: true, valid: false };
21
+
22
+ return { match: true, valid: true, target: { port, path: match[2] || "/" } };
23
+ }
24
+
25
+ /**
26
+ * Verdict for a gateway-proxy URL, combining the URL parse with the
27
+ * lockfile port-allowlist check into one decision a host can act on
28
+ * without re-deriving the rules.
29
+ *
30
+ * - `pass` — not a gateway URL; the host serves it normally.
31
+ * - `invalid-port` — a gateway URL whose port is outside 1024–65535.
32
+ * - `forbidden-port`— a well-formed gateway URL for a port that isn't
33
+ * registered in the lockfile (the security
34
+ * boundary: the proxy only reaches gateway ports
35
+ * the user actually hatched, never arbitrary
36
+ * loopback services).
37
+ * - `forward` — forward to `127.0.0.1:{port}{path}`.
38
+ */
39
+ export type GatewayProxyDecision =
40
+ | { kind: "pass" }
41
+ | { kind: "invalid-port" }
42
+ | { kind: "forbidden-port"; port: number }
43
+ | { kind: "forward"; target: GatewayTarget };
44
+
45
+ /**
46
+ * Resolve a request pathname to a gateway-proxy verdict. Identical across every
47
+ * host that proxies the data plane (the Vite dev middleware and the Electron
48
+ * `app://` protocol handler).
49
+ *
50
+ * `getAllowedPorts` is a thunk (typically `() => readAllowedGatewayPorts(...)`)
51
+ * so the lockfile is read only once a gateway URL is matched — the hot path of
52
+ * static-asset and non-gateway requests never touches disk.
53
+ */
54
+ export function resolveGatewayProxyTarget(
55
+ pathname: string,
56
+ getAllowedPorts: () => Set<number>,
57
+ ): GatewayProxyDecision {
58
+ const parsed = parseGatewayUrl(pathname);
59
+ if (!parsed.match) return { kind: "pass" };
60
+ if (!parsed.valid) return { kind: "invalid-port" };
61
+ if (!getAllowedPorts().has(parsed.target.port)) {
62
+ return { kind: "forbidden-port", port: parsed.target.port };
63
+ }
64
+ return { kind: "forward", target: parsed.target };
65
+ }
66
+
67
+ function addPortFromUrl(url: unknown, ports: Set<number>): void {
68
+ if (typeof url !== "string") return;
69
+ try {
70
+ const parsed = new URL(url);
71
+ if (parsed.hostname !== "127.0.0.1" && parsed.hostname !== "localhost") return;
72
+ const port = Number(parsed.port);
73
+ if (Number.isInteger(port) && port >= 1024 && port <= 65535) {
74
+ ports.add(port);
75
+ }
76
+ } catch {
77
+ // malformed URL — skip
78
+ }
79
+ }
80
+
81
+ export function readAllowedGatewayPorts(lockfilePaths: string[]): Set<number> {
82
+ const ports = new Set<number>();
83
+ for (const candidate of lockfilePaths) {
84
+ try {
85
+ const raw = fs.readFileSync(candidate, "utf-8");
86
+ const data = JSON.parse(raw) as {
87
+ assistants?: Array<{
88
+ gatewayUrl?: unknown;
89
+ localUrl?: unknown;
90
+ resources?: { gatewayPort?: unknown };
91
+ }>;
92
+ };
93
+ const assistants = Array.isArray(data.assistants) ? data.assistants : [];
94
+ for (const assistant of assistants) {
95
+ if (!assistant) continue;
96
+ addPortFromUrl(assistant.gatewayUrl, ports);
97
+ addPortFromUrl(assistant.localUrl, ports);
98
+ const gp = assistant.resources?.gatewayPort;
99
+ if (typeof gp === "number" && Number.isInteger(gp) && gp >= 1024 && gp <= 65535) {
100
+ ports.add(gp);
101
+ }
102
+ }
103
+ if (ports.size > 0) return ports;
104
+ } catch (err: unknown) {
105
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") return new Set<number>();
106
+ }
107
+ }
108
+ return ports;
109
+ }
@@ -0,0 +1,122 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import type { CliInvocation } from "./util";
6
+
7
+ const GUARDIAN_TOKEN_REFRESH_TIMEOUT_MS = 15_000;
8
+
9
+ interface GuardianTokenData {
10
+ accessToken: string;
11
+ accessTokenExpiresAt: string | number;
12
+ refreshToken: string;
13
+ refreshTokenExpiresAt: string | number;
14
+ }
15
+
16
+ function isAccessTokenExpired(data: GuardianTokenData): boolean {
17
+ const expiresAt = new Date(data.accessTokenExpiresAt).getTime();
18
+ if (!Number.isFinite(expiresAt)) return true;
19
+ return Date.now() >= expiresAt - 60_000;
20
+ }
21
+
22
+ function isRefreshTokenExpired(data: GuardianTokenData): boolean {
23
+ const expiresAt = new Date(data.refreshTokenExpiresAt).getTime();
24
+ if (!Number.isFinite(expiresAt)) return true;
25
+ return Date.now() >= expiresAt;
26
+ }
27
+
28
+ export type TokenResult =
29
+ | { ok: true; accessToken: string }
30
+ | { ok: false; status: number; error: string };
31
+
32
+ export function getGuardianAccessToken(
33
+ assistantId: string,
34
+ configDir: string,
35
+ invocation: CliInvocation,
36
+ isLoopback: boolean,
37
+ env?: Record<string, string>,
38
+ ): Promise<TokenResult> {
39
+ if (!isLoopback) {
40
+ return Promise.resolve({ ok: false, status: 403, error: "Forbidden" });
41
+ }
42
+
43
+ const tokenPath = path.join(configDir, "assistants", assistantId, "guardian-token.json");
44
+
45
+ let raw: string;
46
+ try {
47
+ raw = fs.readFileSync(tokenPath, "utf-8");
48
+ } catch {
49
+ return Promise.resolve({ ok: false, status: 404, error: "Guardian token not found" });
50
+ }
51
+
52
+ let data: GuardianTokenData;
53
+ try {
54
+ data = JSON.parse(raw) as GuardianTokenData;
55
+ } catch {
56
+ return Promise.resolve({ ok: false, status: 500, error: "Malformed guardian token file" });
57
+ }
58
+
59
+ if (!isAccessTokenExpired(data)) {
60
+ return Promise.resolve({ ok: true, accessToken: data.accessToken });
61
+ }
62
+
63
+ if (isRefreshTokenExpired(data)) {
64
+ return Promise.resolve({
65
+ ok: false,
66
+ status: 401,
67
+ error: "Guardian token expired — re-run `vellum hatch` or `vellum wake`",
68
+ });
69
+ }
70
+
71
+ return refreshToken(assistantId, invocation, env);
72
+ }
73
+
74
+ function refreshToken(
75
+ assistantId: string,
76
+ invocation: CliInvocation,
77
+ env?: Record<string, string>,
78
+ ): Promise<TokenResult> {
79
+ return new Promise((resolve) => {
80
+ const child = spawn(
81
+ invocation.command,
82
+ [...invocation.baseArgs, "gateway", "token", "refresh", assistantId],
83
+ { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...env } },
84
+ );
85
+
86
+ let stdout = "";
87
+ let done = false;
88
+
89
+ const finish = (result: TokenResult) => {
90
+ if (done) return;
91
+ done = true;
92
+ clearTimeout(timeout);
93
+ resolve(result);
94
+ };
95
+
96
+ const timeout = setTimeout(() => {
97
+ child.kill("SIGTERM");
98
+ finish({ ok: false, status: 500, error: "Guardian token refresh timed out" });
99
+ }, GUARDIAN_TOKEN_REFRESH_TIMEOUT_MS);
100
+
101
+ child.stdout.on("data", (chunk: Buffer) => {
102
+ stdout += chunk.toString();
103
+ });
104
+
105
+ child.on("close", (code) => {
106
+ if (code === 0) {
107
+ const accessToken = stdout.trim();
108
+ if (accessToken) {
109
+ finish({ ok: true, accessToken });
110
+ } else {
111
+ finish({ ok: false, status: 500, error: "CLI returned empty token" });
112
+ }
113
+ } else {
114
+ finish({ ok: false, status: 401, error: "Failed to refresh guardian token" });
115
+ }
116
+ });
117
+
118
+ child.on("error", (err) => {
119
+ finish({ ok: false, status: 500, error: `Failed to spawn CLI: ${err.message}` });
120
+ });
121
+ });
122
+ }
@@ -0,0 +1,92 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ import type { CliInvocation } from "./util";
4
+
5
+ const HATCH_TIMEOUT_MS = 120_000;
6
+ // Docker hatches pull images and wait up to 5 min for container readiness.
7
+ const DOCKER_HATCH_TIMEOUT_MS = 10 * 60 * 1000;
8
+
9
+ export type HatchResult =
10
+ | { ok: true; assistantId: string }
11
+ | { ok: false; status: number; error: string };
12
+
13
+ export interface RunHatchOptions {
14
+ remote?: string;
15
+ }
16
+
17
+ export function runHatch(
18
+ invocation: CliInvocation,
19
+ species: string,
20
+ options?: RunHatchOptions,
21
+ ): Promise<HatchResult> {
22
+ return new Promise((resolve) => {
23
+ const args = [...invocation.baseArgs, "hatch", species];
24
+ if (options?.remote) {
25
+ args.push("--remote", options.remote);
26
+ }
27
+
28
+ const child = spawn(
29
+ invocation.command,
30
+ args,
31
+ { stdio: ["ignore", "pipe", "pipe"] },
32
+ );
33
+
34
+ let stdout = "";
35
+ let stderr = "";
36
+ let done = false;
37
+
38
+ const finish = (result: HatchResult) => {
39
+ if (done) return;
40
+ done = true;
41
+ clearTimeout(timeout);
42
+ resolve(result);
43
+ };
44
+
45
+ const timeoutMs =
46
+ options?.remote === "docker" ? DOCKER_HATCH_TIMEOUT_MS : HATCH_TIMEOUT_MS;
47
+ const timeout = setTimeout(() => {
48
+ child.kill("SIGTERM");
49
+ finish({
50
+ ok: false,
51
+ status: 500,
52
+ error: `Hatch timed out after ${timeoutMs / 1000} seconds`,
53
+ });
54
+ }, timeoutMs);
55
+
56
+ child.stdout.on("data", (data: Buffer) => {
57
+ stdout += data.toString();
58
+ });
59
+
60
+ child.stderr.on("data", (data: Buffer) => {
61
+ stderr += data.toString();
62
+ });
63
+
64
+ child.on("close", (code) => {
65
+ if (code !== 0) {
66
+ const error =
67
+ stderr.trim() ||
68
+ stdout.trim() ||
69
+ `Hatch failed: the CLI exited with code ${code ?? "unknown"} and produced no output.`;
70
+ finish({ ok: false, status: 500, error });
71
+ return;
72
+ }
73
+ const assistantId = stdout
74
+ .match(/Hatching (?:local|Docker) assistant:\s+(.+)/)?.[1]
75
+ ?.trim();
76
+ if (!assistantId) {
77
+ finish({
78
+ ok: false,
79
+ status: 500,
80
+ error:
81
+ "Hatch reported success but no assistant id was found in the CLI output.",
82
+ });
83
+ return;
84
+ }
85
+ finish({ ok: true, assistantId });
86
+ });
87
+
88
+ child.on("error", (err) => {
89
+ finish({ ok: false, status: 500, error: `Failed to spawn CLI: ${err.message}` });
90
+ });
91
+ });
92
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @vellumai/local-mode — shared host library for serving the local-assistant
3
+ * surface (lockfile reads, guardian-token issuance, gateway proxying, and the
4
+ * hatch/retire/wake lifecycle ops) over a loopback HTTP boundary. Consumed by the
5
+ * CLI `client` server and the web app's dev-server middleware so the local
6
+ * endpoint behaviour is defined exactly once instead of one host reaching into
7
+ * another's source tree. Depends only on `@vellumai/environments`.
8
+ */
9
+ export {
10
+ stripSensitiveFields,
11
+ isLoopbackAddr,
12
+ resolveDevCliInvocation,
13
+ } from "./util";
14
+ export type { CliInvocation } from "./util";
15
+ export { resolveLocalConfigFromEnv, resolveLockfilePaths, resolveConfigDir } from "./config";
16
+ export type { LocalEndpointConfig } from "./config";
17
+ export { defaultEnvironmentFilePath, readDefaultEnvironment, resolveEnvironmentName } from "./environment";
18
+ export {
19
+ getLockfileData,
20
+ upsertLockfileAssistant,
21
+ replacePlatformAssistants,
22
+ } from "./lockfile";
23
+ export type { LockfileResult, WriteResult } from "./lockfile";
24
+ export { parseLockfile } from "./lockfile-contract";
25
+ export type {
26
+ Lockfile,
27
+ LockfileAssistant,
28
+ LocalAssistantResources,
29
+ LockfileWriteResult,
30
+ } from "./lockfile-contract";
31
+ export { runHatch } from "./hatch";
32
+ export type { HatchResult } from "./hatch";
33
+ export { runRetire } from "./retire";
34
+ export type { RetireResult } from "./retire";
35
+ export { runWake } from "./wake";
36
+ export type { WakeResult } from "./wake";
37
+ export { getGuardianAccessToken } from "./guardian-token";
38
+ export type { TokenResult } from "./guardian-token";
39
+ export {
40
+ parseGatewayUrl,
41
+ readAllowedGatewayPorts,
42
+ resolveGatewayProxyTarget,
43
+ } from "./gateway-proxy";
44
+ export type {
45
+ GatewayTarget,
46
+ GatewayParseResult,
47
+ GatewayProxyDecision,
48
+ } from "./gateway-proxy";