@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.
- package/bun.lock +8 -0
- package/knip.json +5 -1
- package/node_modules/@vellumai/environments/bun.lock +24 -0
- package/node_modules/@vellumai/environments/package.json +18 -0
- package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
- package/node_modules/@vellumai/environments/src/index.ts +11 -0
- package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
- package/node_modules/@vellumai/environments/tsconfig.json +20 -0
- package/node_modules/@vellumai/local-mode/bun.lock +29 -0
- package/node_modules/@vellumai/local-mode/package.json +22 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +108 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +66 -0
- package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +109 -0
- package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
- package/node_modules/@vellumai/local-mode/src/hatch.ts +92 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +48 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +133 -0
- package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
- package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
- package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
- package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
- package/package.json +12 -1
- package/src/__tests__/assistant-client-refresh.test.ts +182 -0
- package/src/__tests__/clean.test.ts +179 -0
- package/src/__tests__/client-token.test.ts +87 -0
- package/src/__tests__/client-tui-refresh.test.ts +170 -0
- package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
- package/src/__tests__/connect-import.test.ts +317 -0
- package/src/__tests__/devices.test.ts +272 -0
- package/src/__tests__/env-drift.test.ts +32 -44
- package/src/__tests__/flags.test.ts +248 -0
- package/src/__tests__/guardian-token.test.ts +126 -2
- package/src/__tests__/multi-local.test.ts +1 -1
- package/src/__tests__/orphan-detection.test.ts +8 -6
- package/src/__tests__/pair.test.ts +271 -0
- package/src/__tests__/paired-lifecycle.test.ts +116 -0
- package/src/__tests__/segments-to-plain-text.test.ts +37 -0
- package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
- package/src/__tests__/unpair.test.ts +163 -0
- package/src/commands/client.ts +511 -11
- package/src/commands/connect/import.ts +217 -0
- package/src/commands/connect.ts +31 -0
- package/src/commands/devices.ts +247 -0
- package/src/commands/env.ts +1 -1
- package/src/commands/flags.ts +89 -17
- package/src/commands/pair.ts +222 -0
- package/src/commands/ps.ts +16 -0
- package/src/commands/retire.ts +20 -47
- package/src/commands/sleep.ts +7 -0
- package/src/commands/tunnel.ts +46 -2
- package/src/commands/unpair.ts +118 -0
- package/src/commands/wake.ts +7 -0
- package/src/components/DefaultMainScreen.tsx +100 -14
- package/src/index.ts +16 -0
- package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
- package/src/lib/assistant-client.ts +58 -37
- package/src/lib/assistant-config.ts +15 -3
- package/src/lib/cloudflare-tunnel.ts +276 -0
- package/src/lib/confirm-action.ts +57 -0
- package/src/lib/docker.ts +25 -1
- package/src/lib/environments/__tests__/paths.test.ts +2 -1
- package/src/lib/environments/__tests__/seeds.test.ts +2 -1
- package/src/lib/environments/paths.ts +1 -1
- package/src/lib/environments/resolve.ts +11 -35
- package/src/lib/guardian-token.ts +132 -9
- package/src/lib/hatch-local.ts +73 -33
- package/src/lib/lifecycle-reporter.ts +31 -0
- package/src/lib/local.ts +20 -6
- package/src/lib/retire-local.ts +28 -14
- package/src/lib/segments-to-plain-text.ts +35 -0
- /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { execFileSync, spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { GATEWAY_PORT } from "./constants.js";
|
|
7
|
+
|
|
8
|
+
// ── Workspace config helpers (mirrors the pattern in ngrok.ts) ───────────────
|
|
9
|
+
|
|
10
|
+
function getDefaultWorkspaceDir(): string {
|
|
11
|
+
return (
|
|
12
|
+
process.env.VELLUM_WORKSPACE_DIR?.trim() ||
|
|
13
|
+
join(homedir(), ".vellum", "workspace")
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getConfigPath(workspaceDir: string): string {
|
|
18
|
+
return join(workspaceDir, "config.json");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function loadRawConfig(workspaceDir: string): Record<string, unknown> {
|
|
22
|
+
const configPath = getConfigPath(workspaceDir);
|
|
23
|
+
if (!existsSync(configPath)) return {};
|
|
24
|
+
return JSON.parse(readFileSync(configPath, "utf-8")) as Record<
|
|
25
|
+
string,
|
|
26
|
+
unknown
|
|
27
|
+
>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function saveRawConfig(
|
|
31
|
+
workspaceDir: string,
|
|
32
|
+
config: Record<string, unknown>,
|
|
33
|
+
): void {
|
|
34
|
+
const configPath = getConfigPath(workspaceDir);
|
|
35
|
+
const dir = dirname(configPath);
|
|
36
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
37
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function saveIngressUrl(workspaceDir: string, publicUrl: string): void {
|
|
41
|
+
const config = loadRawConfig(workspaceDir);
|
|
42
|
+
const ingress = (config.ingress ?? {}) as Record<string, unknown>;
|
|
43
|
+
ingress.publicBaseUrl = publicUrl;
|
|
44
|
+
ingress.enabled = true;
|
|
45
|
+
config.ingress = ingress;
|
|
46
|
+
saveRawConfig(workspaceDir, config);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function clearIngressUrl(workspaceDir: string): void {
|
|
50
|
+
const config = loadRawConfig(workspaceDir);
|
|
51
|
+
const ingress = (config.ingress ?? {}) as Record<string, unknown>;
|
|
52
|
+
delete ingress.publicBaseUrl;
|
|
53
|
+
config.ingress = ingress;
|
|
54
|
+
saveRawConfig(workspaceDir, config);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Cloudflare Tunnel ─────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
const CLOUDFLARED_TIMEOUT_MS = 30_000;
|
|
60
|
+
|
|
61
|
+
// Quick-tunnel hostnames follow the pattern <word>-<word>-<word>.trycloudflare.com
|
|
62
|
+
const QUICK_TUNNEL_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check whether cloudflared is installed and on PATH.
|
|
66
|
+
* Returns the version string if found, null otherwise.
|
|
67
|
+
*/
|
|
68
|
+
export function getCloudflareTunnelVersion(): string | null {
|
|
69
|
+
try {
|
|
70
|
+
const output = execFileSync("cloudflared", ["version"], {
|
|
71
|
+
encoding: "utf-8",
|
|
72
|
+
timeout: 5_000,
|
|
73
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
74
|
+
});
|
|
75
|
+
return output.trim();
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Spawn a cloudflared quick-tunnel process forwarding HTTP traffic to
|
|
83
|
+
* `targetPort`. The child process writes its public URL to stderr during
|
|
84
|
+
* startup — use {@link waitForCloudflareTunnelUrl} to extract it.
|
|
85
|
+
*/
|
|
86
|
+
export function startCloudflareTunnelProcess(targetPort: number): ChildProcess {
|
|
87
|
+
return spawn(
|
|
88
|
+
"cloudflared",
|
|
89
|
+
["tunnel", "--url", `http://localhost:${targetPort}`, "--no-autoupdate"],
|
|
90
|
+
// Keep stdio as pipes so we can parse the URL from output.
|
|
91
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Listen to a running cloudflared process's stdout/stderr and resolve with
|
|
97
|
+
* the public quick-tunnel URL once cloudflared prints it.
|
|
98
|
+
*
|
|
99
|
+
* cloudflared emits a line containing the trycloudflare.com URL during
|
|
100
|
+
* startup — typically within 5–15 seconds on a normal internet connection.
|
|
101
|
+
*
|
|
102
|
+
* Rejects when:
|
|
103
|
+
* - The URL does not appear within `timeoutMs`.
|
|
104
|
+
* - The child process exits before the URL is found.
|
|
105
|
+
*/
|
|
106
|
+
export function waitForCloudflareTunnelUrl(
|
|
107
|
+
child: ChildProcess,
|
|
108
|
+
timeoutMs: number = CLOUDFLARED_TIMEOUT_MS,
|
|
109
|
+
): Promise<string> {
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
const timer = setTimeout(() => {
|
|
112
|
+
reject(
|
|
113
|
+
new Error(
|
|
114
|
+
`cloudflared tunnel URL did not appear within ${timeoutMs / 1000}s. ` +
|
|
115
|
+
`Ensure cloudflared is working: try running 'cloudflared tunnel --url http://localhost:8080' manually.`,
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
}, timeoutMs);
|
|
119
|
+
|
|
120
|
+
let resolved = false;
|
|
121
|
+
|
|
122
|
+
function scanLine(line: string): void {
|
|
123
|
+
if (resolved) return;
|
|
124
|
+
const match = QUICK_TUNNEL_URL_RE.exec(line);
|
|
125
|
+
if (match) {
|
|
126
|
+
resolved = true;
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
resolve(match[0]);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Buffer incomplete lines across chunks
|
|
133
|
+
let stdoutBuf = "";
|
|
134
|
+
let stderrBuf = "";
|
|
135
|
+
|
|
136
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
137
|
+
stdoutBuf += chunk.toString();
|
|
138
|
+
const lines = stdoutBuf.split("\n");
|
|
139
|
+
stdoutBuf = lines.pop() ?? "";
|
|
140
|
+
for (const line of lines) scanLine(line);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
144
|
+
stderrBuf += chunk.toString();
|
|
145
|
+
const lines = stderrBuf.split("\n");
|
|
146
|
+
stderrBuf = lines.pop() ?? "";
|
|
147
|
+
for (const line of lines) scanLine(line);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
child.on("exit", (code) => {
|
|
151
|
+
if (resolved) return;
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
reject(
|
|
154
|
+
new Error(
|
|
155
|
+
`cloudflared exited with code ${code ?? "unknown"} before the tunnel URL appeared.`,
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Run the cloudflared quick-tunnel workflow:
|
|
164
|
+
* 1. Verify cloudflared is installed.
|
|
165
|
+
* 2. Start a quick tunnel pointing at the gateway port.
|
|
166
|
+
* 3. Parse the public URL from cloudflared output.
|
|
167
|
+
* 4. Persist the URL to the workspace config as the ingress base URL.
|
|
168
|
+
* 5. Block until the process exits or the user presses Ctrl+C.
|
|
169
|
+
* 6. Clear the ingress URL from config on exit.
|
|
170
|
+
*
|
|
171
|
+
* No Cloudflare account is required — quick tunnels are free and ephemeral.
|
|
172
|
+
*/
|
|
173
|
+
export interface RunCloudflareTunnelOptions {
|
|
174
|
+
/** Gateway port to forward. Defaults to the global GATEWAY_PORT. */
|
|
175
|
+
port?: number;
|
|
176
|
+
/** Workspace directory for config read/write. Defaults to ~/.vellum/workspace. */
|
|
177
|
+
workspaceDir?: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function runCloudflareTunnel(
|
|
181
|
+
opts: RunCloudflareTunnelOptions = {},
|
|
182
|
+
): Promise<void> {
|
|
183
|
+
const version = getCloudflareTunnelVersion();
|
|
184
|
+
if (!version) {
|
|
185
|
+
console.error("Error: cloudflared is not installed.");
|
|
186
|
+
console.error("");
|
|
187
|
+
console.error("Install cloudflared:");
|
|
188
|
+
console.error(" macOS: brew install cloudflare/cloudflare/cloudflared");
|
|
189
|
+
console.error(" Linux: https://pkg.cloudflare.com/index.html");
|
|
190
|
+
console.error(
|
|
191
|
+
" Windows: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
|
|
192
|
+
);
|
|
193
|
+
console.error("");
|
|
194
|
+
console.error("No Cloudflare account is required for quick tunnels.");
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
console.log(`Using ${version}`);
|
|
199
|
+
|
|
200
|
+
const port = opts.port ?? GATEWAY_PORT;
|
|
201
|
+
const workspaceDir = opts.workspaceDir ?? getDefaultWorkspaceDir();
|
|
202
|
+
|
|
203
|
+
console.log(`Starting cloudflared quick tunnel to localhost:${port}...`);
|
|
204
|
+
console.log("No Cloudflare account required — quick tunnels are free.");
|
|
205
|
+
console.log("");
|
|
206
|
+
|
|
207
|
+
let publicUrl: string | undefined;
|
|
208
|
+
const child = startCloudflareTunnelProcess(port);
|
|
209
|
+
|
|
210
|
+
const cleanup = (): void => {
|
|
211
|
+
if (!child.killed) child.kill("SIGTERM");
|
|
212
|
+
if (publicUrl) {
|
|
213
|
+
console.log("\nClearing ingress URL from config...");
|
|
214
|
+
clearIngressUrl(workspaceDir);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
process.on("SIGINT", () => {
|
|
219
|
+
cleanup();
|
|
220
|
+
process.exit(0);
|
|
221
|
+
});
|
|
222
|
+
process.on("SIGTERM", () => {
|
|
223
|
+
cleanup();
|
|
224
|
+
process.exit(0);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
child.on("error", (err: Error) => {
|
|
228
|
+
console.error(`cloudflared process error: ${err.message}`);
|
|
229
|
+
process.exit(1);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
child.on("exit", (code) => {
|
|
233
|
+
// Always clear the saved ingress URL when the tunnel process ends so
|
|
234
|
+
// webhook integrations don't keep hitting a dead endpoint.
|
|
235
|
+
if (publicUrl !== undefined) {
|
|
236
|
+
clearIngressUrl(workspaceDir);
|
|
237
|
+
}
|
|
238
|
+
if (code !== null && code !== 0) {
|
|
239
|
+
console.error(`\ncloudflared exited with code ${code}.`);
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Forward cloudflared output to the console so the user can see startup
|
|
245
|
+
// progress and any authentication errors.
|
|
246
|
+
child.stdout?.on("data", (data: Buffer) => {
|
|
247
|
+
const line = data.toString().trim();
|
|
248
|
+
if (line) console.log(`[cloudflared] ${line}`);
|
|
249
|
+
});
|
|
250
|
+
child.stderr?.on("data", (data: Buffer) => {
|
|
251
|
+
const line = data.toString().trim();
|
|
252
|
+
if (line) console.log(`[cloudflared] ${line}`);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
publicUrl = await waitForCloudflareTunnelUrl(child);
|
|
257
|
+
} catch (err) {
|
|
258
|
+
cleanup();
|
|
259
|
+
throw err;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
console.log("");
|
|
263
|
+
console.log(`Tunnel established: ${publicUrl}`);
|
|
264
|
+
console.log(`Forwarding to: localhost:${port}`);
|
|
265
|
+
console.log("");
|
|
266
|
+
|
|
267
|
+
saveIngressUrl(workspaceDir, publicUrl);
|
|
268
|
+
console.log("Ingress URL saved to config.");
|
|
269
|
+
console.log("");
|
|
270
|
+
console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");
|
|
271
|
+
|
|
272
|
+
// Keep running until cloudflared exits (e.g., network error or user Ctrl+C)
|
|
273
|
+
await new Promise<void>((resolve) => {
|
|
274
|
+
child.on("exit", () => resolve());
|
|
275
|
+
});
|
|
276
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared interactive confirmation for destructive CLI commands (retire, unpair,
|
|
3
|
+
* …). Per cli/AGENTS.md, a command that removes assistant state must print the
|
|
4
|
+
* resolved identity and require confirmation, with a `--yes` bypass for
|
|
5
|
+
* automation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** True only when we can run an interactive raw-mode confirmation prompt. */
|
|
9
|
+
export function canPromptForConfirmation(): boolean {
|
|
10
|
+
return (
|
|
11
|
+
process.stdin.isTTY === true &&
|
|
12
|
+
process.stdout.isTTY === true &&
|
|
13
|
+
typeof process.stdin.setRawMode === "function"
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Show `prompt` and resolve true on Enter, false on Esc/q/Ctrl-C. Restores the
|
|
19
|
+
* prior stdin raw/paused state on exit. Caller must gate on
|
|
20
|
+
* {@link canPromptForConfirmation} first.
|
|
21
|
+
*/
|
|
22
|
+
export async function confirmAction(prompt: string): Promise<boolean> {
|
|
23
|
+
const stdin = process.stdin;
|
|
24
|
+
const stdout = process.stdout;
|
|
25
|
+
const wasRaw = stdin.isRaw === true;
|
|
26
|
+
const wasPaused = stdin.isPaused();
|
|
27
|
+
|
|
28
|
+
stdout.write(prompt);
|
|
29
|
+
stdin.setRawMode(true);
|
|
30
|
+
stdin.resume();
|
|
31
|
+
|
|
32
|
+
return await new Promise<boolean>((resolve) => {
|
|
33
|
+
const cleanup = () => {
|
|
34
|
+
stdin.off("data", onData);
|
|
35
|
+
stdin.setRawMode(wasRaw);
|
|
36
|
+
if (wasPaused) {
|
|
37
|
+
stdin.pause();
|
|
38
|
+
}
|
|
39
|
+
stdout.write("\n");
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const onData = (chunk: Buffer) => {
|
|
43
|
+
const byte = chunk[0];
|
|
44
|
+
if (byte === 13 || byte === 10) {
|
|
45
|
+
cleanup();
|
|
46
|
+
resolve(true);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (byte === 27 || byte === 3 || byte === 113 || byte === 81) {
|
|
50
|
+
cleanup();
|
|
51
|
+
resolve(false);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
stdin.on("data", onData);
|
|
56
|
+
});
|
|
57
|
+
}
|
package/src/lib/docker.ts
CHANGED
|
@@ -478,6 +478,17 @@ export async function retireDocker(name: string): Promise<void> {
|
|
|
478
478
|
}
|
|
479
479
|
}
|
|
480
480
|
|
|
481
|
+
// Future: consider stopping Colima VM when no Docker instances remain.
|
|
482
|
+
// Considerations:
|
|
483
|
+
// - Use loadAllAssistantsAcrossEnvs() instead of loadAllAssistants() to
|
|
484
|
+
// avoid stopping Colima while another VELLUM_ENVIRONMENT still has a
|
|
485
|
+
// running Docker instance.
|
|
486
|
+
// - Track whether Vellum started Colima (vs. the user already had it
|
|
487
|
+
// running for non-Vellum workloads) \u2014 e.g. via a dedicated Colima
|
|
488
|
+
// profile (`colima start --profile vellum`) or a sentinel file.
|
|
489
|
+
// - Only stop if both conditions are met: no cross-env Docker instances
|
|
490
|
+
// AND Vellum owns the Colima lifecycle.
|
|
491
|
+
|
|
481
492
|
console.log(`\u2705 Docker instance retired.`);
|
|
482
493
|
}
|
|
483
494
|
|
|
@@ -1137,7 +1148,20 @@ export async function hatchDocker(
|
|
|
1137
1148
|
await loadImageViaHost(HOST_IMAGE_LOADER_URL, ref, log);
|
|
1138
1149
|
} else {
|
|
1139
1150
|
log(` ↪ pulling ${ref}`);
|
|
1140
|
-
|
|
1151
|
+
const MAX_PULL_RETRIES = 3;
|
|
1152
|
+
for (let attempt = 1; attempt <= MAX_PULL_RETRIES; attempt++) {
|
|
1153
|
+
try {
|
|
1154
|
+
await exec("docker", ["pull", ref]);
|
|
1155
|
+
break;
|
|
1156
|
+
} catch (err) {
|
|
1157
|
+
if (attempt === MAX_PULL_RETRIES) throw err;
|
|
1158
|
+
const delaySec = 2 ** attempt;
|
|
1159
|
+
log(
|
|
1160
|
+
` ⚠ pull failed (attempt ${attempt}/${MAX_PULL_RETRIES}), retrying in ${delaySec}s...`,
|
|
1161
|
+
);
|
|
1162
|
+
await new Promise((r) => setTimeout(r, delaySec * 1000));
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1141
1165
|
}
|
|
1142
1166
|
}
|
|
1143
1167
|
log("✅ Docker images acquired");
|
|
@@ -27,7 +27,8 @@ const {
|
|
|
27
27
|
getLockfilePaths,
|
|
28
28
|
getMultiInstanceDir,
|
|
29
29
|
} = await import("../paths.js");
|
|
30
|
-
type EnvironmentDefinition =
|
|
30
|
+
type EnvironmentDefinition =
|
|
31
|
+
import("@vellumai/environments").EnvironmentDefinition;
|
|
31
32
|
|
|
32
33
|
const prod: EnvironmentDefinition = {
|
|
33
34
|
name: "production",
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
+
import { SEEDS } from "@vellumai/environments";
|
|
4
|
+
|
|
3
5
|
import { getDefaultPorts } from "../paths.js";
|
|
4
|
-
import { SEEDS } from "../seeds.js";
|
|
5
6
|
|
|
6
7
|
describe("SEEDS port blocks", () => {
|
|
7
8
|
test("production uses the legacy (pre-MVP) port layout", () => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { homedir } from "os";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
|
|
4
|
-
import type { EnvironmentDefinition, PortMap } from "
|
|
4
|
+
import type { EnvironmentDefinition, PortMap } from "@vellumai/environments";
|
|
5
5
|
|
|
6
6
|
const PRODUCTION_ENVIRONMENT_NAME = "production";
|
|
7
7
|
|
|
@@ -1,49 +1,27 @@
|
|
|
1
|
+
import { mkdirSync, unlinkSync, writeFileSync } from "fs";
|
|
2
|
+
import { dirname } from "path";
|
|
3
|
+
|
|
4
|
+
import { SEEDS, type EnvironmentDefinition } from "@vellumai/environments";
|
|
1
5
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
unlinkSync,
|
|
6
|
-
writeFileSync,
|
|
7
|
-
} from "fs";
|
|
8
|
-
import { homedir } from "os";
|
|
9
|
-
import { dirname, join } from "path";
|
|
10
|
-
|
|
11
|
-
import { SEEDS } from "./seeds.js";
|
|
12
|
-
import type { EnvironmentDefinition } from "./types.js";
|
|
6
|
+
defaultEnvironmentFilePath,
|
|
7
|
+
readDefaultEnvironment as readPersistedDefaultEnvironment,
|
|
8
|
+
} from "@vellumai/local-mode";
|
|
13
9
|
|
|
14
10
|
const DEFAULT_ENVIRONMENT_NAME = "production";
|
|
15
11
|
|
|
16
|
-
/**
|
|
17
|
-
* Path to the user's persisted default environment file.
|
|
18
|
-
* Lives at `~/.config/vellum/environment` — a fixed, environment-agnostic
|
|
19
|
-
* location so it can be read before the environment is resolved.
|
|
20
|
-
*/
|
|
21
|
-
function getDefaultEnvironmentPath(): string {
|
|
22
|
-
const xdgConfig =
|
|
23
|
-
process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
|
|
24
|
-
return join(xdgConfig, "vellum", "environment");
|
|
25
|
-
}
|
|
26
|
-
|
|
27
12
|
/**
|
|
28
13
|
* Read the persisted default environment name, if any.
|
|
29
14
|
* Returns `undefined` if no file exists or the file is empty.
|
|
30
15
|
*/
|
|
31
16
|
export function readDefaultEnvironment(): string | undefined {
|
|
32
|
-
|
|
33
|
-
try {
|
|
34
|
-
if (!existsSync(filePath)) return undefined;
|
|
35
|
-
const content = readFileSync(filePath, "utf-8").trim();
|
|
36
|
-
return content.length > 0 ? content : undefined;
|
|
37
|
-
} catch {
|
|
38
|
-
return undefined;
|
|
39
|
-
}
|
|
17
|
+
return readPersistedDefaultEnvironment(process.env);
|
|
40
18
|
}
|
|
41
19
|
|
|
42
20
|
/**
|
|
43
21
|
* Persist a default environment name to the user config file.
|
|
44
22
|
*/
|
|
45
23
|
export function writeDefaultEnvironment(name: string): void {
|
|
46
|
-
const filePath =
|
|
24
|
+
const filePath = defaultEnvironmentFilePath(process.env);
|
|
47
25
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
48
26
|
writeFileSync(filePath, name + "\n", "utf-8");
|
|
49
27
|
}
|
|
@@ -52,7 +30,7 @@ export function writeDefaultEnvironment(name: string): void {
|
|
|
52
30
|
* Remove the persisted default environment file, falling back to production.
|
|
53
31
|
*/
|
|
54
32
|
export function clearDefaultEnvironment(): void {
|
|
55
|
-
const filePath =
|
|
33
|
+
const filePath = defaultEnvironmentFilePath(process.env);
|
|
56
34
|
try {
|
|
57
35
|
unlinkSync(filePath);
|
|
58
36
|
} catch {
|
|
@@ -115,7 +93,7 @@ export function getCurrentEnvironment(
|
|
|
115
93
|
// writers don't end up in disjoint states on a typo.
|
|
116
94
|
process.stderr.write(
|
|
117
95
|
`warning: unknown environment "${name}"; falling back to "${DEFAULT_ENVIRONMENT_NAME}". ` +
|
|
118
|
-
`Add it to
|
|
96
|
+
`Add it to packages/environments/src/seeds.ts and rebuild if this was intentional.\n`,
|
|
119
97
|
);
|
|
120
98
|
}
|
|
121
99
|
const fallback = SEEDS[DEFAULT_ENVIRONMENT_NAME];
|
|
@@ -174,5 +152,3 @@ export function resolveEnvironmentSource(override?: string): {
|
|
|
174
152
|
}
|
|
175
153
|
return { name: DEFAULT_ENVIRONMENT_NAME, source: "default" };
|
|
176
154
|
}
|
|
177
|
-
|
|
178
|
-
|
|
@@ -2,18 +2,24 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { execSync } from "node:child_process";
|
|
3
3
|
import {
|
|
4
4
|
chmodSync,
|
|
5
|
+
closeSync,
|
|
5
6
|
existsSync,
|
|
6
7
|
mkdirSync,
|
|
8
|
+
openSync,
|
|
7
9
|
readFileSync,
|
|
10
|
+
rmdirSync,
|
|
8
11
|
statSync,
|
|
12
|
+
unlinkSync,
|
|
9
13
|
writeFileSync,
|
|
14
|
+
writeSync,
|
|
10
15
|
} from "fs";
|
|
11
16
|
import { platform } from "os";
|
|
12
17
|
import { dirname, join } from "path";
|
|
13
18
|
|
|
19
|
+
import { SEEDS } from "@vellumai/environments";
|
|
20
|
+
|
|
14
21
|
import { getConfigDir } from "./environments/paths.js";
|
|
15
22
|
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
16
|
-
import { SEEDS } from "./environments/seeds.js";
|
|
17
23
|
|
|
18
24
|
const DEVICE_ID_SALT = "vellum-assistant-host-id";
|
|
19
25
|
|
|
@@ -40,6 +46,27 @@ function getGuardianTokenPath(assistantId: string): string {
|
|
|
40
46
|
);
|
|
41
47
|
}
|
|
42
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Best-effort removal of an assistant's stored guardian token (used by
|
|
51
|
+
* `vellum unpair` to forget a paired connection). Never throws if the token
|
|
52
|
+
* file or its per-assistant directory is already absent.
|
|
53
|
+
*/
|
|
54
|
+
export function deleteGuardianToken(assistantId: string): void {
|
|
55
|
+
const tokenPath = getGuardianTokenPath(assistantId);
|
|
56
|
+
try {
|
|
57
|
+
unlinkSync(tokenPath);
|
|
58
|
+
} catch {
|
|
59
|
+
/* already gone */
|
|
60
|
+
}
|
|
61
|
+
// Clean up the now-empty per-assistant directory; rmdir throws if it still
|
|
62
|
+
// holds other files, in which case we leave it.
|
|
63
|
+
try {
|
|
64
|
+
rmdirSync(dirname(tokenPath));
|
|
65
|
+
} catch {
|
|
66
|
+
/* not empty or absent */
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
43
70
|
function getPersistedDeviceIdPath(): string {
|
|
44
71
|
return join(getConfigDir(getCurrentEnvironment()), "device-id");
|
|
45
72
|
}
|
|
@@ -160,42 +187,136 @@ export function saveGuardianToken(
|
|
|
160
187
|
chmodSync(tokenPath, 0o600);
|
|
161
188
|
}
|
|
162
189
|
|
|
190
|
+
/** Abort the refresh POST if the gateway is slow/unreachable (it's now on the
|
|
191
|
+
* hot request path, so it must never hang indefinitely). */
|
|
192
|
+
const REFRESH_FETCH_TIMEOUT_MS = 15_000;
|
|
193
|
+
/** Max time to wait for the per-assistant refresh lock before proceeding. */
|
|
194
|
+
const REFRESH_LOCK_WAIT_MS = 10_000;
|
|
195
|
+
/** A lock older than this is treated as stale (holder crashed) and stolen. */
|
|
196
|
+
const REFRESH_LOCK_STALE_MS = 30_000;
|
|
197
|
+
const REFRESH_LOCK_POLL_MS = 100;
|
|
198
|
+
|
|
199
|
+
function getRefreshLockPath(assistantId: string): string {
|
|
200
|
+
return join(dirname(getGuardianTokenPath(assistantId)), "refresh.lock");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Best-effort exclusive cross-process lock for a per-assistant token refresh.
|
|
207
|
+
* Created atomically with `wx`; a stale lock (crashed holder) is stolen.
|
|
208
|
+
* Returns true if acquired, false if it timed out (caller proceeds degraded).
|
|
209
|
+
*/
|
|
210
|
+
async function acquireRefreshLock(lockPath: string): Promise<boolean> {
|
|
211
|
+
mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
212
|
+
const deadline = Date.now() + REFRESH_LOCK_WAIT_MS;
|
|
213
|
+
for (;;) {
|
|
214
|
+
try {
|
|
215
|
+
const fd = openSync(lockPath, "wx", 0o600);
|
|
216
|
+
writeSync(fd, String(process.pid));
|
|
217
|
+
closeSync(fd);
|
|
218
|
+
return true;
|
|
219
|
+
} catch (err) {
|
|
220
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") return false;
|
|
221
|
+
try {
|
|
222
|
+
if (Date.now() - statSync(lockPath).mtimeMs > REFRESH_LOCK_STALE_MS) {
|
|
223
|
+
unlinkSync(lockPath); // steal a stale lock, then retry
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
} catch {
|
|
227
|
+
continue; // lock vanished between open and stat — retry
|
|
228
|
+
}
|
|
229
|
+
if (Date.now() >= deadline) return false;
|
|
230
|
+
await delay(REFRESH_LOCK_POLL_MS);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function releaseRefreshLock(lockPath: string): void {
|
|
236
|
+
try {
|
|
237
|
+
unlinkSync(lockPath);
|
|
238
|
+
} catch {
|
|
239
|
+
/* already released/stolen */
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
163
243
|
/**
|
|
164
244
|
* Call POST /v1/guardian/refresh on the remote gateway to obtain a new
|
|
165
245
|
* access token using an existing (possibly expired) access token for auth.
|
|
166
246
|
* Returns the refreshed token data (persisted locally), or null if the
|
|
167
247
|
* refresh fails (e.g. no stored token, or refresh token itself is expired).
|
|
248
|
+
*
|
|
249
|
+
* Concurrency-safe: the gateway rotates refresh tokens and treats reuse of an
|
|
250
|
+
* already-rotated token as replay (revoking the whole token family), so two
|
|
251
|
+
* processes (e.g. `vellum message` + `vellum events`) refreshing the same
|
|
252
|
+
* stored token at once would self-revoke and force re-pairing. We serialize on
|
|
253
|
+
* a per-assistant lock and, once held, re-read the stored token: if another
|
|
254
|
+
* process already rotated it while we waited, we return that fresh token
|
|
255
|
+
* instead of replaying our now-stale refresh token.
|
|
168
256
|
*/
|
|
169
257
|
export async function refreshGuardianToken(
|
|
170
258
|
gatewayUrl: string,
|
|
171
259
|
assistantId: string,
|
|
172
260
|
): Promise<GuardianTokenData | null> {
|
|
173
|
-
const
|
|
174
|
-
if (!
|
|
261
|
+
const before = loadGuardianToken(assistantId);
|
|
262
|
+
if (!before) return null;
|
|
175
263
|
|
|
176
264
|
// Gateway persists expiresAt as epoch-ms numbers; Date.parse("1234567890000")
|
|
177
265
|
// returns NaN. new Date() accepts both ISO strings and epoch-ms numbers.
|
|
178
|
-
const refreshExpiry = new Date(
|
|
179
|
-
if (!Number.isFinite(refreshExpiry) || refreshExpiry <= Date.now())
|
|
266
|
+
const refreshExpiry = new Date(before.refreshTokenExpiresAt).getTime();
|
|
267
|
+
if (!Number.isFinite(refreshExpiry) || refreshExpiry <= Date.now())
|
|
268
|
+
return null;
|
|
180
269
|
|
|
270
|
+
const lockPath = getRefreshLockPath(assistantId);
|
|
271
|
+
const locked = await acquireRefreshLock(lockPath);
|
|
181
272
|
try {
|
|
273
|
+
// Re-read under the lock: a concurrent process may have rotated the token
|
|
274
|
+
// while we waited. If the stored refresh token changed, ours is now stale
|
|
275
|
+
// (replaying it would trip reuse-detection) — use the fresh token instead.
|
|
276
|
+
const current = loadGuardianToken(assistantId);
|
|
277
|
+
if (current && current.refreshToken !== before.refreshToken) {
|
|
278
|
+
return current;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// We did NOT acquire the lock (another process is likely mid-refresh) and
|
|
282
|
+
// the stored token hasn't been rotated yet. Do NOT call the gateway: our
|
|
283
|
+
// refresh token may be the one the winner is rotating right now, and
|
|
284
|
+
// replaying a rotated token revokes the whole family (forcing re-pair).
|
|
285
|
+
// Give up — the caller surfaces the original 401, and the next attempt
|
|
286
|
+
// picks up the winner's persisted token.
|
|
287
|
+
if (!locked) return null;
|
|
288
|
+
|
|
289
|
+
const tokenData = current ?? before;
|
|
290
|
+
|
|
182
291
|
const response = await fetch(`${gatewayUrl}/v1/guardian/refresh`, {
|
|
183
292
|
method: "POST",
|
|
184
293
|
headers: {
|
|
185
294
|
"Content-Type": "application/json",
|
|
186
295
|
Authorization: `Bearer ${tokenData.accessToken}`,
|
|
187
296
|
},
|
|
188
|
-
body: JSON.stringify({
|
|
297
|
+
body: JSON.stringify({
|
|
298
|
+
refreshToken: tokenData.refreshToken,
|
|
299
|
+
// The refresh token is device-bound; send the device id used at init
|
|
300
|
+
// (falling back to a fresh computation for tokens persisted before the
|
|
301
|
+
// field was stored) so the gateway can verify the binding.
|
|
302
|
+
deviceId: tokenData.deviceId || computeDeviceId(),
|
|
303
|
+
}),
|
|
304
|
+
signal: AbortSignal.timeout(REFRESH_FETCH_TIMEOUT_MS),
|
|
189
305
|
});
|
|
190
306
|
if (!response.ok) return null;
|
|
191
307
|
|
|
192
308
|
const json = (await response.json()) as Record<string, unknown>;
|
|
193
309
|
const refreshed: GuardianTokenData = {
|
|
194
|
-
guardianPrincipalId:
|
|
310
|
+
guardianPrincipalId:
|
|
311
|
+
(json.guardianPrincipalId as string) ?? tokenData.guardianPrincipalId,
|
|
195
312
|
accessToken: json.accessToken as string,
|
|
196
|
-
accessTokenExpiresAt:
|
|
313
|
+
accessTokenExpiresAt:
|
|
314
|
+
(json.accessTokenExpiresAt as string | number) ??
|
|
315
|
+
tokenData.accessTokenExpiresAt,
|
|
197
316
|
refreshToken: (json.refreshToken as string) ?? tokenData.refreshToken,
|
|
198
|
-
refreshTokenExpiresAt:
|
|
317
|
+
refreshTokenExpiresAt:
|
|
318
|
+
(json.refreshTokenExpiresAt as string | number) ??
|
|
319
|
+
tokenData.refreshTokenExpiresAt,
|
|
199
320
|
refreshAfter: (json.refreshAfter as string) ?? tokenData.refreshAfter,
|
|
200
321
|
isNew: false,
|
|
201
322
|
deviceId: tokenData.deviceId,
|
|
@@ -205,6 +326,8 @@ export async function refreshGuardianToken(
|
|
|
205
326
|
return refreshed;
|
|
206
327
|
} catch {
|
|
207
328
|
return null;
|
|
329
|
+
} finally {
|
|
330
|
+
if (locked) releaseRefreshLock(lockPath);
|
|
208
331
|
}
|
|
209
332
|
}
|
|
210
333
|
|