@vellumai/cli 0.10.5-dev.202607042025.8a04d0b → 0.10.5-staging.1
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/index.ts +1 -1
- package/node_modules/@vellumai/local-mode/src/upgrade.ts +0 -43
- package/package.json +1 -1
- package/src/__tests__/sleep.test.ts +2 -10
- package/src/commands/recover.ts +3 -9
- package/src/commands/sleep.ts +0 -12
- package/src/commands/upgrade.ts +3 -11
- package/src/commands/wake.ts +2 -18
- package/src/lib/docker.ts +1 -39
- package/src/lib/hatch-local.ts +4 -12
- package/src/lib/local.ts +0 -215
- package/src/lib/process.ts +1 -1
- package/src/lib/retire-local.ts +0 -12
- package/node_modules/@vellumai/local-mode/src/__tests__/upgrade.test.ts +0 -128
- package/src/__tests__/local-runtime-version.test.ts +0 -42
- package/src/__tests__/retire-local.test.ts +0 -157
|
@@ -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
|
|
51
|
+
export { runUpgrade } from "./upgrade";
|
|
52
52
|
export type { UpgradeOptions, UpgradeResult } from "./upgrade";
|
|
53
53
|
export { getLocalAssistantStatus } from "./status";
|
|
54
54
|
export type {
|
|
@@ -14,37 +14,6 @@ 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
|
-
|
|
48
17
|
function extractVersion(output: string): string | undefined {
|
|
49
18
|
const versionPattern = "(v?[0-9]+(?:\\.[0-9]+)*(?:[-+][\\w.-]+)?)";
|
|
50
19
|
const upgraded = output.match(
|
|
@@ -66,18 +35,6 @@ export function runUpgrade(
|
|
|
66
35
|
options?: UpgradeOptions,
|
|
67
36
|
): Promise<UpgradeResult> {
|
|
68
37
|
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
|
-
|
|
81
38
|
const args = [...invocation.baseArgs, "upgrade", assistantId];
|
|
82
39
|
if (options?.latest) {
|
|
83
40
|
args.push("--latest");
|
package/package.json
CHANGED
|
@@ -139,8 +139,7 @@ describe("sleep command", () => {
|
|
|
139
139
|
consoleLog.mockRestore();
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
|
|
143
|
-
expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(3);
|
|
142
|
+
expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(2);
|
|
144
143
|
});
|
|
145
144
|
|
|
146
145
|
test("force stops the assistant even when an active call lease exists", async () => {
|
|
@@ -155,7 +154,7 @@ describe("sleep command", () => {
|
|
|
155
154
|
consoleLog.mockRestore();
|
|
156
155
|
}
|
|
157
156
|
|
|
158
|
-
expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(
|
|
157
|
+
expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(2);
|
|
159
158
|
// The assistant stop passes a generous 120s grace so the daemon's WAL
|
|
160
159
|
// checkpoint completes before any SIGKILL (default 2s would truncate it).
|
|
161
160
|
expect(stopProcessByPidFileMock).toHaveBeenNthCalledWith(
|
|
@@ -172,12 +171,5 @@ describe("sleep command", () => {
|
|
|
172
171
|
undefined,
|
|
173
172
|
7000,
|
|
174
173
|
);
|
|
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
|
-
);
|
|
182
174
|
});
|
|
183
175
|
});
|
package/src/commands/recover.ts
CHANGED
|
@@ -12,7 +12,6 @@ import { saveAssistantEntry } from "../lib/assistant-config";
|
|
|
12
12
|
import type { AssistantEntry } from "../lib/assistant-config";
|
|
13
13
|
import {
|
|
14
14
|
generateLocalSigningKey,
|
|
15
|
-
startCes,
|
|
16
15
|
startLocalDaemon,
|
|
17
16
|
startGateway,
|
|
18
17
|
} from "../lib/local";
|
|
@@ -118,14 +117,9 @@ export async function recover(): Promise<void> {
|
|
|
118
117
|
entry.guardianBootstrapSecret = bootstrapSecret;
|
|
119
118
|
saveAssistantEntry(entry);
|
|
120
119
|
|
|
121
|
-
// 8. Start
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
await Promise.all([
|
|
125
|
-
startCes(false, entry.resources),
|
|
126
|
-
startLocalDaemon(false, entry.resources, { signingKey }),
|
|
127
|
-
startGateway(false, entry.resources, { signingKey, bootstrapSecret }),
|
|
128
|
-
]);
|
|
120
|
+
// 8. Start daemon + gateway
|
|
121
|
+
await startLocalDaemon(false, entry.resources, { signingKey });
|
|
122
|
+
await startGateway(false, entry.resources, { signingKey, bootstrapSecret });
|
|
129
123
|
|
|
130
124
|
console.log(`✅ Recovered assistant '${name}'.`);
|
|
131
125
|
}
|
package/src/commands/sleep.ts
CHANGED
|
@@ -168,18 +168,6 @@ 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
|
-
|
|
183
171
|
// Stop the nginx ingress if one is fronting this gateway — otherwise it
|
|
184
172
|
// keeps running against a dead upstream and serves 502s.
|
|
185
173
|
const ingressStopped = await stopIngressNginx(join(vellumDir, "workspace"));
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -66,7 +66,6 @@ import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
|
|
|
66
66
|
import {
|
|
67
67
|
generateLocalSigningKey,
|
|
68
68
|
ensureLocalRuntime,
|
|
69
|
-
startCes,
|
|
70
69
|
startGateway,
|
|
71
70
|
startLocalDaemon,
|
|
72
71
|
stopLocalProcesses,
|
|
@@ -435,8 +434,7 @@ async function upgradeDocker(
|
|
|
435
434
|
}
|
|
436
435
|
|
|
437
436
|
// Recover the assistant host port from the entry, fall back to default.
|
|
438
|
-
const assistantPort =
|
|
439
|
-
entry.containerInfo?.assistantPort ?? ASSISTANT_INTERNAL_PORT;
|
|
437
|
+
const assistantPort = entry.containerInfo?.assistantPort ?? ASSISTANT_INTERNAL_PORT;
|
|
440
438
|
|
|
441
439
|
// Create pre-upgrade backup (best-effort, daemon must be running)
|
|
442
440
|
await broadcastUpgradeEvent(
|
|
@@ -986,14 +984,8 @@ async function upgradeLocal(
|
|
|
986
984
|
const previousAppVersion = process.env.APP_VERSION;
|
|
987
985
|
process.env.APP_VERSION = stripVersionPrefix(targetVersion);
|
|
988
986
|
try {
|
|
989
|
-
|
|
990
|
-
|
|
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
|
-
]);
|
|
987
|
+
await startLocalDaemon(false, entry.resources, { signingKey });
|
|
988
|
+
await startGateway(false, entry.resources, { signingKey, bootstrapSecret });
|
|
997
989
|
} finally {
|
|
998
990
|
if (previousAppVersion === undefined) {
|
|
999
991
|
delete process.env.APP_VERSION;
|
package/src/commands/wake.ts
CHANGED
|
@@ -17,7 +17,6 @@ import {
|
|
|
17
17
|
generateLocalSigningKey,
|
|
18
18
|
isAssistantWatchModeAvailable,
|
|
19
19
|
isGatewayWatchModeAvailable,
|
|
20
|
-
startCes,
|
|
21
20
|
startLocalDaemon,
|
|
22
21
|
startGateway,
|
|
23
22
|
} from "../lib/local";
|
|
@@ -177,18 +176,7 @@ export async function wake(): Promise<void> {
|
|
|
177
176
|
}
|
|
178
177
|
|
|
179
178
|
if (!daemonRunning) {
|
|
180
|
-
|
|
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
|
-
]);
|
|
179
|
+
await startLocalDaemon(watch, resources, { foreground, signingKey });
|
|
192
180
|
}
|
|
193
181
|
|
|
194
182
|
// Start gateway
|
|
@@ -268,11 +256,7 @@ export async function wake(): Promise<void> {
|
|
|
268
256
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
269
257
|
try {
|
|
270
258
|
await resetGuardianBootstrap(loopbackUrl, bootstrapSecret);
|
|
271
|
-
await leaseGuardianToken(
|
|
272
|
-
loopbackUrl,
|
|
273
|
-
entry.assistantId,
|
|
274
|
-
bootstrapSecret,
|
|
275
|
-
);
|
|
259
|
+
await leaseGuardianToken(loopbackUrl, entry.assistantId, bootstrapSecret);
|
|
276
260
|
console.log(" Re-provisioned guardian token.");
|
|
277
261
|
break;
|
|
278
262
|
} catch (err) {
|
package/src/lib/docker.ts
CHANGED
|
@@ -1626,45 +1626,7 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1626
1626
|
log(` Container: ${containerName}`);
|
|
1627
1627
|
log("");
|
|
1628
1628
|
log(`Stop with: vellum retire ${instanceName}`);
|
|
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 };
|
|
1629
|
+
return { ready: true };
|
|
1668
1630
|
}
|
|
1669
1631
|
|
|
1670
1632
|
log(` Container: ${containerName}`);
|
package/src/lib/hatch-local.ts
CHANGED
|
@@ -25,7 +25,6 @@ import type { Species } from "./constants.js";
|
|
|
25
25
|
import { buildHatchConfigValues, writeInitialConfig } from "./config-utils.js";
|
|
26
26
|
import {
|
|
27
27
|
generateLocalSigningKey,
|
|
28
|
-
startCes,
|
|
29
28
|
startLocalDaemon,
|
|
30
29
|
startGateway,
|
|
31
30
|
stopLocalProcesses,
|
|
@@ -226,17 +225,10 @@ export async function hatchLocal(
|
|
|
226
225
|
reporter.progress(3, 6, "Starting assistant...");
|
|
227
226
|
const signingKey = generateLocalSigningKey();
|
|
228
227
|
const bootstrapSecret = generateLocalSigningKey();
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
await Promise.all([
|
|
234
|
-
startCes(watch, resources),
|
|
235
|
-
startLocalDaemon(watch, resources, {
|
|
236
|
-
defaultWorkspaceConfigPath,
|
|
237
|
-
signingKey,
|
|
238
|
-
}),
|
|
239
|
-
]);
|
|
228
|
+
await startLocalDaemon(watch, resources, {
|
|
229
|
+
defaultWorkspaceConfigPath,
|
|
230
|
+
signingKey,
|
|
231
|
+
});
|
|
240
232
|
|
|
241
233
|
reporter.progress(4, 6, "Starting gateway...");
|
|
242
234
|
let runtimeUrl = `http://127.0.0.1:${resources.gatewayPort}`;
|
package/src/lib/local.ts
CHANGED
|
@@ -11,8 +11,6 @@ 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
|
-
|
|
16
14
|
import {
|
|
17
15
|
getDaemonPidPath,
|
|
18
16
|
type LocalInstanceResources,
|
|
@@ -145,36 +143,11 @@ function localRuntimeGatewayDir(
|
|
|
145
143
|
return isGatewaySourceDir(candidate) ? candidate : undefined;
|
|
146
144
|
}
|
|
147
145
|
|
|
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
|
-
|
|
161
146
|
export function ensureLocalRuntime(
|
|
162
147
|
resources: LocalInstanceResources,
|
|
163
148
|
version: string,
|
|
164
149
|
options: { force?: boolean } = {},
|
|
165
150
|
): 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
|
-
|
|
178
151
|
const normalizedVersion = normalizeRuntimeVersion(version);
|
|
179
152
|
const displayVersion =
|
|
180
153
|
normalizedVersion === "latest" ? "latest" : `v${normalizedVersion}`;
|
|
@@ -360,18 +333,6 @@ function isGatewaySourceDir(dir: string): boolean {
|
|
|
360
333
|
}
|
|
361
334
|
}
|
|
362
335
|
|
|
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
|
-
|
|
375
336
|
function findGatewaySourceFromCwd(): string | undefined {
|
|
376
337
|
let current = process.cwd();
|
|
377
338
|
while (true) {
|
|
@@ -565,14 +526,6 @@ function applyDaemonEnvOverrides(
|
|
|
565
526
|
env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH =
|
|
566
527
|
options.defaultWorkspaceConfigPath;
|
|
567
528
|
}
|
|
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
|
-
}
|
|
576
529
|
applyIpcSocketDirOverride(env);
|
|
577
530
|
}
|
|
578
531
|
|
|
@@ -764,168 +717,6 @@ function resolveGatewayDir(resources?: LocalInstanceResources): string {
|
|
|
764
717
|
}
|
|
765
718
|
}
|
|
766
719
|
|
|
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
|
-
|
|
929
720
|
/**
|
|
930
721
|
* Check if the daemon is responsive by hitting its HTTP `/healthz` endpoint.
|
|
931
722
|
* This replaces the socket-based `isSocketResponsive()` check.
|
|
@@ -1616,12 +1407,6 @@ export async function stopLocalProcesses(
|
|
|
1616
1407
|
const gatewayPidFile = join(vellumDir, "gateway.pid");
|
|
1617
1408
|
await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
|
|
1618
1409
|
|
|
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
|
-
|
|
1625
1410
|
// Kill ngrok directly by PID rather than using stopProcessByPidFile, because
|
|
1626
1411
|
// isVellumProcess() won't match the ngrok binary — resulting in a no-op that
|
|
1627
1412
|
// 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
|
|
18
|
+
return /vellum-daemon|vellum-cli|vellum-gateway|@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,18 +66,6 @@ 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
|
-
|
|
81
69
|
// Stop Qdrant — the daemon's graceful shutdown tries to stop it via
|
|
82
70
|
// qdrantManager.stop(), but if the daemon was SIGKILL'd (after 2s timeout)
|
|
83
71
|
// Qdrant may still be running as an orphan. Check both the current PID file
|
|
@@ -1,128 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,42 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,157 +0,0 @@
|
|
|
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
|
-
});
|