@vellumai/cli 0.8.5 → 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/AGENTS.md +6 -0
- package/bun.lock +8 -0
- package/knip.json +6 -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__/backup.test.ts +38 -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__/recover.test.ts +307 -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/__tests__/wake.test.ts +215 -0
- package/src/commands/backup.ts +2 -0
- package/src/commands/client.ts +569 -39
- 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 +269 -0
- package/src/commands/gateway/token.ts +73 -0
- package/src/commands/gateway.ts +29 -0
- package/src/commands/logs.ts +6 -18
- package/src/commands/pair.ts +222 -0
- package/src/commands/ps.ts +57 -41
- package/src/commands/recover.ts +47 -9
- package/src/commands/restore.ts +8 -1
- package/src/commands/retire.ts +23 -70
- package/src/commands/rollback.ts +2 -14
- package/src/commands/sleep.ts +7 -0
- package/src/commands/ssh.ts +5 -24
- package/src/commands/teleport.ts +34 -26
- package/src/commands/tunnel.ts +46 -2
- package/src/commands/unpair.ts +118 -0
- package/src/commands/upgrade.ts +8 -16
- package/src/commands/wake.ts +75 -45
- package/src/components/DefaultMainScreen.tsx +100 -14
- package/src/index.ts +22 -0
- package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
- package/src/lib/__tests__/step-runner.test.ts +49 -1
- package/src/lib/assistant-client.ts +58 -37
- package/src/lib/assistant-config.ts +28 -3
- package/src/lib/cloudflare-tunnel.ts +276 -0
- package/src/lib/config-utils.ts +24 -3
- package/src/lib/confirm-action.ts +57 -0
- package/src/lib/docker.ts +82 -8
- 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 +75 -33
- package/src/lib/http-client.ts +1 -3
- package/src/lib/lifecycle-reporter.ts +31 -0
- package/src/lib/local.ts +193 -298
- package/src/lib/orphan-detection.ts +9 -5
- package/src/lib/pgrep.ts +5 -1
- package/src/lib/platform-client.ts +97 -49
- package/src/lib/process.ts +109 -39
- package/src/lib/retire-local.ts +28 -14
- package/src/lib/segments-to-plain-text.ts +35 -0
- package/src/lib/step-runner.ts +67 -7
- package/src/lib/sync-cloud-assistants.ts +17 -0
- /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { consoleLifecycleReporter } from "../lifecycle-reporter.js";
|
|
4
|
+
|
|
5
|
+
describe("consoleLifecycleReporter", () => {
|
|
6
|
+
const originalDesktopApp = process.env.VELLUM_DESKTOP_APP;
|
|
7
|
+
let stdoutWriteSpy: ReturnType<typeof spyOn>;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
stdoutWriteSpy = spyOn(process.stdout, "write").mockImplementation(
|
|
11
|
+
() => true,
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
stdoutWriteSpy.mockRestore();
|
|
17
|
+
if (originalDesktopApp === undefined) {
|
|
18
|
+
delete process.env.VELLUM_DESKTOP_APP;
|
|
19
|
+
} else {
|
|
20
|
+
process.env.VELLUM_DESKTOP_APP = originalDesktopApp;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("routes log/warn/error to the matching console methods", () => {
|
|
25
|
+
const logSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
26
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
27
|
+
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
28
|
+
|
|
29
|
+
consoleLifecycleReporter.log("hello");
|
|
30
|
+
consoleLifecycleReporter.warn("careful");
|
|
31
|
+
consoleLifecycleReporter.error("boom");
|
|
32
|
+
|
|
33
|
+
expect(logSpy).toHaveBeenCalledWith("hello");
|
|
34
|
+
expect(warnSpy).toHaveBeenCalledWith("careful");
|
|
35
|
+
expect(errorSpy).toHaveBeenCalledWith("boom");
|
|
36
|
+
|
|
37
|
+
logSpy.mockRestore();
|
|
38
|
+
warnSpy.mockRestore();
|
|
39
|
+
errorSpy.mockRestore();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("emits the HATCH_PROGRESS stdout contract under VELLUM_DESKTOP_APP", () => {
|
|
43
|
+
process.env.VELLUM_DESKTOP_APP = "1";
|
|
44
|
+
|
|
45
|
+
consoleLifecycleReporter.progress(3, 6, "Starting assistant...");
|
|
46
|
+
|
|
47
|
+
expect(stdoutWriteSpy).toHaveBeenCalledWith(
|
|
48
|
+
`HATCH_PROGRESS:${JSON.stringify({ step: 3, total: 6, label: "Starting assistant..." })}\n`,
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("suppresses progress output when not running under the desktop app", () => {
|
|
53
|
+
delete process.env.VELLUM_DESKTOP_APP;
|
|
54
|
+
|
|
55
|
+
consoleLifecycleReporter.progress(1, 6, "Allocating resources...");
|
|
56
|
+
|
|
57
|
+
expect(stdoutWriteSpy).not.toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -1,6 +1,15 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync } from "fs";
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
1
5
|
import { describe, expect, it } from "bun:test";
|
|
2
6
|
|
|
3
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
buildExecErrorMessage,
|
|
9
|
+
exec,
|
|
10
|
+
execOutput,
|
|
11
|
+
execWithStdin,
|
|
12
|
+
} from "../step-runner";
|
|
4
13
|
|
|
5
14
|
describe("buildExecErrorMessage", () => {
|
|
6
15
|
it("omits the argv from the header so secrets in args can't leak", () => {
|
|
@@ -63,6 +72,45 @@ describe("exec — secret leak regression", () => {
|
|
|
63
72
|
});
|
|
64
73
|
});
|
|
65
74
|
|
|
75
|
+
describe("execWithStdin — pipes input + no secret leak in errors", () => {
|
|
76
|
+
it("writes the supplied input to the child's stdin", async () => {
|
|
77
|
+
// Use sh `cat > path` to capture stdin to a real file we can inspect.
|
|
78
|
+
// Mirrors the Docker-hatch overlay-staging call site shape.
|
|
79
|
+
const workDir = mkdtempSync(join(tmpdir(), "step-runner-stdin-"));
|
|
80
|
+
const dest = join(workDir, "captured.txt");
|
|
81
|
+
try {
|
|
82
|
+
const payload = '{"hello":"world"}\n';
|
|
83
|
+
await execWithStdin("sh", ["-c", `cat > ${dest}`], payload);
|
|
84
|
+
expect(readFileSync(dest, "utf-8")).toBe(payload);
|
|
85
|
+
} finally {
|
|
86
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("rejects with an Error whose message contains neither the args nor any -e KEY=VALUE pair", async () => {
|
|
91
|
+
const fakeSecret = "sk-anthropic-stdin-canary";
|
|
92
|
+
try {
|
|
93
|
+
await execWithStdin(
|
|
94
|
+
"sh",
|
|
95
|
+
[
|
|
96
|
+
"-c",
|
|
97
|
+
'echo "permission denied while trying to connect to docker daemon" 1>&2 && exit 1',
|
|
98
|
+
"-e",
|
|
99
|
+
`ANTHROPIC_API_KEY=${fakeSecret}`,
|
|
100
|
+
],
|
|
101
|
+
"",
|
|
102
|
+
);
|
|
103
|
+
throw new Error("execWithStdin should have rejected");
|
|
104
|
+
} catch (err) {
|
|
105
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
106
|
+
expect(message).not.toContain(fakeSecret);
|
|
107
|
+
expect(message).not.toContain("ANTHROPIC_API_KEY");
|
|
108
|
+
expect(message).toContain("sh exited with code 1");
|
|
109
|
+
expect(message).toContain("permission denied");
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
66
114
|
describe("execOutput — secret leak regression", () => {
|
|
67
115
|
it("rejects with an Error whose message contains neither the args nor any -e KEY=VALUE pair", async () => {
|
|
68
116
|
const fakeSecret = "sk-openai-leak-canary";
|
|
@@ -12,11 +12,9 @@
|
|
|
12
12
|
* ```
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
16
|
-
resolveAssistant,
|
|
17
|
-
} from "./assistant-config.js";
|
|
15
|
+
import { resolveAssistant } from "./assistant-config.js";
|
|
18
16
|
import { GATEWAY_PORT } from "./constants.js";
|
|
19
|
-
import { loadGuardianToken } from "./guardian-token.js";
|
|
17
|
+
import { loadGuardianToken, refreshGuardianToken } from "./guardian-token.js";
|
|
20
18
|
|
|
21
19
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
22
20
|
const FALLBACK_RUNTIME_URL = `http://127.0.0.1:${GATEWAY_PORT}`;
|
|
@@ -45,7 +43,8 @@ export class AssistantClient {
|
|
|
45
43
|
readonly runtimeUrl: string;
|
|
46
44
|
|
|
47
45
|
private readonly _assistantId: string;
|
|
48
|
-
|
|
46
|
+
/** Mutable: a 401 on the guardian path refreshes this in place (see request). */
|
|
47
|
+
private token: string | undefined;
|
|
49
48
|
/** True when token is a platform session token (X-Session-Token), false for guardian JWT (Authorization: Bearer). */
|
|
50
49
|
private readonly isSessionAuth: boolean;
|
|
51
50
|
private readonly orgId: string | undefined;
|
|
@@ -176,45 +175,67 @@ export class AssistantClient {
|
|
|
176
175
|
? `?${new URLSearchParams(opts.query).toString()}`
|
|
177
176
|
: "";
|
|
178
177
|
const url = `${this.runtimeUrl}/v1/assistants/${this._assistantId}${urlPath}${qs}`;
|
|
179
|
-
|
|
180
|
-
const headers: Record<string, string> = { ...opts?.headers };
|
|
181
|
-
if (this.token) {
|
|
182
|
-
if (this.isSessionAuth) {
|
|
183
|
-
headers["X-Session-Token"] ??= this.token;
|
|
184
|
-
} else {
|
|
185
|
-
headers["Authorization"] ??= `Bearer ${this.token}`;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
if (this.orgId) {
|
|
189
|
-
headers["Vellum-Organization-Id"] ??= this.orgId;
|
|
190
|
-
}
|
|
191
|
-
if (body !== undefined) {
|
|
192
|
-
headers["Content-Type"] = "application/json";
|
|
193
|
-
}
|
|
194
|
-
|
|
195
178
|
const jsonBody = body !== undefined ? JSON.stringify(body) : undefined;
|
|
196
179
|
|
|
197
|
-
|
|
180
|
+
// Headers are built per-attempt so a refreshed token is picked up on retry.
|
|
181
|
+
const buildHeaders = (): Record<string, string> => {
|
|
182
|
+
const headers: Record<string, string> = { ...opts?.headers };
|
|
183
|
+
if (this.token) {
|
|
184
|
+
if (this.isSessionAuth) {
|
|
185
|
+
headers["X-Session-Token"] ??= this.token;
|
|
186
|
+
} else {
|
|
187
|
+
headers["Authorization"] ??= `Bearer ${this.token}`;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (this.orgId) {
|
|
191
|
+
headers["Vellum-Organization-Id"] ??= this.orgId;
|
|
192
|
+
}
|
|
193
|
+
if (body !== undefined) {
|
|
194
|
+
headers["Content-Type"] = "application/json";
|
|
195
|
+
}
|
|
196
|
+
return headers;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const doFetch = (): Promise<Response> => {
|
|
200
|
+
const headers = buildHeaders();
|
|
201
|
+
if (opts?.signal) {
|
|
202
|
+
return fetch(url, {
|
|
203
|
+
method,
|
|
204
|
+
headers,
|
|
205
|
+
body: jsonBody,
|
|
206
|
+
signal: opts.signal,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
210
|
+
const controller = new AbortController();
|
|
211
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
198
212
|
return fetch(url, {
|
|
199
|
-
method,
|
|
200
|
-
headers,
|
|
201
|
-
body: jsonBody,
|
|
202
|
-
signal: opts.signal,
|
|
203
|
-
});
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
207
|
-
const controller = new AbortController();
|
|
208
|
-
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
209
|
-
try {
|
|
210
|
-
return await fetch(url, {
|
|
211
213
|
method,
|
|
212
214
|
headers,
|
|
213
215
|
body: jsonBody,
|
|
214
216
|
signal: controller.signal,
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
|
|
217
|
+
}).finally(() => clearTimeout(timeoutId));
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const response = await doFetch();
|
|
221
|
+
|
|
222
|
+
// Reactive auto-refresh: a paired/local guardian access token that has
|
|
223
|
+
// expired comes back 401. Refresh it once via the stored refresh credential
|
|
224
|
+
// and retry. Self-gating — refreshGuardianToken returns null unless a usable
|
|
225
|
+
// refresh token is stored, so ephemeral (`--token`) and access-only sessions
|
|
226
|
+
// just see the original 401. The platform session-auth path is never
|
|
227
|
+
// refreshed here (its token is managed by the Vellum platform).
|
|
228
|
+
if (response.status === 401 && !this.isSessionAuth) {
|
|
229
|
+
const refreshed = await refreshGuardianToken(
|
|
230
|
+
this.runtimeUrl,
|
|
231
|
+
this._assistantId,
|
|
232
|
+
);
|
|
233
|
+
if (refreshed?.accessToken) {
|
|
234
|
+
this.token = refreshed.accessToken;
|
|
235
|
+
return doFetch();
|
|
236
|
+
}
|
|
218
237
|
}
|
|
238
|
+
|
|
239
|
+
return response;
|
|
219
240
|
}
|
|
220
241
|
}
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
import { homedir } from "os";
|
|
11
11
|
import { dirname, join } from "path";
|
|
12
12
|
|
|
13
|
+
import { SEEDS, type EnvironmentDefinition } from "@vellumai/environments";
|
|
14
|
+
|
|
13
15
|
import { DAEMON_INTERNAL_ASSISTANT_ID } from "./constants.js";
|
|
14
16
|
import {
|
|
15
17
|
getDefaultPorts,
|
|
@@ -18,8 +20,6 @@ import {
|
|
|
18
20
|
getMultiInstanceDir,
|
|
19
21
|
} from "./environments/paths.js";
|
|
20
22
|
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
21
|
-
import { SEEDS } from "./environments/seeds.js";
|
|
22
|
-
import type { EnvironmentDefinition } from "./environments/types.js";
|
|
23
23
|
import { probePort } from "./port-probe.js";
|
|
24
24
|
|
|
25
25
|
/**
|
|
@@ -76,7 +76,19 @@ export interface AssistantEntry {
|
|
|
76
76
|
* Avoids mDNS resolution issues when the machine checks its own gateway. */
|
|
77
77
|
localUrl?: string;
|
|
78
78
|
bearerToken?: string;
|
|
79
|
+
/** Deployment topology / how the assistant is reached. Known values:
|
|
80
|
+
* `"local"` (on-machine daemon), `"docker"` (local container),
|
|
81
|
+
* `"apple-container"` (macOS-app-managed container), `"vellum"`
|
|
82
|
+
* (platform-managed, uses the X-Session-Token auth path), `"gcp"` / `"aws"`
|
|
83
|
+
* / `"custom"` (remote, SSH-managed), and `"paired"` (a remote assistant
|
|
84
|
+
* paired from another machine — reached via a bearer guardian token at
|
|
85
|
+
* `runtimeUrl`; has no local process, container, or `resources`).
|
|
86
|
+
* Kept as a free `string` (not a union) for forward-compatibility. */
|
|
79
87
|
cloud: string;
|
|
88
|
+
/** True when this entry was registered via `vellum connect import` (a remote
|
|
89
|
+
* pairing). Set alongside `cloud: "paired"`; also backs the re-import /
|
|
90
|
+
* overwrite guard in connect import. */
|
|
91
|
+
paired?: boolean;
|
|
80
92
|
instanceId?: string;
|
|
81
93
|
namespace?: string;
|
|
82
94
|
project?: string;
|
|
@@ -559,6 +571,19 @@ export function resolveCloud(entry: AssistantEntry): string {
|
|
|
559
571
|
return "local";
|
|
560
572
|
}
|
|
561
573
|
|
|
574
|
+
/**
|
|
575
|
+
* Extract the hostname from a URL string. Falls back to stripping the scheme
|
|
576
|
+
* and taking the hostname portion if URL parsing fails.
|
|
577
|
+
*/
|
|
578
|
+
export function extractHostFromUrl(url: string): string {
|
|
579
|
+
try {
|
|
580
|
+
const parsed = new URL(url);
|
|
581
|
+
return parsed.hostname;
|
|
582
|
+
} catch {
|
|
583
|
+
return url.replace(/^https?:\/\//, "").split(":")[0];
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
562
587
|
export function saveAssistantEntry(entry: AssistantEntry): void {
|
|
563
588
|
const entries = readAssistants().filter(
|
|
564
589
|
(e) => e.assistantId !== entry.assistantId,
|
|
@@ -618,7 +643,7 @@ export async function allocateLocalResources(
|
|
|
618
643
|
|
|
619
644
|
// Env-aware bases: non-prod envs sit in their own 1000-port window so
|
|
620
645
|
// running prod and staging assistants side-by-side doesn't collide. See
|
|
621
|
-
// `
|
|
646
|
+
// the `@vellumai/environments` `portBlock` layout.
|
|
622
647
|
const basePorts = getDefaultPorts(env);
|
|
623
648
|
const daemonPort = await findAvailablePort(basePorts.daemon, reservedPorts);
|
|
624
649
|
const gatewayPort = await findAvailablePort(basePorts.gateway, [
|
|
@@ -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
|
+
}
|
package/src/lib/config-utils.ts
CHANGED
|
@@ -35,6 +35,21 @@ export function buildNestedConfig(
|
|
|
35
35
|
/**
|
|
36
36
|
* Ensure hatch always provides enough initial LLM config for the assistant to
|
|
37
37
|
* detect a fresh off-platform hatch and seed BYOK profiles.
|
|
38
|
+
*
|
|
39
|
+
* @deprecated Part of the workspace-config overlay path — a CLI→Assistant
|
|
40
|
+
* side channel that bypasses the Assistant's public APIs and has no
|
|
41
|
+
* equivalent in web/desktop. Two replacement paths are on the table:
|
|
42
|
+
*
|
|
43
|
+
* 1. Post-hatch API calls — the CLI calls public Assistant routes after
|
|
44
|
+
* boot (`POST /v1/secrets`, plus a small read-only endpoint that
|
|
45
|
+
* returns the canonical inference-profile templates so the CLI can
|
|
46
|
+
* PATCH them in). See the closed alternatives in PR #32061 and
|
|
47
|
+
* PR #32131 for the shape this would take.
|
|
48
|
+
* 2. Move inference-profile seeds out of workspace config and into
|
|
49
|
+
* Assistant code, so there is nothing for the CLI to inject in the
|
|
50
|
+
* first place.
|
|
51
|
+
*
|
|
52
|
+
* Either path removes the need for this helper.
|
|
38
53
|
*/
|
|
39
54
|
export function buildHatchConfigValues(
|
|
40
55
|
configValues: Record<string, string>,
|
|
@@ -52,15 +67,21 @@ export function buildHatchConfigValues(
|
|
|
52
67
|
|
|
53
68
|
/**
|
|
54
69
|
* Write arbitrary key-value pairs to a temporary JSON file and return its
|
|
55
|
-
* path. The caller
|
|
56
|
-
*
|
|
57
|
-
*
|
|
70
|
+
* path. The caller is responsible for getting the file to the daemon — for
|
|
71
|
+
* the local hatch flow that means setting `VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH`
|
|
72
|
+
* on the daemon process; for the Docker hatch flow the caller stages the
|
|
73
|
+
* file into the workspace volume so the rename-after-consume step in
|
|
74
|
+
* `mergeDefaultWorkspaceConfig` is a same-filesystem rename.
|
|
58
75
|
*
|
|
59
76
|
* Keys use dot-notation to address nested fields. For example:
|
|
60
77
|
* "llm.default.provider" → {llm: {default: {provider: ...}}}
|
|
61
78
|
* "llm.default.model" → {llm: {default: {model: ...}}}
|
|
62
79
|
*
|
|
63
80
|
* Returns undefined when configValues is empty (nothing to write).
|
|
81
|
+
*
|
|
82
|
+
* @deprecated See {@link buildHatchConfigValues} for the replacement
|
|
83
|
+
* direction. This overlay path is a CLI→Assistant side channel and will be
|
|
84
|
+
* removed once one of the documented replacements lands.
|
|
64
85
|
*/
|
|
65
86
|
export function writeInitialConfig(
|
|
66
87
|
configValues: Record<string, string>,
|
|
@@ -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
|
+
}
|