@vellumai/cli 0.10.5 → 0.10.6-dev.202607062043.72bc40b
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/node_modules/@vellumai/local-mode/src/__tests__/upgrade.test.ts +128 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +1 -1
- package/node_modules/@vellumai/local-mode/src/upgrade.ts +43 -0
- package/package.json +1 -1
- package/src/__tests__/local-runtime-version.test.ts +42 -0
- package/src/__tests__/retire-local.test.ts +157 -0
- package/src/__tests__/sleep.test.ts +10 -2
- package/src/__tests__/wake.test.ts +34 -0
- package/src/commands/hatch.ts +1 -1
- package/src/commands/recover.ts +9 -3
- package/src/commands/sleep.ts +12 -0
- package/src/commands/upgrade.ts +11 -3
- package/src/commands/wake.ts +32 -3
- package/src/index.ts +59 -0
- package/src/lib/docker.ts +39 -1
- package/src/lib/hatch-local.ts +12 -4
- package/src/lib/local.ts +215 -0
- package/src/lib/process.ts +1 -1
- package/src/lib/retire-local.ts +12 -0
|
@@ -0,0 +1,128 @@
|
|
|
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<
|
|
14
|
+
[string, string[], { env?: NodeJS.ProcessEnv; stdio?: unknown }]
|
|
15
|
+
> = [];
|
|
16
|
+
const spawnMock = mock(
|
|
17
|
+
(
|
|
18
|
+
command: string,
|
|
19
|
+
args: string[],
|
|
20
|
+
options: { env?: NodeJS.ProcessEnv; stdio?: unknown },
|
|
21
|
+
) => {
|
|
22
|
+
spawnArgs.push([command, args, options]);
|
|
23
|
+
lastChild = new FakeChild();
|
|
24
|
+
return lastChild;
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
mock.module("node:child_process", () => ({ spawn: spawnMock }));
|
|
29
|
+
|
|
30
|
+
let runUpgrade: typeof import("../upgrade").runUpgrade;
|
|
31
|
+
let isValidReleaseVersion: typeof import("../upgrade").isValidReleaseVersion;
|
|
32
|
+
|
|
33
|
+
beforeAll(async () => {
|
|
34
|
+
({ runUpgrade, isValidReleaseVersion } = await import("../upgrade"));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
spawnArgs.length = 0;
|
|
39
|
+
spawnMock.mockClear();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const invocation: CliInvocation = { command: "bun", baseArgs: ["run", "cli"] };
|
|
43
|
+
|
|
44
|
+
describe("isValidReleaseVersion", () => {
|
|
45
|
+
test("accepts release tags, staying lenient about semver shape", () => {
|
|
46
|
+
for (const version of [
|
|
47
|
+
"latest",
|
|
48
|
+
"v1.2.3",
|
|
49
|
+
"V1.2.3", // uppercase prefix
|
|
50
|
+
"1.2.3",
|
|
51
|
+
"1.2", // two-part
|
|
52
|
+
"1.2.3.4", // four-part
|
|
53
|
+
"0.6.0-staging.5",
|
|
54
|
+
"1.2.3-rc.1+build.7",
|
|
55
|
+
]) {
|
|
56
|
+
expect(isValidReleaseVersion(version)).toBe(true);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("rejects package specs and path-traversal input", () => {
|
|
61
|
+
for (const version of [
|
|
62
|
+
"npm:@attacker/evil@1.0.0",
|
|
63
|
+
"https://evil.example/x.tgz",
|
|
64
|
+
"git+https://evil.example/x.git",
|
|
65
|
+
"github:attacker/vellum",
|
|
66
|
+
"file:/tmp/evil",
|
|
67
|
+
"../../../../tmp/evil",
|
|
68
|
+
"1.2.3-..", // no consecutive dots (would be a traversal segment)
|
|
69
|
+
"1.2.3-a..b",
|
|
70
|
+
"..",
|
|
71
|
+
"",
|
|
72
|
+
"vellum@1.2.3",
|
|
73
|
+
]) {
|
|
74
|
+
expect(isValidReleaseVersion(version)).toBe(false);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("runUpgrade", () => {
|
|
80
|
+
test("spawns the CLI upgrade command for a release tag", async () => {
|
|
81
|
+
const pending = runUpgrade(invocation, "asst-42", { version: "v1.2.3" });
|
|
82
|
+
lastChild.emit("close", 0);
|
|
83
|
+
|
|
84
|
+
expect(await pending).toEqual({ ok: true });
|
|
85
|
+
expect(spawnArgs[0]).toEqual([
|
|
86
|
+
"bun",
|
|
87
|
+
["run", "cli", "upgrade", "asst-42", "--version", "v1.2.3"],
|
|
88
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("spawns the CLI upgrade command for --latest", async () => {
|
|
93
|
+
const pending = runUpgrade(invocation, "asst-42", { latest: true });
|
|
94
|
+
lastChild.emit("close", 0);
|
|
95
|
+
|
|
96
|
+
expect(await pending).toEqual({ ok: true });
|
|
97
|
+
expect(spawnArgs[0]?.[1]).toEqual([
|
|
98
|
+
"run",
|
|
99
|
+
"cli",
|
|
100
|
+
"upgrade",
|
|
101
|
+
"asst-42",
|
|
102
|
+
"--latest",
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("rejects a malicious version without spawning", async () => {
|
|
107
|
+
const result = await runUpgrade(invocation, "asst-42", {
|
|
108
|
+
version: "npm:@attacker/evil@1.0.0",
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(result).toEqual({
|
|
112
|
+
ok: false,
|
|
113
|
+
status: 400,
|
|
114
|
+
error:
|
|
115
|
+
"Invalid upgrade version 'npm:@attacker/evil@1.0.0': expected a release tag like v1.2.3 or 'latest'.",
|
|
116
|
+
});
|
|
117
|
+
expect(spawnArgs.length).toBe(0);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("rejects a path-traversal version without spawning", async () => {
|
|
121
|
+
const result = await runUpgrade(invocation, "asst-42", {
|
|
122
|
+
version: "../../../../tmp/evil",
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(result.ok).toBe(false);
|
|
126
|
+
expect(spawnArgs.length).toBe(0);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -48,7 +48,7 @@ export { runSleep } from "./sleep";
|
|
|
48
48
|
export type { SleepResult } from "./sleep";
|
|
49
49
|
export { runWake } from "./wake";
|
|
50
50
|
export type { WakeOptions, WakeResult } from "./wake";
|
|
51
|
-
export { runUpgrade } from "./upgrade";
|
|
51
|
+
export { runUpgrade, isValidReleaseVersion } from "./upgrade";
|
|
52
52
|
export type { UpgradeOptions, UpgradeResult } from "./upgrade";
|
|
53
53
|
export { getLocalAssistantStatus } from "./status";
|
|
54
54
|
export type {
|
|
@@ -14,6 +14,37 @@ export interface UpgradeOptions {
|
|
|
14
14
|
force?: boolean;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// A trusted release version is the literal `latest` dist-tag, or a
|
|
18
|
+
// version-shaped string: an optional `v`/`V` prefix, then a leading digit,
|
|
19
|
+
// then any run of `[0-9A-Za-z.+-]`. This is intentionally lenient about the
|
|
20
|
+
// *shape* of the version (2-, 3-, or 4-part, arbitrary pre-release/build
|
|
21
|
+
// identifiers, uppercase `V`) so we never reject a legitimate release tag.
|
|
22
|
+
//
|
|
23
|
+
// Security does not depend on the shape — it depends on the CHARSET. The
|
|
24
|
+
// allowed set excludes `/`, `\`, `:`, `@`, `#` and whitespace, so the value can
|
|
25
|
+
// never be interpreted by `bun install` as a package-manager spec (npm alias,
|
|
26
|
+
// tarball/git URL, `file:`/`github:` ref) — all of which require one of those
|
|
27
|
+
// characters. The mandatory leading digit means the value can never be a bare
|
|
28
|
+
// `.`/`..` path segment, and `..` is rejected outright, so the version is safe
|
|
29
|
+
// to use as the runtime-install directory name.
|
|
30
|
+
const RELEASE_VERSION_PATTERN = /^(?:latest|[vV]?\d[0-9A-Za-z.+-]*)$/;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether `version` is a trusted release identifier: the literal `latest`, or a
|
|
34
|
+
* version-shaped tag like `v1.2.3` / `1.2.3` / `0.6.0-staging.5`. Rejects
|
|
35
|
+
* package-manager specifiers (npm aliases, tarball or git URLs) and any
|
|
36
|
+
* path-traversal-like input, while staying permissive about semver shape.
|
|
37
|
+
*
|
|
38
|
+
* Defined once here and reused by both the host-bridge boundary guard
|
|
39
|
+
* (`runUpgrade`, in the shared library backing the Electron host and the web
|
|
40
|
+
* dev-server middleware) and the CLI's runtime-install sink
|
|
41
|
+
* (`ensureLocalRuntime`), so the security boundary can never drift between the
|
|
42
|
+
* two call sites.
|
|
43
|
+
*/
|
|
44
|
+
export function isValidReleaseVersion(version: string): boolean {
|
|
45
|
+
return RELEASE_VERSION_PATTERN.test(version) && !version.includes("..");
|
|
46
|
+
}
|
|
47
|
+
|
|
17
48
|
function extractVersion(output: string): string | undefined {
|
|
18
49
|
const versionPattern = "(v?[0-9]+(?:\\.[0-9]+)*(?:[-+][\\w.-]+)?)";
|
|
19
50
|
const upgraded = output.match(
|
|
@@ -35,6 +66,18 @@ export function runUpgrade(
|
|
|
35
66
|
options?: UpgradeOptions,
|
|
36
67
|
): Promise<UpgradeResult> {
|
|
37
68
|
return new Promise((resolve) => {
|
|
69
|
+
// Reject an untrusted `--version` at the host boundary before spawning the
|
|
70
|
+
// CLI. `latest` (or the `--latest` flag) is always allowed; an explicit
|
|
71
|
+
// version must be a release tag. Preserves the never-reject contract.
|
|
72
|
+
if (options?.version && !isValidReleaseVersion(options.version)) {
|
|
73
|
+
resolve({
|
|
74
|
+
ok: false,
|
|
75
|
+
status: 400,
|
|
76
|
+
error: `Invalid upgrade version '${options.version}': expected a release tag like v1.2.3 or 'latest'.`,
|
|
77
|
+
});
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
38
81
|
const args = [...invocation.baseArgs, "upgrade", assistantId];
|
|
39
82
|
if (options?.latest) {
|
|
40
83
|
args.push("--latest");
|
package/package.json
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { isValidReleaseVersion } from "@vellumai/local-mode";
|
|
4
|
+
|
|
5
|
+
import type { LocalInstanceResources } from "../lib/assistant-config.js";
|
|
6
|
+
import { ensureLocalRuntime } from "../lib/local.js";
|
|
7
|
+
|
|
8
|
+
// Only `instanceDir` is read, and only after the validation guard passes —
|
|
9
|
+
// invalid versions throw before any filesystem access, so a minimal stub is
|
|
10
|
+
// sufficient for the rejection cases.
|
|
11
|
+
const resources = {
|
|
12
|
+
instanceDir: "/tmp/vellum-nonexistent-instance",
|
|
13
|
+
} as unknown as LocalInstanceResources;
|
|
14
|
+
|
|
15
|
+
const VALID_VERSIONS = ["latest", "v1.2.3", "1.2.3", "0.6.0-staging.5"];
|
|
16
|
+
const MALICIOUS_VERSIONS = [
|
|
17
|
+
"npm:@attacker/evil@1.0.0",
|
|
18
|
+
"https://evil.example/x.tgz",
|
|
19
|
+
"git+https://evil.example/x.git",
|
|
20
|
+
"../../../../tmp/evil",
|
|
21
|
+
"1.2.3-..",
|
|
22
|
+
"1.2.3-a..b",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
describe("ensureLocalRuntime version guard", () => {
|
|
26
|
+
test("shares the trusted-release validator with the host boundary", () => {
|
|
27
|
+
for (const version of VALID_VERSIONS) {
|
|
28
|
+
expect(isValidReleaseVersion(version)).toBe(true);
|
|
29
|
+
}
|
|
30
|
+
for (const version of MALICIOUS_VERSIONS) {
|
|
31
|
+
expect(isValidReleaseVersion(version)).toBe(false);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("throws before any install for untrusted versions", () => {
|
|
36
|
+
for (const version of MALICIOUS_VERSIONS) {
|
|
37
|
+
expect(() => ensureLocalRuntime(resources, version)).toThrow(
|
|
38
|
+
`Invalid runtime version '${version}'`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
beforeAll,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
mock,
|
|
8
|
+
spyOn,
|
|
9
|
+
test,
|
|
10
|
+
} from "bun:test";
|
|
11
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
16
|
+
|
|
17
|
+
const testDir = mkdtempSync(join(tmpdir(), "retire-local-test-"));
|
|
18
|
+
const originalLockfileDir = process.env.VELLUM_LOCKFILE_DIR;
|
|
19
|
+
|
|
20
|
+
const stopProcessByPidFileMock = mock(
|
|
21
|
+
async (_pidFile: string, _label: string): Promise<boolean> => true,
|
|
22
|
+
);
|
|
23
|
+
const stopOrphanedDaemonProcessesMock = mock(async (): Promise<boolean> => false);
|
|
24
|
+
const stopIngressNginxMock = mock(async (): Promise<boolean> => false);
|
|
25
|
+
|
|
26
|
+
mock.module("../lib/process.js", () => ({
|
|
27
|
+
stopProcessByPidFile: stopProcessByPidFileMock,
|
|
28
|
+
stopOrphanedDaemonProcesses: stopOrphanedDaemonProcessesMock,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
mock.module("../lib/nginx-ingress.js", () => ({
|
|
32
|
+
stopIngressNginx: stopIngressNginxMock,
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
// Keep archive paths on the same filesystem as the test dir so rename doesn't
|
|
36
|
+
// hit EXDEV (cross-device link).
|
|
37
|
+
const retiredStagingDir = join(testDir, "retired");
|
|
38
|
+
mock.module("../lib/retire-archive.js", () => ({
|
|
39
|
+
getArchivePath: () => join(retiredStagingDir, "test-assistant.tar.gz"),
|
|
40
|
+
getMetadataPath: () => join(retiredStagingDir, "test-assistant.meta.json"),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
import { retireLocal } from "../lib/retire-local.js";
|
|
44
|
+
|
|
45
|
+
const instanceDir = join(testDir, "test-instance");
|
|
46
|
+
const vellumDir = join(instanceDir, ".vellum");
|
|
47
|
+
|
|
48
|
+
function makeEntry(assistantId: string): AssistantEntry {
|
|
49
|
+
return {
|
|
50
|
+
assistantId,
|
|
51
|
+
runtimeUrl: "http://127.0.0.1:7801",
|
|
52
|
+
cloud: "local",
|
|
53
|
+
resources: {
|
|
54
|
+
instanceDir,
|
|
55
|
+
daemonPort: 7801,
|
|
56
|
+
gatewayPort: 7831,
|
|
57
|
+
qdrantPort: 6334,
|
|
58
|
+
cesPort: 7790,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function writeLockfile(entries: AssistantEntry[]): void {
|
|
64
|
+
writeFileSync(
|
|
65
|
+
join(testDir, ".vellum.lock.json"),
|
|
66
|
+
JSON.stringify({ assistants: entries }, null, 2) + "\n",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("retireLocal — CES sibling stop", () => {
|
|
71
|
+
beforeAll(() => {
|
|
72
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
stopProcessByPidFileMock.mockReset();
|
|
77
|
+
stopProcessByPidFileMock.mockResolvedValue(true);
|
|
78
|
+
stopOrphanedDaemonProcessesMock.mockReset();
|
|
79
|
+
stopOrphanedDaemonProcessesMock.mockResolvedValue(false);
|
|
80
|
+
stopIngressNginxMock.mockReset();
|
|
81
|
+
stopIngressNginxMock.mockResolvedValue(false);
|
|
82
|
+
|
|
83
|
+
rmSync(instanceDir, { recursive: true, force: true });
|
|
84
|
+
rmSync(join(testDir, "retired"), { recursive: true, force: true });
|
|
85
|
+
mkdirSync(vellumDir, { recursive: true });
|
|
86
|
+
writeLockfile([makeEntry("test-assistant")]);
|
|
87
|
+
|
|
88
|
+
// Suppress console output from the lifecycle reporter.
|
|
89
|
+
spyOn(console, "log").mockImplementation(() => {});
|
|
90
|
+
spyOn(console, "warn").mockImplementation(() => {});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterAll(() => {
|
|
94
|
+
if (originalLockfileDir === undefined) {
|
|
95
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
96
|
+
} else {
|
|
97
|
+
process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
|
|
98
|
+
}
|
|
99
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("stops the CES sibling alongside daemon and gateway", async () => {
|
|
103
|
+
const entry = makeEntry("test-assistant");
|
|
104
|
+
await retireLocal("test-assistant", entry, {
|
|
105
|
+
progress: () => {},
|
|
106
|
+
log: () => {},
|
|
107
|
+
warn: () => {},
|
|
108
|
+
error: () => {},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Verify CES PID file is among the stop calls.
|
|
112
|
+
const cesStopCall = stopProcessByPidFileMock.mock.calls.find(
|
|
113
|
+
([pidFile, label]) =>
|
|
114
|
+
pidFile === join(vellumDir, "ces.pid") &&
|
|
115
|
+
label === "credential-executor",
|
|
116
|
+
);
|
|
117
|
+
expect(cesStopCall).toBeDefined();
|
|
118
|
+
|
|
119
|
+
// Also verify daemon and gateway are still stopped (sanity).
|
|
120
|
+
const daemonStopCall = stopProcessByPidFileMock.mock.calls.find(
|
|
121
|
+
([, label]) => label === "daemon",
|
|
122
|
+
);
|
|
123
|
+
expect(daemonStopCall).toBeDefined();
|
|
124
|
+
|
|
125
|
+
const gatewayStopCall = stopProcessByPidFileMock.mock.calls.find(
|
|
126
|
+
([, label]) => label === "gateway",
|
|
127
|
+
);
|
|
128
|
+
expect(gatewayStopCall).toBeDefined();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("CES stop is a no-op when ces.pid is absent", async () => {
|
|
132
|
+
// stopProcessByPidFile returns false when the PID file doesn't exist.
|
|
133
|
+
// retireLocal should still complete successfully — the CES stop is best-effort.
|
|
134
|
+
stopProcessByPidFileMock.mockImplementation(
|
|
135
|
+
async (pidFile: string): Promise<boolean> => {
|
|
136
|
+
if (pidFile.includes("ces.pid")) return false;
|
|
137
|
+
return true;
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const entry = makeEntry("test-assistant");
|
|
142
|
+
await retireLocal("test-assistant", entry, {
|
|
143
|
+
progress: () => {},
|
|
144
|
+
log: () => {},
|
|
145
|
+
warn: () => {},
|
|
146
|
+
error: () => {},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// The CES stop was attempted (PID file checked) but returned false.
|
|
150
|
+
const cesStopCall = stopProcessByPidFileMock.mock.calls.find(
|
|
151
|
+
([pidFile, label]) =>
|
|
152
|
+
pidFile === join(vellumDir, "ces.pid") &&
|
|
153
|
+
label === "credential-executor",
|
|
154
|
+
);
|
|
155
|
+
expect(cesStopCall).toBeDefined();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -139,7 +139,8 @@ describe("sleep command", () => {
|
|
|
139
139
|
consoleLog.mockRestore();
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
// assistant + gateway + CES sibling
|
|
143
|
+
expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(3);
|
|
143
144
|
});
|
|
144
145
|
|
|
145
146
|
test("force stops the assistant even when an active call lease exists", async () => {
|
|
@@ -154,7 +155,7 @@ describe("sleep command", () => {
|
|
|
154
155
|
consoleLog.mockRestore();
|
|
155
156
|
}
|
|
156
157
|
|
|
157
|
-
expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(
|
|
158
|
+
expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(3);
|
|
158
159
|
// The assistant stop passes a generous 120s grace so the daemon's WAL
|
|
159
160
|
// checkpoint completes before any SIGKILL (default 2s would truncate it).
|
|
160
161
|
expect(stopProcessByPidFileMock).toHaveBeenNthCalledWith(
|
|
@@ -171,5 +172,12 @@ describe("sleep command", () => {
|
|
|
171
172
|
undefined,
|
|
172
173
|
7000,
|
|
173
174
|
);
|
|
175
|
+
// The CES sibling (CES_STANDALONE opt-in) is stopped by its PID file; a
|
|
176
|
+
// no-op when absent on the default topology.
|
|
177
|
+
expect(stopProcessByPidFileMock).toHaveBeenNthCalledWith(
|
|
178
|
+
3,
|
|
179
|
+
join(assistantRootDir, "ces.pid"),
|
|
180
|
+
"credential-executor",
|
|
181
|
+
);
|
|
174
182
|
});
|
|
175
183
|
});
|
|
@@ -90,11 +90,16 @@ const resolveProcessStateMock = mock<typeof processLib.resolveProcessState>(
|
|
|
90
90
|
const stopProcessByPidFileMock = mock<typeof processLib.stopProcessByPidFile>(
|
|
91
91
|
async () => true,
|
|
92
92
|
);
|
|
93
|
+
const isProcessAliveMock = mock<typeof processLib.isProcessAlive>(() => ({
|
|
94
|
+
alive: false,
|
|
95
|
+
pid: null,
|
|
96
|
+
}));
|
|
93
97
|
|
|
94
98
|
mock.module("../lib/process", () => ({
|
|
95
99
|
...realProcessLib,
|
|
96
100
|
resolveProcessState: resolveProcessStateMock,
|
|
97
101
|
stopProcessByPidFile: stopProcessByPidFileMock,
|
|
102
|
+
isProcessAlive: isProcessAliveMock,
|
|
98
103
|
}));
|
|
99
104
|
|
|
100
105
|
const generateLocalSigningKeyMock = mock<typeof local.generateLocalSigningKey>(
|
|
@@ -110,6 +115,7 @@ const startLocalDaemonMock = mock<typeof local.startLocalDaemon>(async () => {})
|
|
|
110
115
|
const startGatewayMock = mock<typeof local.startGateway>(
|
|
111
116
|
async () => "http://127.0.0.1:7830",
|
|
112
117
|
);
|
|
118
|
+
const startCesMock = mock<typeof local.startCes>(async () => {});
|
|
113
119
|
|
|
114
120
|
mock.module("../lib/local", () => ({
|
|
115
121
|
...realLocal,
|
|
@@ -118,6 +124,7 @@ mock.module("../lib/local", () => ({
|
|
|
118
124
|
isGatewayWatchModeAvailable: isGatewayWatchModeAvailableMock,
|
|
119
125
|
startLocalDaemon: startLocalDaemonMock,
|
|
120
126
|
startGateway: startGatewayMock,
|
|
127
|
+
startCes: startCesMock,
|
|
121
128
|
}));
|
|
122
129
|
|
|
123
130
|
const maybeStartNgrokTunnelMock = mock<typeof ngrok.maybeStartNgrokTunnel>(
|
|
@@ -184,6 +191,10 @@ beforeEach(() => {
|
|
|
184
191
|
startLocalDaemonMock.mockResolvedValue(undefined);
|
|
185
192
|
startGatewayMock.mockReset();
|
|
186
193
|
startGatewayMock.mockResolvedValue("http://127.0.0.1:7830");
|
|
194
|
+
startCesMock.mockReset();
|
|
195
|
+
startCesMock.mockResolvedValue(undefined);
|
|
196
|
+
isProcessAliveMock.mockReset();
|
|
197
|
+
isProcessAliveMock.mockReturnValue({ alive: false, pid: null });
|
|
187
198
|
seedGuardianTokenFromSiblingEnvMock.mockReset();
|
|
188
199
|
seedGuardianTokenFromSiblingEnvMock.mockReturnValue(false);
|
|
189
200
|
loadGuardianTokenMock.mockReset();
|
|
@@ -291,4 +302,27 @@ describe("vellum wake", () => {
|
|
|
291
302
|
"generated-bootstrap-secret",
|
|
292
303
|
);
|
|
293
304
|
});
|
|
305
|
+
|
|
306
|
+
test("relaunches CES sibling when daemon is healthy but CES is dead", async () => {
|
|
307
|
+
// Daemon is healthy (default mock) but CES pid says dead.
|
|
308
|
+
// isProcessAliveMock defaults to { alive: false }.
|
|
309
|
+
await wake();
|
|
310
|
+
|
|
311
|
+
// startCes should be called to relaunch the dead sibling.
|
|
312
|
+
expect(startCesMock).toHaveBeenCalledTimes(1);
|
|
313
|
+
// startLocalDaemon should NOT be called (daemon already running).
|
|
314
|
+
expect(startLocalDaemonMock).not.toHaveBeenCalled();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("does NOT relaunch CES sibling when both daemon and CES are healthy", async () => {
|
|
318
|
+
// Daemon is healthy (default mock). CES is also alive.
|
|
319
|
+
isProcessAliveMock.mockReturnValue({ alive: true, pid: 789 });
|
|
320
|
+
|
|
321
|
+
await wake();
|
|
322
|
+
|
|
323
|
+
// startCes should NOT be called (CES is alive).
|
|
324
|
+
expect(startCesMock).not.toHaveBeenCalled();
|
|
325
|
+
// startLocalDaemon should NOT be called (daemon already running).
|
|
326
|
+
expect(startLocalDaemonMock).not.toHaveBeenCalled();
|
|
327
|
+
});
|
|
294
328
|
});
|
package/src/commands/hatch.ts
CHANGED
|
@@ -146,7 +146,7 @@ ${userSetup}
|
|
|
146
146
|
${envSetLines}
|
|
147
147
|
VELLUM_ASSISTANT_NAME=${instanceName}
|
|
148
148
|
mkdir -p "\$HOME/.config/vellum"
|
|
149
|
-
cat > "\$HOME/.config/vellum
|
|
149
|
+
cat > "\$HOME/.config/vellum/.env" << DOTENV_EOF
|
|
150
150
|
${dotenvLines}
|
|
151
151
|
RUNTIME_HTTP_PORT=7821
|
|
152
152
|
DOTENV_EOF
|
package/src/commands/recover.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { saveAssistantEntry } from "../lib/assistant-config";
|
|
|
12
12
|
import type { AssistantEntry } from "../lib/assistant-config";
|
|
13
13
|
import {
|
|
14
14
|
generateLocalSigningKey,
|
|
15
|
+
startCes,
|
|
15
16
|
startLocalDaemon,
|
|
16
17
|
startGateway,
|
|
17
18
|
} from "../lib/local";
|
|
@@ -117,9 +118,14 @@ export async function recover(): Promise<void> {
|
|
|
117
118
|
entry.guardianBootstrapSecret = bootstrapSecret;
|
|
118
119
|
saveAssistantEntry(entry);
|
|
119
120
|
|
|
120
|
-
// 8. Start daemon + gateway
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
// 8. Start CES sibling (opt-in) + daemon + gateway in parallel, the way the
|
|
122
|
+
// Docker topology brings its sibling processes up together. startCes is a
|
|
123
|
+
// no-op unless CES_STANDALONE is set.
|
|
124
|
+
await Promise.all([
|
|
125
|
+
startCes(false, entry.resources),
|
|
126
|
+
startLocalDaemon(false, entry.resources, { signingKey }),
|
|
127
|
+
startGateway(false, entry.resources, { signingKey, bootstrapSecret }),
|
|
128
|
+
]);
|
|
123
129
|
|
|
124
130
|
console.log(`✅ Recovered assistant '${name}'.`);
|
|
125
131
|
}
|
package/src/commands/sleep.ts
CHANGED
|
@@ -168,6 +168,18 @@ export async function sleep(): Promise<void> {
|
|
|
168
168
|
console.log("Gateway stopped.");
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
// Stop the CES sibling if one was launched (CES_STANDALONE). No-op when the
|
|
172
|
+
// PID file is absent — on the default topology the assistant owns CES as an
|
|
173
|
+
// stdio child and it exits with the daemon.
|
|
174
|
+
const cesPidFile = join(vellumDir, "ces.pid");
|
|
175
|
+
const cesStopped = await stopProcessByPidFile(
|
|
176
|
+
cesPidFile,
|
|
177
|
+
"credential-executor",
|
|
178
|
+
);
|
|
179
|
+
if (cesStopped) {
|
|
180
|
+
console.log("credential-executor stopped.");
|
|
181
|
+
}
|
|
182
|
+
|
|
171
183
|
// Stop the nginx ingress if one is fronting this gateway — otherwise it
|
|
172
184
|
// keeps running against a dead upstream and serves 502s.
|
|
173
185
|
const ingressStopped = await stopIngressNginx(join(vellumDir, "workspace"));
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -66,6 +66,7 @@ import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
|
|
|
66
66
|
import {
|
|
67
67
|
generateLocalSigningKey,
|
|
68
68
|
ensureLocalRuntime,
|
|
69
|
+
startCes,
|
|
69
70
|
startGateway,
|
|
70
71
|
startLocalDaemon,
|
|
71
72
|
stopLocalProcesses,
|
|
@@ -434,7 +435,8 @@ async function upgradeDocker(
|
|
|
434
435
|
}
|
|
435
436
|
|
|
436
437
|
// Recover the assistant host port from the entry, fall back to default.
|
|
437
|
-
const assistantPort =
|
|
438
|
+
const assistantPort =
|
|
439
|
+
entry.containerInfo?.assistantPort ?? ASSISTANT_INTERNAL_PORT;
|
|
438
440
|
|
|
439
441
|
// Create pre-upgrade backup (best-effort, daemon must be running)
|
|
440
442
|
await broadcastUpgradeEvent(
|
|
@@ -984,8 +986,14 @@ async function upgradeLocal(
|
|
|
984
986
|
const previousAppVersion = process.env.APP_VERSION;
|
|
985
987
|
process.env.APP_VERSION = stripVersionPrefix(targetVersion);
|
|
986
988
|
try {
|
|
987
|
-
|
|
988
|
-
|
|
989
|
+
// Bring CES, daemon, and gateway up in parallel, the way the Docker
|
|
990
|
+
// topology starts its sibling processes together. startCes is a no-op
|
|
991
|
+
// unless CES_STANDALONE is set.
|
|
992
|
+
await Promise.all([
|
|
993
|
+
startCes(false, entry.resources),
|
|
994
|
+
startLocalDaemon(false, entry.resources, { signingKey }),
|
|
995
|
+
startGateway(false, entry.resources, { signingKey, bootstrapSecret }),
|
|
996
|
+
]);
|
|
989
997
|
} finally {
|
|
990
998
|
if (previousAppVersion === undefined) {
|
|
991
999
|
delete process.env.APP_VERSION;
|
package/src/commands/wake.ts
CHANGED
|
@@ -12,11 +12,12 @@ import {
|
|
|
12
12
|
resetGuardianBootstrap,
|
|
13
13
|
seedGuardianTokenFromSiblingEnv,
|
|
14
14
|
} from "../lib/guardian-token.js";
|
|
15
|
-
import { resolveProcessState, stopProcessByPidFile } from "../lib/process";
|
|
15
|
+
import { resolveProcessState, stopProcessByPidFile, isProcessAlive } from "../lib/process";
|
|
16
16
|
import {
|
|
17
17
|
generateLocalSigningKey,
|
|
18
18
|
isAssistantWatchModeAvailable,
|
|
19
19
|
isGatewayWatchModeAvailable,
|
|
20
|
+
startCes,
|
|
20
21
|
startLocalDaemon,
|
|
21
22
|
startGateway,
|
|
22
23
|
} from "../lib/local";
|
|
@@ -176,7 +177,31 @@ export async function wake(): Promise<void> {
|
|
|
176
177
|
}
|
|
177
178
|
|
|
178
179
|
if (!daemonRunning) {
|
|
179
|
-
|
|
180
|
+
// Spin up CES and the daemon in parallel, the way the Docker topology
|
|
181
|
+
// brings its sibling containers up together — the assistant polls for the
|
|
182
|
+
// CES socket during startup (discoverCesWithRetry), so it tolerates CES
|
|
183
|
+
// still binding. CES's lifecycle tracks the daemon (its only consumer):
|
|
184
|
+
// restarting it under a live daemon would sever the daemon's open
|
|
185
|
+
// connection, so it is only (re)started alongside the daemon. startCes is a
|
|
186
|
+
// no-op unless CES_STANDALONE is set, in which case the assistant spawns
|
|
187
|
+
// CES itself as today.
|
|
188
|
+
await Promise.all([
|
|
189
|
+
startCes(watch, resources),
|
|
190
|
+
startLocalDaemon(watch, resources, { foreground, signingKey }),
|
|
191
|
+
]);
|
|
192
|
+
} else {
|
|
193
|
+
// Self-heal: the daemon is already healthy, but the CES sibling may have
|
|
194
|
+
// died independently (crash, OOM kill). A dead ces.pid under a live daemon
|
|
195
|
+
// means credential operations will fail until the next wake. Relaunch the
|
|
196
|
+
// sibling so the daemon's lazy reconnect (secure-keys.ts) picks it up on
|
|
197
|
+
// the next credential read. startCes is a no-op unless CES_STANDALONE.
|
|
198
|
+
const vellumDir = join(resources.instanceDir, ".vellum");
|
|
199
|
+
const cesPidFile = join(vellumDir, "ces.pid");
|
|
200
|
+
const cesAlive = isProcessAlive(cesPidFile).alive;
|
|
201
|
+
if (!cesAlive) {
|
|
202
|
+
console.log("CES sibling not running — relaunching...");
|
|
203
|
+
await startCes(watch, resources);
|
|
204
|
+
}
|
|
180
205
|
}
|
|
181
206
|
|
|
182
207
|
// Start gateway
|
|
@@ -256,7 +281,11 @@ export async function wake(): Promise<void> {
|
|
|
256
281
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
257
282
|
try {
|
|
258
283
|
await resetGuardianBootstrap(loopbackUrl, bootstrapSecret);
|
|
259
|
-
await leaseGuardianToken(
|
|
284
|
+
await leaseGuardianToken(
|
|
285
|
+
loopbackUrl,
|
|
286
|
+
entry.assistantId,
|
|
287
|
+
bootstrapSecret,
|
|
288
|
+
);
|
|
260
289
|
console.log(" Re-provisioned guardian token.");
|
|
261
290
|
break;
|
|
262
291
|
} catch (err) {
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
import { resolveConfigDir } from "@vellumai/local-mode";
|
|
7
|
+
|
|
3
8
|
import cliPkg from "../package.json";
|
|
4
9
|
import { backup } from "./commands/backup";
|
|
5
10
|
import { clean } from "./commands/clean";
|
|
@@ -156,6 +161,56 @@ function applyNoColorFlags(argv: string[]): void {
|
|
|
156
161
|
}
|
|
157
162
|
}
|
|
158
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Load env vars from the vellum config dotenv file into `process.env` so
|
|
166
|
+
* that `vellum hatch` forwards provider API keys to containers and other
|
|
167
|
+
* commands have access to them.
|
|
168
|
+
*
|
|
169
|
+
* Reads `$XDG_CONFIG_HOME/vellum{-env}/.env` — the same config directory
|
|
170
|
+
* the CLI uses for guardian tokens and environment state. The file is
|
|
171
|
+
* written by remote-hatch scripts and can be user-managed.
|
|
172
|
+
*
|
|
173
|
+
* Existing `process.env` values take precedence (standard dotenv convention).
|
|
174
|
+
* Only KEY=VALUE lines are parsed. Lines starting with # are comments.
|
|
175
|
+
* Values may be quoted with single or double quotes.
|
|
176
|
+
*/
|
|
177
|
+
function loadConfigDotenv(): void {
|
|
178
|
+
const configDir = resolveConfigDir(process.env);
|
|
179
|
+
const envPath = join(configDir, ".env");
|
|
180
|
+
|
|
181
|
+
let content: string;
|
|
182
|
+
try {
|
|
183
|
+
content = readFileSync(envPath, "utf-8");
|
|
184
|
+
} catch {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const line of content.split("\n")) {
|
|
189
|
+
const trimmed = line.trim();
|
|
190
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
191
|
+
|
|
192
|
+
const eqIndex = trimmed.indexOf("=");
|
|
193
|
+
if (eqIndex === -1) continue;
|
|
194
|
+
|
|
195
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
196
|
+
if (!key) continue;
|
|
197
|
+
|
|
198
|
+
// Existing env vars take precedence (dotenv convention).
|
|
199
|
+
if (process.env[key] !== undefined) continue;
|
|
200
|
+
|
|
201
|
+
let value = trimmed.slice(eqIndex + 1).trim();
|
|
202
|
+
// Strip surrounding quotes.
|
|
203
|
+
if (
|
|
204
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
205
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
206
|
+
) {
|
|
207
|
+
value = value.slice(1, -1);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
process.env[key] = value;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
159
214
|
/**
|
|
160
215
|
* If a running assistant is detected, launch the TUI client and return true.
|
|
161
216
|
* Otherwise return false so the caller can fall back to help text.
|
|
@@ -181,6 +236,10 @@ async function tryLaunchClient(): Promise<boolean> {
|
|
|
181
236
|
}
|
|
182
237
|
|
|
183
238
|
async function main() {
|
|
239
|
+
// Load $XDG_CONFIG_HOME/vellum/.env before any command runs so
|
|
240
|
+
// provider API keys and other config are available to hatch, exec, etc.
|
|
241
|
+
loadConfigDotenv();
|
|
242
|
+
|
|
184
243
|
const args = process.argv.slice(2);
|
|
185
244
|
|
|
186
245
|
// Must run before any command or terminal-capabilities usage
|
package/src/lib/docker.ts
CHANGED
|
@@ -1626,7 +1626,45 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1626
1626
|
log(` Container: ${containerName}`);
|
|
1627
1627
|
log("");
|
|
1628
1628
|
log(`Stop with: vellum retire ${instanceName}`);
|
|
1629
|
-
|
|
1629
|
+
|
|
1630
|
+
// Lease a guardian token even in detached mode so that `vellum ps`,
|
|
1631
|
+
// `vellum exec`, and other CLI commands can authenticate to the
|
|
1632
|
+
// gateway. Skip the /readyz readiness poll (the caller asked to detach
|
|
1633
|
+
// and not block) but retry the lease itself since the gateway may need
|
|
1634
|
+
// a moment to accept connections after the container starts.
|
|
1635
|
+
const leaseStart = Date.now();
|
|
1636
|
+
const leaseDeadline = containersUpAt + DOCKER_READY_TIMEOUT_MS;
|
|
1637
|
+
let guardianAccessToken: string | undefined;
|
|
1638
|
+
while (Date.now() < leaseDeadline) {
|
|
1639
|
+
try {
|
|
1640
|
+
const tokenData = await leaseGuardianToken(
|
|
1641
|
+
runtimeUrl,
|
|
1642
|
+
instanceName,
|
|
1643
|
+
bootstrapSecret,
|
|
1644
|
+
);
|
|
1645
|
+
guardianAccessToken = tokenData.accessToken;
|
|
1646
|
+
const leaseElapsed = ((Date.now() - leaseStart) / 1000).toFixed(1);
|
|
1647
|
+
log(
|
|
1648
|
+
`Guardian token lease: success after ${leaseElapsed}s (principalId=${tokenData.guardianPrincipalId})`,
|
|
1649
|
+
);
|
|
1650
|
+
break;
|
|
1651
|
+
} catch (err) {
|
|
1652
|
+
const elapsed = ((Date.now() - leaseStart) / 1000).toFixed(0);
|
|
1653
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1654
|
+
log(
|
|
1655
|
+
`Guardian token lease: attempt failed after ${elapsed}s (${msg.split("\n")[0]}), retrying...`,
|
|
1656
|
+
);
|
|
1657
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
if (!guardianAccessToken) {
|
|
1661
|
+
log(
|
|
1662
|
+
`⚠️ Guardian token lease failed after ${DOCKER_READY_TIMEOUT_MS / 1000}s.\n` +
|
|
1663
|
+
` The assistant is running but CLI commands (vellum ps, vellum exec) will not authenticate.\n` +
|
|
1664
|
+
` Re-hatch or run \`vellum setup\` to recover.`,
|
|
1665
|
+
);
|
|
1666
|
+
}
|
|
1667
|
+
return { ready: true, guardianAccessToken };
|
|
1630
1668
|
}
|
|
1631
1669
|
|
|
1632
1670
|
log(` Container: ${containerName}`);
|
package/src/lib/hatch-local.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type { Species } from "./constants.js";
|
|
|
25
25
|
import { buildHatchConfigValues, writeInitialConfig } from "./config-utils.js";
|
|
26
26
|
import {
|
|
27
27
|
generateLocalSigningKey,
|
|
28
|
+
startCes,
|
|
28
29
|
startLocalDaemon,
|
|
29
30
|
startGateway,
|
|
30
31
|
stopLocalProcesses,
|
|
@@ -225,10 +226,17 @@ export async function hatchLocal(
|
|
|
225
226
|
reporter.progress(3, 6, "Starting assistant...");
|
|
226
227
|
const signingKey = generateLocalSigningKey();
|
|
227
228
|
const bootstrapSecret = generateLocalSigningKey();
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
229
|
+
// Launch the CES sibling alongside the daemon, in parallel — matching the
|
|
230
|
+
// Docker topology. Under the opt-in the assistant does not spawn its own CES,
|
|
231
|
+
// so a freshly hatched instance would otherwise come up with CES unavailable.
|
|
232
|
+
// startCes is a no-op unless CES_STANDALONE is set.
|
|
233
|
+
await Promise.all([
|
|
234
|
+
startCes(watch, resources),
|
|
235
|
+
startLocalDaemon(watch, resources, {
|
|
236
|
+
defaultWorkspaceConfigPath,
|
|
237
|
+
signingKey,
|
|
238
|
+
}),
|
|
239
|
+
]);
|
|
232
240
|
|
|
233
241
|
reporter.progress(4, 6, "Starting gateway...");
|
|
234
242
|
let runtimeUrl = `http://127.0.0.1:${resources.gatewayPort}`;
|
package/src/lib/local.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { createRequire } from "module";
|
|
|
11
11
|
import { homedir, networkInterfaces, platform, tmpdir } from "os";
|
|
12
12
|
import { basename, dirname, join } from "path";
|
|
13
13
|
|
|
14
|
+
import { isValidReleaseVersion } from "@vellumai/local-mode";
|
|
15
|
+
|
|
14
16
|
import {
|
|
15
17
|
getDaemonPidPath,
|
|
16
18
|
type LocalInstanceResources,
|
|
@@ -143,11 +145,36 @@ function localRuntimeGatewayDir(
|
|
|
143
145
|
return isGatewaySourceDir(candidate) ? candidate : undefined;
|
|
144
146
|
}
|
|
145
147
|
|
|
148
|
+
function localRuntimeCesDir(
|
|
149
|
+
resources: LocalInstanceResources | undefined,
|
|
150
|
+
): string | undefined {
|
|
151
|
+
const installDir = resources?.runtimeInstallDir;
|
|
152
|
+
if (!installDir) return undefined;
|
|
153
|
+
const candidate = packagePath(
|
|
154
|
+
installDir,
|
|
155
|
+
"@vellumai/credential-executor",
|
|
156
|
+
"",
|
|
157
|
+
);
|
|
158
|
+
return isCesSourceDir(candidate) ? candidate : undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
146
161
|
export function ensureLocalRuntime(
|
|
147
162
|
resources: LocalInstanceResources,
|
|
148
163
|
version: string,
|
|
149
164
|
options: { force?: boolean } = {},
|
|
150
165
|
): LocalRuntimeInstall {
|
|
166
|
+
// Reject anything that is not a trusted release identifier BEFORE it becomes
|
|
167
|
+
// a filesystem path segment or a `bun install` dependency spec. Without this,
|
|
168
|
+
// a package-manager spec (npm alias, tarball/git URL) or a `../`-laden string
|
|
169
|
+
// reaching this sink would install and then execute arbitrary attacker code
|
|
170
|
+
// as the local assistant runtime. Shares the validator with the host-bridge
|
|
171
|
+
// boundary guard (`runUpgrade`) so the two can never drift.
|
|
172
|
+
if (!isValidReleaseVersion(version)) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`Invalid runtime version '${version}': expected a release tag like v1.2.3 or 'latest'.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
151
178
|
const normalizedVersion = normalizeRuntimeVersion(version);
|
|
152
179
|
const displayVersion =
|
|
153
180
|
normalizedVersion === "latest" ? "latest" : `v${normalizedVersion}`;
|
|
@@ -333,6 +360,18 @@ function isGatewaySourceDir(dir: string): boolean {
|
|
|
333
360
|
}
|
|
334
361
|
}
|
|
335
362
|
|
|
363
|
+
function isCesSourceDir(dir: string): boolean {
|
|
364
|
+
const pkgPath = join(dir, "package.json");
|
|
365
|
+
if (!existsSync(pkgPath) || !existsSync(join(dir, "src", "main.ts")))
|
|
366
|
+
return false;
|
|
367
|
+
try {
|
|
368
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
369
|
+
return pkg.name === "@vellumai/credential-executor";
|
|
370
|
+
} catch {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
336
375
|
function findGatewaySourceFromCwd(): string | undefined {
|
|
337
376
|
let current = process.cwd();
|
|
338
377
|
while (true) {
|
|
@@ -526,6 +565,14 @@ function applyDaemonEnvOverrides(
|
|
|
526
565
|
env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH =
|
|
527
566
|
options.defaultWorkspaceConfigPath;
|
|
528
567
|
}
|
|
568
|
+
// When the CLI launches CES as a sibling (CES_STANDALONE), pin the daemon to
|
|
569
|
+
// the exact socket the sibling binds so the two agree regardless of any stale
|
|
570
|
+
// CES_LOCAL_SOCKET inherited from the parent environment. The assistant then
|
|
571
|
+
// connects to the sibling instead of spawning its own CES.
|
|
572
|
+
if (isCesSiblingOptIn()) {
|
|
573
|
+
env.CES_STANDALONE = "1";
|
|
574
|
+
env.CES_LOCAL_SOCKET = resolveCesSocketPath(resources);
|
|
575
|
+
}
|
|
529
576
|
applyIpcSocketDirOverride(env);
|
|
530
577
|
}
|
|
531
578
|
|
|
@@ -717,6 +764,168 @@ function resolveGatewayDir(resources?: LocalInstanceResources): string {
|
|
|
717
764
|
}
|
|
718
765
|
}
|
|
719
766
|
|
|
767
|
+
function resolveCesDir(resources?: LocalInstanceResources): string {
|
|
768
|
+
const runtimeCesDir = localRuntimeCesDir(resources);
|
|
769
|
+
if (runtimeCesDir) return runtimeCesDir;
|
|
770
|
+
|
|
771
|
+
// Source tree / npm sibling: cli/src/lib/ → ../../.. → credential-executor/
|
|
772
|
+
const sourceDir = join(
|
|
773
|
+
import.meta.dir,
|
|
774
|
+
"..",
|
|
775
|
+
"..",
|
|
776
|
+
"..",
|
|
777
|
+
"credential-executor",
|
|
778
|
+
);
|
|
779
|
+
if (isCesSourceDir(sourceDir)) {
|
|
780
|
+
return sourceDir;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// npm-installed elsewhere on disk: resolve via the package entry.
|
|
784
|
+
try {
|
|
785
|
+
const pkgPath = _require.resolve(
|
|
786
|
+
"@vellumai/credential-executor/package.json",
|
|
787
|
+
);
|
|
788
|
+
return dirname(pkgPath);
|
|
789
|
+
} catch {
|
|
790
|
+
throw new Error(
|
|
791
|
+
"credential-executor not found. Ensure @vellumai/credential-executor is installed or run from the source tree.",
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Resolve the Unix socket path the CLI-launched CES sibling binds and the
|
|
798
|
+
* daemon connects to. Both sides read `CES_LOCAL_SOCKET`, which the CLI sets to
|
|
799
|
+
* this exact path so they agree. On macOS, long workspace paths are relocated
|
|
800
|
+
* to a short tmpdir override (the same one the IPC sockets use) to stay under
|
|
801
|
+
* the AF_UNIX path limit.
|
|
802
|
+
*/
|
|
803
|
+
function resolveCesSocketPath(resources?: LocalInstanceResources): string {
|
|
804
|
+
const workspaceDir = resources
|
|
805
|
+
? join(resources.instanceDir, ".vellum", "workspace")
|
|
806
|
+
: join(homedir(), ".vellum", "workspace");
|
|
807
|
+
const override = computeIpcSocketDirOverride(workspaceDir);
|
|
808
|
+
const socketDir = override ?? workspaceDir;
|
|
809
|
+
mkdirSync(socketDir, { recursive: true });
|
|
810
|
+
return join(socketDir, "ces.sock");
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Whether the CLI should launch CES as an independent sibling process instead
|
|
815
|
+
* of leaving the assistant to spawn it as an stdio child. Temporary opt-in
|
|
816
|
+
* (`CES_STANDALONE=1`) while local CES converges onto the sibling model that
|
|
817
|
+
* containerized homes already use.
|
|
818
|
+
*/
|
|
819
|
+
function isCesSiblingOptIn(): boolean {
|
|
820
|
+
return process.env.CES_STANDALONE === "1";
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Launch the local CES sibling over a Unix socket (opted into via
|
|
825
|
+
* `CES_STANDALONE=1`). No-op unless the opt-in is set, in which case the
|
|
826
|
+
* assistant continues to spawn CES itself as today.
|
|
827
|
+
*
|
|
828
|
+
* The sibling runs with `CES_STANDALONE=1` so its lifecycle is anchored to
|
|
829
|
+
* SIGTERM rather than stdin EOF, mirroring the gateway: a CLI-owned process
|
|
830
|
+
* with a PID file under `.vellum/ces.pid`, started by `wake` and stopped by
|
|
831
|
+
* `sleep`.
|
|
832
|
+
*/
|
|
833
|
+
export async function startCes(
|
|
834
|
+
watch: boolean = false,
|
|
835
|
+
resources?: LocalInstanceResources,
|
|
836
|
+
): Promise<void> {
|
|
837
|
+
if (!isCesSiblingOptIn()) return;
|
|
838
|
+
|
|
839
|
+
const vellumDir = resources
|
|
840
|
+
? join(resources.instanceDir, ".vellum")
|
|
841
|
+
: join(homedir(), ".vellum");
|
|
842
|
+
const cesPidFile = join(vellumDir, "ces.pid");
|
|
843
|
+
|
|
844
|
+
// Kill any existing sibling first — a stale CES holds the socket and would
|
|
845
|
+
// corrupt the shared credential store if a second copy also bound it.
|
|
846
|
+
await stopProcessByPidFile(cesPidFile, "credential-executor");
|
|
847
|
+
|
|
848
|
+
console.log("🔐 Starting credential-executor sibling...");
|
|
849
|
+
|
|
850
|
+
const socketPath = resolveCesSocketPath(resources);
|
|
851
|
+
// A stale socket file from an unclean shutdown blocks re-bind; CES unlinks it
|
|
852
|
+
// on startup, but remove it here too so a leftover never masks a launch bug.
|
|
853
|
+
try {
|
|
854
|
+
unlinkSync(socketPath);
|
|
855
|
+
} catch {
|
|
856
|
+
/* no stale socket — fine */
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const securityDir = resources
|
|
860
|
+
? join(resources.instanceDir, ".vellum", "protected")
|
|
861
|
+
: join(homedir(), ".vellum", "protected");
|
|
862
|
+
const workspaceDir = resources
|
|
863
|
+
? join(resources.instanceDir, ".vellum", "workspace")
|
|
864
|
+
: join(homedir(), ".vellum", "workspace");
|
|
865
|
+
mkdirSync(securityDir, { recursive: true });
|
|
866
|
+
|
|
867
|
+
const cesEnv: Record<string, string | undefined> = {
|
|
868
|
+
...process.env,
|
|
869
|
+
CES_STANDALONE: "1",
|
|
870
|
+
CES_LOCAL_SOCKET: socketPath,
|
|
871
|
+
CREDENTIAL_SECURITY_DIR: securityDir,
|
|
872
|
+
VELLUM_WORKSPACE_DIR: workspaceDir,
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
let ces;
|
|
876
|
+
const runtimeCesDir = !watch ? localRuntimeCesDir(resources) : undefined;
|
|
877
|
+
const cesBinary = join(dirname(process.execPath), "credential-executor");
|
|
878
|
+
if (!runtimeCesDir && existsSync(cesBinary) && !watch) {
|
|
879
|
+
// Compiled binary alongside the CLI (desktop app / compiled CLI).
|
|
880
|
+
const cesLogFd = openLogFile("hatch.log");
|
|
881
|
+
ces = spawn(cesBinary, [], {
|
|
882
|
+
detached: true,
|
|
883
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
884
|
+
env: cesEnv,
|
|
885
|
+
});
|
|
886
|
+
pipeToLogFile(ces, cesLogFd, "credential-executor");
|
|
887
|
+
} else {
|
|
888
|
+
// Source tree / bunx: run the CES entry point via bun.
|
|
889
|
+
const cesDir = runtimeCesDir ?? resolveCesDir(resources);
|
|
890
|
+
const bunArgs = watch
|
|
891
|
+
? ["--watch", "run", "src/main.ts"]
|
|
892
|
+
: ["run", "src/main.ts"];
|
|
893
|
+
const cesLogFd = openLogFile("hatch.log");
|
|
894
|
+
ces = spawn(resolveBunExecutable(), bunArgs, {
|
|
895
|
+
cwd: cesDir,
|
|
896
|
+
detached: true,
|
|
897
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
898
|
+
env: envWithBunPath(cesEnv),
|
|
899
|
+
});
|
|
900
|
+
pipeToLogFile(ces, cesLogFd, "credential-executor");
|
|
901
|
+
if (watch) {
|
|
902
|
+
console.log(" credential-executor started in watch mode (bun --watch)");
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
ces.unref();
|
|
907
|
+
|
|
908
|
+
if (ces.pid) {
|
|
909
|
+
mkdirSync(vellumDir, { recursive: true });
|
|
910
|
+
writeFileSync(cesPidFile, String(ces.pid), "utf-8");
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Wait for the socket to appear so the daemon's discovery finds it on the
|
|
914
|
+
// first probe rather than burning its retry budget.
|
|
915
|
+
const deadline = Date.now() + 10_000;
|
|
916
|
+
while (Date.now() < deadline) {
|
|
917
|
+
if (existsSync(socketPath)) break;
|
|
918
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
919
|
+
}
|
|
920
|
+
if (!existsSync(socketPath)) {
|
|
921
|
+
console.warn(
|
|
922
|
+
"⚠ credential-executor started but its socket did not appear within 10s",
|
|
923
|
+
);
|
|
924
|
+
} else {
|
|
925
|
+
console.log("✅ credential-executor started\n");
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
720
929
|
/**
|
|
721
930
|
* Check if the daemon is responsive by hitting its HTTP `/healthz` endpoint.
|
|
722
931
|
* This replaces the socket-based `isSocketResponsive()` check.
|
|
@@ -1407,6 +1616,12 @@ export async function stopLocalProcesses(
|
|
|
1407
1616
|
const gatewayPidFile = join(vellumDir, "gateway.pid");
|
|
1408
1617
|
await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
|
|
1409
1618
|
|
|
1619
|
+
// Stop the CES sibling if one was launched (CES_STANDALONE). No-op when the
|
|
1620
|
+
// PID file is absent, so this is safe on the default topology where the
|
|
1621
|
+
// assistant owns CES as an stdio child.
|
|
1622
|
+
const cesPidFile = join(vellumDir, "ces.pid");
|
|
1623
|
+
await stopProcessByPidFile(cesPidFile, "credential-executor");
|
|
1624
|
+
|
|
1410
1625
|
// Kill ngrok directly by PID rather than using stopProcessByPidFile, because
|
|
1411
1626
|
// isVellumProcess() won't match the ngrok binary — resulting in a no-op that
|
|
1412
1627
|
// leaves ngrok running. Verify the PID still belongs to ngrok before killing
|
package/src/lib/process.ts
CHANGED
|
@@ -15,7 +15,7 @@ export function isVellumProcess(pid: number): boolean {
|
|
|
15
15
|
timeout: 3000,
|
|
16
16
|
stdio: ["ignore", "pipe", "ignore"],
|
|
17
17
|
}).trim();
|
|
18
|
-
return /vellum-daemon|vellum-cli|vellum-gateway|@vellumai|\/\.?vellum\/|\/daemon\/main|\/\.vellum\/.*qdrant\/bin\/qdrant/.test(
|
|
18
|
+
return /vellum-daemon|vellum-cli|vellum-gateway|credential-executor|@vellumai|\/\.?vellum\/|\/daemon\/main|\/\.vellum\/.*qdrant\/bin\/qdrant/.test(
|
|
19
19
|
output,
|
|
20
20
|
);
|
|
21
21
|
} catch {
|
package/src/lib/retire-local.ts
CHANGED
|
@@ -66,6 +66,18 @@ export async function retireLocal(
|
|
|
66
66
|
const gatewayPidFile = join(vellumDir, "gateway.pid");
|
|
67
67
|
await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
|
|
68
68
|
|
|
69
|
+
// Stop the CES sibling if one was launched (CES_STANDALONE). No-op when the
|
|
70
|
+
// PID file is absent — on the default topology the assistant owns CES as an
|
|
71
|
+
// stdio child and it exits with the daemon.
|
|
72
|
+
const cesPidFile = join(vellumDir, "ces.pid");
|
|
73
|
+
const cesStopped = await stopProcessByPidFile(
|
|
74
|
+
cesPidFile,
|
|
75
|
+
"credential-executor",
|
|
76
|
+
);
|
|
77
|
+
if (cesStopped) {
|
|
78
|
+
reporter.log("credential-executor stopped.");
|
|
79
|
+
}
|
|
80
|
+
|
|
69
81
|
// Stop Qdrant — the daemon's graceful shutdown tries to stop it via
|
|
70
82
|
// qdrantManager.stop(), but if the daemon was SIGKILL'd (after 2s timeout)
|
|
71
83
|
// Qdrant may still be running as an orphan. Check both the current PID file
|