@vellumai/cli 0.8.5 → 0.8.7-dev.202606052118.34cd356
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +6 -0
- package/bun.lock +8 -0
- package/knip.json +6 -1
- package/node_modules/@vellumai/environments/bun.lock +24 -0
- package/node_modules/@vellumai/environments/package.json +18 -0
- package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
- package/node_modules/@vellumai/environments/src/index.ts +11 -0
- package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
- package/node_modules/@vellumai/environments/tsconfig.json +20 -0
- package/node_modules/@vellumai/local-mode/bun.lock +29 -0
- package/node_modules/@vellumai/local-mode/package.json +22 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +108 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +66 -0
- package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +109 -0
- package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
- package/node_modules/@vellumai/local-mode/src/hatch.ts +92 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +48 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +133 -0
- package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
- package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
- package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
- package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
- package/package.json +12 -1
- package/src/__tests__/assistant-client-refresh.test.ts +182 -0
- package/src/__tests__/backup.test.ts +38 -0
- package/src/__tests__/clean.test.ts +179 -0
- package/src/__tests__/client-token.test.ts +87 -0
- package/src/__tests__/client-tui-refresh.test.ts +170 -0
- package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
- package/src/__tests__/connect-import.test.ts +317 -0
- package/src/__tests__/devices.test.ts +272 -0
- package/src/__tests__/env-drift.test.ts +32 -44
- package/src/__tests__/flags.test.ts +248 -0
- package/src/__tests__/guardian-token.test.ts +126 -2
- package/src/__tests__/multi-local.test.ts +1 -1
- package/src/__tests__/orphan-detection.test.ts +8 -6
- package/src/__tests__/pair.test.ts +271 -0
- package/src/__tests__/paired-lifecycle.test.ts +116 -0
- package/src/__tests__/recover.test.ts +307 -0
- package/src/__tests__/segments-to-plain-text.test.ts +37 -0
- package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
- package/src/__tests__/unpair.test.ts +163 -0
- package/src/__tests__/wake.test.ts +215 -0
- package/src/commands/backup.ts +2 -0
- package/src/commands/client.ts +569 -39
- package/src/commands/connect/import.ts +217 -0
- package/src/commands/connect.ts +31 -0
- package/src/commands/devices.ts +247 -0
- package/src/commands/env.ts +1 -1
- package/src/commands/flags.ts +269 -0
- package/src/commands/gateway/token.ts +73 -0
- package/src/commands/gateway.ts +29 -0
- package/src/commands/logs.ts +6 -18
- package/src/commands/pair.ts +222 -0
- package/src/commands/ps.ts +57 -41
- package/src/commands/recover.ts +47 -9
- package/src/commands/restore.ts +8 -1
- package/src/commands/retire.ts +23 -70
- package/src/commands/rollback.ts +2 -14
- package/src/commands/sleep.ts +7 -0
- package/src/commands/ssh.ts +5 -24
- package/src/commands/teleport.ts +34 -26
- package/src/commands/tunnel.ts +46 -2
- package/src/commands/unpair.ts +118 -0
- package/src/commands/upgrade.ts +8 -16
- package/src/commands/wake.ts +75 -45
- package/src/components/DefaultMainScreen.tsx +100 -14
- package/src/index.ts +22 -0
- package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
- package/src/lib/__tests__/step-runner.test.ts +49 -1
- package/src/lib/assistant-client.ts +58 -37
- package/src/lib/assistant-config.ts +28 -3
- package/src/lib/cloudflare-tunnel.ts +276 -0
- package/src/lib/config-utils.ts +24 -3
- package/src/lib/confirm-action.ts +57 -0
- package/src/lib/docker.ts +82 -8
- package/src/lib/environments/__tests__/paths.test.ts +2 -1
- package/src/lib/environments/__tests__/seeds.test.ts +2 -1
- package/src/lib/environments/paths.ts +1 -1
- package/src/lib/environments/resolve.ts +11 -35
- package/src/lib/guardian-token.ts +132 -9
- package/src/lib/hatch-local.ts +75 -33
- package/src/lib/http-client.ts +1 -3
- package/src/lib/lifecycle-reporter.ts +31 -0
- package/src/lib/local.ts +193 -298
- package/src/lib/orphan-detection.ts +9 -5
- package/src/lib/pgrep.ts +5 -1
- package/src/lib/platform-client.ts +97 -49
- package/src/lib/process.ts +109 -39
- package/src/lib/retire-local.ts +28 -14
- package/src/lib/segments-to-plain-text.ts +35 -0
- package/src/lib/step-runner.ts +67 -7
- package/src/lib/sync-cloud-assistants.ts +17 -0
- /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import type { CliInvocation } from "./util";
|
|
4
|
+
|
|
5
|
+
const RETIRE_TIMEOUT_MS = 60_000;
|
|
6
|
+
|
|
7
|
+
export type RetireResult =
|
|
8
|
+
| { ok: true }
|
|
9
|
+
| { ok: false; status: number; error: string };
|
|
10
|
+
|
|
11
|
+
export function runRetire(
|
|
12
|
+
invocation: CliInvocation,
|
|
13
|
+
assistantId: string,
|
|
14
|
+
): Promise<RetireResult> {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const child = spawn(
|
|
17
|
+
invocation.command,
|
|
18
|
+
[...invocation.baseArgs, "retire", assistantId, "--yes"],
|
|
19
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
let stdout = "";
|
|
23
|
+
let stderr = "";
|
|
24
|
+
let done = false;
|
|
25
|
+
|
|
26
|
+
const finish = (result: RetireResult) => {
|
|
27
|
+
if (done) return;
|
|
28
|
+
done = true;
|
|
29
|
+
clearTimeout(timeout);
|
|
30
|
+
resolve(result);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const timeout = setTimeout(() => {
|
|
34
|
+
child.kill("SIGTERM");
|
|
35
|
+
finish({ ok: false, status: 500, error: "Retire timed out after 60 seconds" });
|
|
36
|
+
}, RETIRE_TIMEOUT_MS);
|
|
37
|
+
|
|
38
|
+
child.stdout.on("data", (data: Buffer) => {
|
|
39
|
+
stdout += data.toString();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
child.stderr.on("data", (data: Buffer) => {
|
|
43
|
+
stderr += data.toString();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
child.on("close", (code) => {
|
|
47
|
+
if (code === 0) {
|
|
48
|
+
finish({ ok: true });
|
|
49
|
+
} else {
|
|
50
|
+
finish({ ok: false, status: 500, error: stderr || stdout });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
child.on("error", (err) => {
|
|
55
|
+
finish({ ok: false, status: 500, error: `Failed to spawn CLI: ${err.message}` });
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const CLI_PACKAGE_NAME = "@vellumai/cli";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* How to invoke the Vellum CLI as a child process: a base command plus the
|
|
9
|
+
* leading arguments that precede the subcommand. Each host resolves its own
|
|
10
|
+
* invocation — the dev hosts run the CLI from source via `bun run <entry>`,
|
|
11
|
+
* a packaged host would point at its bundled runtime — and the shared
|
|
12
|
+
* lifecycle ops (`runHatch`, `runRetire`, guardian-token refresh) append
|
|
13
|
+
* their subcommand args to `baseArgs`.
|
|
14
|
+
*/
|
|
15
|
+
export interface CliInvocation {
|
|
16
|
+
command: string;
|
|
17
|
+
baseArgs: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const SENSITIVE_FIELDS = [
|
|
21
|
+
"signingKey",
|
|
22
|
+
"bearerToken",
|
|
23
|
+
"guardianBootstrapSecret",
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
export function stripSensitiveFields(data: Record<string, unknown>): void {
|
|
27
|
+
const assistants = data.assistants;
|
|
28
|
+
if (!Array.isArray(assistants)) return;
|
|
29
|
+
for (const assistant of assistants) {
|
|
30
|
+
if (assistant && typeof assistant === "object") {
|
|
31
|
+
const entry = assistant as Record<string, unknown>;
|
|
32
|
+
for (const field of SENSITIVE_FIELDS) {
|
|
33
|
+
delete entry[field];
|
|
34
|
+
}
|
|
35
|
+
const resources = entry.resources;
|
|
36
|
+
if (resources && typeof resources === "object") {
|
|
37
|
+
for (const field of SENSITIVE_FIELDS) {
|
|
38
|
+
delete (resources as Record<string, unknown>)[field];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isLoopbackAddr(addr: string): boolean {
|
|
46
|
+
const v4Mapped = addr.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
|
|
47
|
+
const normalized = v4Mapped ? v4Mapped[1]! : addr;
|
|
48
|
+
if (normalized.includes(".")) {
|
|
49
|
+
return normalized.startsWith("127.");
|
|
50
|
+
}
|
|
51
|
+
return normalized === "::1";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let _resolvedCliPath: string | undefined;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the CLI entry point.
|
|
58
|
+
*
|
|
59
|
+
* 1. Source tree — `<baseDir>/cli/src/index.ts` (dev mode in monorepo).
|
|
60
|
+
* 2. Installed package — `require.resolve("@vellumai/cli/package.json")`.
|
|
61
|
+
*/
|
|
62
|
+
function resolveCliPath(baseDir: string, importMetaUrl?: string): string {
|
|
63
|
+
if (_resolvedCliPath) return _resolvedCliPath;
|
|
64
|
+
|
|
65
|
+
const sourceTreePath = path.join(baseDir, "cli", "src", "index.ts");
|
|
66
|
+
if (fs.existsSync(sourceTreePath)) {
|
|
67
|
+
_resolvedCliPath = sourceTreePath;
|
|
68
|
+
return _resolvedCliPath;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const _require = createRequire(importMetaUrl ?? `file://${baseDir}/`);
|
|
72
|
+
try {
|
|
73
|
+
const pkgPath = _require.resolve(`${CLI_PACKAGE_NAME}/package.json`);
|
|
74
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { bin?: Record<string, string> };
|
|
75
|
+
const binEntry = pkg.bin?.["vellum"];
|
|
76
|
+
if (binEntry) {
|
|
77
|
+
const entryPoint = path.resolve(path.dirname(pkgPath), binEntry);
|
|
78
|
+
if (fs.existsSync(entryPoint)) {
|
|
79
|
+
_resolvedCliPath = entryPoint;
|
|
80
|
+
return _resolvedCliPath;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// Not found in node_modules
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Vellum CLI not found. Looked for source tree at ${sourceTreePath} and npm package ${CLI_PACKAGE_NAME}.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build the CLI invocation used by the dev hosts (CLI client server and the
|
|
94
|
+
* web dev-server middleware), which run the CLI from source via Bun:
|
|
95
|
+
* `bun run <cli-entry> <subcommand> …`.
|
|
96
|
+
*/
|
|
97
|
+
export function resolveDevCliInvocation(
|
|
98
|
+
baseDir: string,
|
|
99
|
+
importMetaUrl?: string,
|
|
100
|
+
): CliInvocation {
|
|
101
|
+
return { command: "bun", baseArgs: ["run", resolveCliPath(baseDir, importMetaUrl)] };
|
|
102
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import type { CliInvocation } from "./util";
|
|
4
|
+
|
|
5
|
+
// `wake` cold-starts a stopped assistant, so it can legitimately run far
|
|
6
|
+
// longer than a teardown like `retire`: the CLI waits up to 60s for the daemon
|
|
7
|
+
// to answer (plus another 60s if it falls back to a source daemon) and up to
|
|
8
|
+
// 30s for the gateway. The wrapper timeout is a safety net for a truly hung
|
|
9
|
+
// process, so it must sit above those documented readiness windows — otherwise
|
|
10
|
+
// a slow-but-succeeding wake gets killed and misreported as a timeout.
|
|
11
|
+
const WAKE_TIMEOUT_MS = 180_000;
|
|
12
|
+
|
|
13
|
+
export type WakeResult =
|
|
14
|
+
| { ok: true }
|
|
15
|
+
| { ok: false; status: number; error: string };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Start (or restart) a local assistant's daemon and gateway via the CLI's
|
|
19
|
+
* `wake`, which also re-seeds the guardian token from a sibling environment.
|
|
20
|
+
*
|
|
21
|
+
* This is the non-destructive repair primitive: it revives a stopped or
|
|
22
|
+
* mis-seeded local assistant in place without touching its data or identity,
|
|
23
|
+
* the same way the native client re-pairs on a failed connection. Mirrors
|
|
24
|
+
* {@link runRetire}'s never-reject contract so each host wires transport once
|
|
25
|
+
* and surfaces a structured failure rather than a thrown error.
|
|
26
|
+
*/
|
|
27
|
+
export function runWake(
|
|
28
|
+
invocation: CliInvocation,
|
|
29
|
+
assistantId: string,
|
|
30
|
+
): Promise<WakeResult> {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const child = spawn(
|
|
33
|
+
invocation.command,
|
|
34
|
+
[...invocation.baseArgs, "wake", assistantId],
|
|
35
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
let stdout = "";
|
|
39
|
+
let stderr = "";
|
|
40
|
+
let done = false;
|
|
41
|
+
|
|
42
|
+
const finish = (result: WakeResult) => {
|
|
43
|
+
if (done) return;
|
|
44
|
+
done = true;
|
|
45
|
+
clearTimeout(timeout);
|
|
46
|
+
resolve(result);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const timeout = setTimeout(() => {
|
|
50
|
+
child.kill("SIGTERM");
|
|
51
|
+
finish({
|
|
52
|
+
ok: false,
|
|
53
|
+
status: 500,
|
|
54
|
+
error: `Wake timed out after ${WAKE_TIMEOUT_MS / 1000} seconds`,
|
|
55
|
+
});
|
|
56
|
+
}, WAKE_TIMEOUT_MS);
|
|
57
|
+
|
|
58
|
+
child.stdout.on("data", (data: Buffer) => {
|
|
59
|
+
stdout += data.toString();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
child.stderr.on("data", (data: Buffer) => {
|
|
63
|
+
stderr += data.toString();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
child.on("close", (code) => {
|
|
67
|
+
if (code === 0) {
|
|
68
|
+
finish({ ok: true });
|
|
69
|
+
} else {
|
|
70
|
+
finish({ ok: false, status: 500, error: stderr || stdout });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
child.on("error", (err) => {
|
|
75
|
+
finish({ ok: false, status: 500, error: `Failed to spawn CLI: ${err.message}` });
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"types": ["bun-types"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*"],
|
|
15
|
+
"exclude": ["node_modules"]
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vellumai/cli",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.7-dev.202606052118.34cd356",
|
|
4
4
|
"description": "CLI tools for vellum-assistant",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
"./package.json": "./package.json",
|
|
9
9
|
"./src/components/DefaultMainScreen": "./src/components/DefaultMainScreen.tsx",
|
|
10
10
|
"./src/lib/constants": "./src/lib/constants.ts",
|
|
11
|
+
"./src/lib/hatch-local": "./src/lib/hatch-local.ts",
|
|
12
|
+
"./src/lib/retire-local": "./src/lib/retire-local.ts",
|
|
13
|
+
"./src/lib/guardian-token": "./src/lib/guardian-token.ts",
|
|
14
|
+
"./src/lib/lifecycle-reporter": "./src/lib/lifecycle-reporter.ts",
|
|
11
15
|
"./src/commands/*": "./src/commands/*.ts"
|
|
12
16
|
},
|
|
13
17
|
"bin": {
|
|
@@ -18,18 +22,25 @@
|
|
|
18
22
|
"format:check": "prettier --check .",
|
|
19
23
|
"lint": "eslint",
|
|
20
24
|
"lint:unused": "knip --include files,dependencies,unlisted",
|
|
25
|
+
"prepack": "node ../scripts/prepack-bundled-deps.mjs",
|
|
21
26
|
"test": "bun test",
|
|
22
27
|
"typecheck": "bunx tsc --noEmit"
|
|
23
28
|
},
|
|
24
29
|
"author": "Vellum AI",
|
|
25
30
|
"license": "MIT",
|
|
26
31
|
"dependencies": {
|
|
32
|
+
"@vellumai/environments": "file:../packages/environments",
|
|
33
|
+
"@vellumai/local-mode": "file:../packages/local-mode",
|
|
27
34
|
"chalk": "5.6.2",
|
|
28
35
|
"ink": "6.8.0",
|
|
29
36
|
"nanoid": "5.1.7",
|
|
30
37
|
"react": "19.2.4",
|
|
31
38
|
"react-devtools-core": "6.1.5"
|
|
32
39
|
},
|
|
40
|
+
"bundledDependencies": [
|
|
41
|
+
"@vellumai/environments",
|
|
42
|
+
"@vellumai/local-mode"
|
|
43
|
+
],
|
|
33
44
|
"devDependencies": {
|
|
34
45
|
"@types/bun": "1.3.11",
|
|
35
46
|
"@types/react": "19.2.14",
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for AssistantClient's reactive 401 -> refresh -> retry: a paired/local
|
|
3
|
+
* guardian access token that 401s is refreshed once via the stored refresh
|
|
4
|
+
* credential and the request retried. Self-gating (no refresh token => no
|
|
5
|
+
* retry) and never applied to the platform session-auth path.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
afterAll,
|
|
9
|
+
afterEach,
|
|
10
|
+
beforeEach,
|
|
11
|
+
describe,
|
|
12
|
+
expect,
|
|
13
|
+
test,
|
|
14
|
+
} from "bun:test";
|
|
15
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
const testDir = mkdtempSync(join(tmpdir(), "client-refresh-test-"));
|
|
20
|
+
const ORIGINAL_LOCKFILE_DIR = process.env.VELLUM_LOCKFILE_DIR;
|
|
21
|
+
const ORIGINAL_CONFIG_HOME = process.env.XDG_CONFIG_HOME;
|
|
22
|
+
const ORIGINAL_FETCH = globalThis.fetch;
|
|
23
|
+
|
|
24
|
+
import { AssistantClient } from "../lib/assistant-client.js";
|
|
25
|
+
import { saveAssistantEntry } from "../lib/assistant-config.js";
|
|
26
|
+
import { loadGuardianToken, saveGuardianToken } from "../lib/guardian-token.js";
|
|
27
|
+
|
|
28
|
+
const RUNTIME = "http://10.0.0.9:7830";
|
|
29
|
+
const FUTURE = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
|
|
30
|
+
|
|
31
|
+
function seedPaired(refreshToken: string): void {
|
|
32
|
+
saveAssistantEntry({
|
|
33
|
+
assistantId: "px",
|
|
34
|
+
name: "Paired",
|
|
35
|
+
runtimeUrl: RUNTIME,
|
|
36
|
+
cloud: "paired",
|
|
37
|
+
paired: true,
|
|
38
|
+
species: "vellum",
|
|
39
|
+
});
|
|
40
|
+
saveGuardianToken("px", {
|
|
41
|
+
guardianPrincipalId: "imported",
|
|
42
|
+
accessToken: "old-acc",
|
|
43
|
+
accessTokenExpiresAt: FUTURE,
|
|
44
|
+
refreshToken,
|
|
45
|
+
refreshTokenExpiresAt: refreshToken ? FUTURE : 0,
|
|
46
|
+
refreshAfter: "",
|
|
47
|
+
isNew: false,
|
|
48
|
+
deviceId: "dev",
|
|
49
|
+
leasedAt: new Date().toISOString(),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface Call {
|
|
54
|
+
url: string;
|
|
55
|
+
headers: Record<string, string>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Replace global fetch with a URL-routed stub; returns the call log. */
|
|
59
|
+
function stubFetch(handler: (url: string, calls: Call[]) => Response): Call[] {
|
|
60
|
+
const calls: Call[] = [];
|
|
61
|
+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
62
|
+
const url = typeof input === "string" ? input : String(input);
|
|
63
|
+
calls.push({
|
|
64
|
+
url,
|
|
65
|
+
headers: (init?.headers ?? {}) as Record<string, string>,
|
|
66
|
+
});
|
|
67
|
+
return handler(url, calls);
|
|
68
|
+
}) as typeof fetch;
|
|
69
|
+
return calls;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const isRefresh = (url: string) => url.includes("/v1/guardian/refresh");
|
|
73
|
+
|
|
74
|
+
function refreshResponse(): Response {
|
|
75
|
+
return new Response(
|
|
76
|
+
JSON.stringify({
|
|
77
|
+
accessToken: "new-acc",
|
|
78
|
+
refreshToken: "new-ref",
|
|
79
|
+
accessTokenExpiresAt: FUTURE,
|
|
80
|
+
refreshTokenExpiresAt: FUTURE,
|
|
81
|
+
refreshAfter: "",
|
|
82
|
+
}),
|
|
83
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe("AssistantClient 401 -> refresh -> retry", () => {
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
90
|
+
process.env.XDG_CONFIG_HOME = testDir;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
globalThis.fetch = ORIGINAL_FETCH;
|
|
95
|
+
if (ORIGINAL_LOCKFILE_DIR === undefined)
|
|
96
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
97
|
+
else process.env.VELLUM_LOCKFILE_DIR = ORIGINAL_LOCKFILE_DIR;
|
|
98
|
+
if (ORIGINAL_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME;
|
|
99
|
+
else process.env.XDG_CONFIG_HOME = ORIGINAL_CONFIG_HOME;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterAll(() => {
|
|
103
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("refreshes and retries once on 401, persisting the new token", async () => {
|
|
107
|
+
seedPaired("refresh-tok");
|
|
108
|
+
let assistantAttempts = 0;
|
|
109
|
+
const calls = stubFetch((url) => {
|
|
110
|
+
if (isRefresh(url)) return refreshResponse();
|
|
111
|
+
assistantAttempts++;
|
|
112
|
+
return new Response("", { status: assistantAttempts === 1 ? 401 : 200 });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const client = new AssistantClient({ assistantId: "px" });
|
|
116
|
+
const res = await client.get("/messages/");
|
|
117
|
+
|
|
118
|
+
expect(res.status).toBe(200);
|
|
119
|
+
expect(assistantAttempts).toBe(2); // original + one retry
|
|
120
|
+
expect(calls.filter((c) => isRefresh(c.url))).toHaveLength(1);
|
|
121
|
+
// The retry carried the refreshed bearer token.
|
|
122
|
+
const assistantCalls = calls.filter((c) => !isRefresh(c.url));
|
|
123
|
+
expect(assistantCalls[1].headers["Authorization"]).toBe("Bearer new-acc");
|
|
124
|
+
// refreshGuardianToken persisted the rotated token.
|
|
125
|
+
expect(loadGuardianToken("px")?.accessToken).toBe("new-acc");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("does not retry when there is no stored refresh token", async () => {
|
|
129
|
+
seedPaired(""); // access-only
|
|
130
|
+
let assistantAttempts = 0;
|
|
131
|
+
const calls = stubFetch((url) => {
|
|
132
|
+
if (isRefresh(url)) return refreshResponse();
|
|
133
|
+
assistantAttempts++;
|
|
134
|
+
return new Response("", { status: 401 });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const client = new AssistantClient({ assistantId: "px" });
|
|
138
|
+
const res = await client.get("/messages/");
|
|
139
|
+
|
|
140
|
+
expect(res.status).toBe(401);
|
|
141
|
+
expect(assistantAttempts).toBe(1); // no retry
|
|
142
|
+
expect(calls.filter((c) => isRefresh(c.url))).toHaveLength(0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("never refreshes on the platform session-auth path", async () => {
|
|
146
|
+
seedPaired("refresh-tok"); // entry must exist; session auth ignores it
|
|
147
|
+
let assistantAttempts = 0;
|
|
148
|
+
const calls = stubFetch((url) => {
|
|
149
|
+
if (isRefresh(url)) return refreshResponse();
|
|
150
|
+
assistantAttempts++;
|
|
151
|
+
return new Response("", { status: 401 });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const client = new AssistantClient({
|
|
155
|
+
assistantId: "px",
|
|
156
|
+
sessionToken: "sess-tok",
|
|
157
|
+
orgId: "org-1",
|
|
158
|
+
});
|
|
159
|
+
const res = await client.get("/messages/");
|
|
160
|
+
|
|
161
|
+
expect(res.status).toBe(401);
|
|
162
|
+
expect(assistantAttempts).toBe(1);
|
|
163
|
+
expect(calls.filter((c) => isRefresh(c.url))).toHaveLength(0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("retries at most once (second 401 is not refreshed again)", async () => {
|
|
167
|
+
seedPaired("refresh-tok");
|
|
168
|
+
let assistantAttempts = 0;
|
|
169
|
+
const calls = stubFetch((url) => {
|
|
170
|
+
if (isRefresh(url)) return refreshResponse();
|
|
171
|
+
assistantAttempts++;
|
|
172
|
+
return new Response("", { status: 401 }); // always 401
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const client = new AssistantClient({ assistantId: "px" });
|
|
176
|
+
const res = await client.get("/messages/");
|
|
177
|
+
|
|
178
|
+
expect(res.status).toBe(401);
|
|
179
|
+
expect(assistantAttempts).toBe(2); // original + one retry, no more
|
|
180
|
+
expect(calls.filter((c) => isRefresh(c.url))).toHaveLength(1);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -162,6 +162,16 @@ beforeEach(() => {
|
|
|
162
162
|
});
|
|
163
163
|
getBackupsDirMock.mockReset();
|
|
164
164
|
getBackupsDirMock.mockReturnValue("/tmp/backups-default");
|
|
165
|
+
loadGuardianTokenSpy.mockReset();
|
|
166
|
+
loadGuardianTokenSpy.mockReturnValue({
|
|
167
|
+
accessToken: "local-token",
|
|
168
|
+
accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
169
|
+
} as unknown as ReturnType<typeof guardianToken.loadGuardianToken>);
|
|
170
|
+
leaseGuardianTokenSpy.mockReset();
|
|
171
|
+
leaseGuardianTokenSpy.mockResolvedValue({
|
|
172
|
+
accessToken: "leased-token",
|
|
173
|
+
accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
174
|
+
} as unknown as Awaited<ReturnType<typeof guardianToken.leaseGuardianToken>>);
|
|
165
175
|
mkdirSyncMock.mockReset();
|
|
166
176
|
mkdirSyncMock.mockImplementation((() => undefined) as never);
|
|
167
177
|
writeFileSyncMock.mockReset();
|
|
@@ -207,6 +217,34 @@ function mockGcsDownload(body: Uint8Array, ok = true, status = 200) {
|
|
|
207
217
|
}) as unknown as typeof globalThis.fetch;
|
|
208
218
|
}
|
|
209
219
|
|
|
220
|
+
describe("vellum backup <local>: guardian bootstrap secret", () => {
|
|
221
|
+
test("passes the lockfile bootstrap secret when leasing a fresh guardian token", async () => {
|
|
222
|
+
const localEntry = {
|
|
223
|
+
assistantId: "local-assistant",
|
|
224
|
+
runtimeUrl: "http://127.0.0.1:7830",
|
|
225
|
+
cloud: "local",
|
|
226
|
+
guardianBootstrapSecret: "bootstrap-secret-value",
|
|
227
|
+
} satisfies assistantConfig.AssistantEntry;
|
|
228
|
+
findAssistantByNameMock.mockReturnValue(localEntry);
|
|
229
|
+
loadGuardianTokenSpy.mockReturnValue(null);
|
|
230
|
+
setArgv("my-local", "--output", "/tmp/local-backup.vbundle");
|
|
231
|
+
|
|
232
|
+
globalThis.fetch = mock(async () => {
|
|
233
|
+
return new Response(new Uint8Array([1, 2, 3]), {
|
|
234
|
+
status: 200,
|
|
235
|
+
});
|
|
236
|
+
}) as unknown as typeof globalThis.fetch;
|
|
237
|
+
|
|
238
|
+
await backup();
|
|
239
|
+
|
|
240
|
+
expect(leaseGuardianTokenSpy).toHaveBeenCalledWith(
|
|
241
|
+
"http://127.0.0.1:7830",
|
|
242
|
+
"local-assistant",
|
|
243
|
+
"bootstrap-secret-value",
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
210
248
|
describe("vellum backup <platform-managed>: GCS happy path", () => {
|
|
211
249
|
test("requests upload URL → kicks off runtime export → polls → downloads from GCS → writes file", async () => {
|
|
212
250
|
findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
|