@vellumai/cli 0.8.7 → 0.8.8

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.
Files changed (49) hide show
  1. package/node_modules/@vellumai/local-mode/package.json +2 -1
  2. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
  3. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
  4. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +15 -0
  5. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
  6. package/node_modules/@vellumai/local-mode/src/config.ts +15 -8
  7. package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
  8. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +42 -0
  9. package/node_modules/@vellumai/local-mode/src/hatch.ts +22 -4
  10. package/node_modules/@vellumai/local-mode/src/index.ts +26 -4
  11. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
  12. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
  13. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
  14. package/node_modules/@vellumai/local-mode/src/lockfile.ts +9 -7
  15. package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
  16. package/package.json +1 -1
  17. package/src/__tests__/assistant-client-refresh.test.ts +182 -0
  18. package/src/__tests__/clean.test.ts +179 -0
  19. package/src/__tests__/client-token.test.ts +87 -0
  20. package/src/__tests__/client-tui-refresh.test.ts +170 -0
  21. package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
  22. package/src/__tests__/connect-import.test.ts +317 -0
  23. package/src/__tests__/devices.test.ts +272 -0
  24. package/src/__tests__/guardian-token.test.ts +126 -2
  25. package/src/__tests__/pair.test.ts +271 -0
  26. package/src/__tests__/paired-lifecycle.test.ts +116 -0
  27. package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
  28. package/src/__tests__/unpair.test.ts +163 -0
  29. package/src/commands/client.ts +115 -26
  30. package/src/commands/connect/import.ts +217 -0
  31. package/src/commands/connect.ts +31 -0
  32. package/src/commands/devices.ts +247 -0
  33. package/src/commands/pair.ts +222 -0
  34. package/src/commands/ps.ts +16 -0
  35. package/src/commands/retire.ts +20 -47
  36. package/src/commands/sleep.ts +7 -0
  37. package/src/commands/tunnel.ts +46 -2
  38. package/src/commands/unpair.ts +118 -0
  39. package/src/commands/wake.ts +7 -0
  40. package/src/components/DefaultMainScreen.tsx +84 -13
  41. package/src/index.ts +16 -0
  42. package/src/lib/assistant-client.ts +58 -37
  43. package/src/lib/assistant-config.ts +12 -0
  44. package/src/lib/cloudflare-tunnel.ts +276 -0
  45. package/src/lib/confirm-action.ts +57 -0
  46. package/src/lib/docker.ts +25 -1
  47. package/src/lib/environments/resolve.ts +9 -30
  48. package/src/lib/guardian-token.ts +120 -4
  49. package/src/lib/local.ts +20 -6
@@ -0,0 +1,217 @@
1
+ /**
2
+ * `vellum connect import <blob> [--name <localname>]`
3
+ *
4
+ * Import a pairing bundle printed by `vellum pair` on another machine and
5
+ * register it locally so `vellum client`/`message`/`events <name>` work against
6
+ * the remote assistant.
7
+ *
8
+ * The bundle is base64(JSON.stringify({ gatewayUrl, assistantId, token,
9
+ * deviceId })). We store the entry under a UNIQUE LOCAL id (not the bundle's
10
+ * assistantId, which is typically "self" and would collide across hosts). This
11
+ * is safe because the gateway's runtime proxy strips the `/v1/assistants/<id>/`
12
+ * segment before forwarding, so the local id never has to match the remote one
13
+ * — the token (validated by signature/audience) is what authorizes requests.
14
+ */
15
+
16
+ import { nanoid } from "nanoid";
17
+
18
+ import { extractFlag } from "../../lib/arg-utils.js";
19
+ import {
20
+ findAssistantByName,
21
+ saveAssistantEntry,
22
+ } from "../../lib/assistant-config.js";
23
+ import { saveGuardianToken } from "../../lib/guardian-token.js";
24
+
25
+ function printUsage(): void {
26
+ console.log(`vellum connect import - Register an assistant paired from another machine
27
+
28
+ USAGE:
29
+ vellum connect import <bundle> [options]
30
+
31
+ ARGUMENTS:
32
+ <bundle> The base64 bundle printed by 'vellum pair' on the host machine
33
+
34
+ OPTIONS:
35
+ --name <name> Local name to register the assistant under
36
+ (default: paired-<deviceId>)
37
+
38
+ EXAMPLES:
39
+ vellum connect import eyJnYXRld2F5...
40
+ vellum connect import eyJnYXRld2F5... --name desk
41
+ `);
42
+ }
43
+
44
+ interface PairBundle {
45
+ gatewayUrl: string;
46
+ token: string;
47
+ assistantId?: string;
48
+ deviceId?: string;
49
+ // Optional refresh credential. Present when the host's gateway issued a
50
+ // device-bound token pair; absent for older access-only bundles (which remain
51
+ // importable, just without auto-renewal). `refreshTokenExpiresAt` mirrors
52
+ // GuardianTokenData (ISO string OR epoch-ms number) so a numeric expiry isn't
53
+ // silently dropped on import.
54
+ refreshToken?: string;
55
+ refreshTokenExpiresAt?: string | number;
56
+ refreshAfter?: string;
57
+ }
58
+
59
+ /** Decode the base64 bundle, returning null if malformed or missing fields. */
60
+ function decodeBundle(blob: string): PairBundle | null {
61
+ let json: unknown;
62
+ try {
63
+ json = JSON.parse(Buffer.from(blob, "base64").toString("utf8"));
64
+ } catch {
65
+ return null;
66
+ }
67
+ if (typeof json !== "object" || json === null) return null;
68
+ const b = json as Record<string, unknown>;
69
+ if (typeof b.gatewayUrl !== "string" || typeof b.token !== "string") {
70
+ return null;
71
+ }
72
+ // The gatewayUrl is persisted as runtimeUrl and used to build fetch URLs, so
73
+ // require an absolute http(s) URL here rather than letting an invalid string
74
+ // through (which would crash `new URL(...)` or break later client calls).
75
+ try {
76
+ const parsed = new URL(b.gatewayUrl);
77
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
78
+ return null;
79
+ }
80
+ } catch {
81
+ return null;
82
+ }
83
+ return {
84
+ gatewayUrl: b.gatewayUrl,
85
+ token: b.token,
86
+ assistantId: typeof b.assistantId === "string" ? b.assistantId : undefined,
87
+ deviceId: typeof b.deviceId === "string" ? b.deviceId : undefined,
88
+ refreshToken:
89
+ typeof b.refreshToken === "string" ? b.refreshToken : undefined,
90
+ refreshTokenExpiresAt:
91
+ typeof b.refreshTokenExpiresAt === "string" ||
92
+ typeof b.refreshTokenExpiresAt === "number"
93
+ ? b.refreshTokenExpiresAt
94
+ : undefined,
95
+ refreshAfter:
96
+ typeof b.refreshAfter === "string" ? b.refreshAfter : undefined,
97
+ };
98
+ }
99
+
100
+ /** Lowercase, collapse non-alphanumerics to single dashes, trim dashes. */
101
+ function slugify(name: string): string {
102
+ return name
103
+ .toLowerCase()
104
+ .replace(/[^a-z0-9]+/g, "-")
105
+ .replace(/^-+|-+$/g, "");
106
+ }
107
+
108
+ /** Best-effort JWT `exp` (epoch seconds) → epoch ms; null if undecodable. */
109
+ function jwtExpiryMs(token: string): number | null {
110
+ const parts = token.split(".");
111
+ if (parts.length !== 3) return null;
112
+ try {
113
+ const payload = JSON.parse(
114
+ Buffer.from(parts[1], "base64").toString("utf8"),
115
+ );
116
+ if (typeof payload.exp === "number") return payload.exp * 1000;
117
+ } catch {
118
+ /* fall through */
119
+ }
120
+ return null;
121
+ }
122
+
123
+ export async function connectImport(): Promise<void> {
124
+ const rawArgs = process.argv.slice(4);
125
+
126
+ if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
127
+ printUsage();
128
+ return;
129
+ }
130
+
131
+ const [nameFlag, args] = extractFlag(rawArgs, "--name");
132
+ const blob = args[0];
133
+ if (!blob) {
134
+ console.error("Error: missing pairing bundle.");
135
+ printUsage();
136
+ process.exit(1);
137
+ }
138
+
139
+ const bundle = decodeBundle(blob);
140
+ if (!bundle) {
141
+ console.error(
142
+ "Error: invalid pairing bundle. Paste the full base64 string printed by `vellum pair`.",
143
+ );
144
+ process.exit(1);
145
+ }
146
+
147
+ // Unique local id: a --name slug, or paired-<deviceId> (deviceId is unique
148
+ // per pairing). Never the bundle's "self" assistantId — that would collide.
149
+ // The deviceId comes from an untrusted bundle and is used as a path component
150
+ // by saveGuardianToken, so it MUST be slugified (no `../` traversal); fall
151
+ // back to a random id if it sanitizes to empty.
152
+ const localId = nameFlag
153
+ ? slugify(nameFlag)
154
+ : `paired-${slugify(bundle.deviceId ?? "") || nanoid()}`;
155
+ if (!localId) {
156
+ console.error(
157
+ "Error: --name must contain at least one alphanumeric character.",
158
+ );
159
+ process.exit(1);
160
+ }
161
+
162
+ // Don't clobber an existing assistant. Only update in place when the prior
163
+ // entry is itself a paired import (marked `paired: true`); otherwise the id
164
+ // collides with a real local/remote assistant and overwriting would drop its
165
+ // resources/runtime metadata. Reject and let the user pick a fresh --name.
166
+ const existing = findAssistantByName(localId);
167
+ if (existing && existing.paired !== true) {
168
+ console.error(
169
+ `Error: an assistant named '${localId}' already exists locally. ` +
170
+ "Choose a different --name to avoid overwriting it.",
171
+ );
172
+ process.exit(1);
173
+ }
174
+ const existed = existing !== null;
175
+
176
+ saveAssistantEntry({
177
+ assistantId: localId,
178
+ name: nameFlag ?? `paired (${new URL(bundle.gatewayUrl).host})`,
179
+ runtimeUrl: bundle.gatewayUrl,
180
+ // Paired entries are reached by bearer token at the remote runtimeUrl
181
+ // (a non-"vellum" cloud selects the bearer-token auth path in client.ts).
182
+ // The "paired" topology lets lifecycle/status commands (ps/wake/sleep)
183
+ // recognize this as a remote pairing rather than an on-machine process.
184
+ cloud: "paired",
185
+ // Marks this entry as a connect-import so re-imports update in place while
186
+ // imports never silently overwrite a non-paired assistant (see guard above).
187
+ paired: true,
188
+ species: "vellum",
189
+ });
190
+
191
+ const now = Date.now();
192
+ const hasRefresh = Boolean(bundle.refreshToken);
193
+ saveGuardianToken(localId, {
194
+ guardianPrincipalId: "imported",
195
+ accessToken: bundle.token,
196
+ accessTokenExpiresAt:
197
+ jwtExpiryMs(bundle.token) ?? now + 24 * 60 * 60 * 1000,
198
+ refreshToken: bundle.refreshToken ?? "",
199
+ refreshTokenExpiresAt: bundle.refreshTokenExpiresAt ?? 0,
200
+ refreshAfter: bundle.refreshAfter ?? "",
201
+ isNew: false,
202
+ deviceId: bundle.deviceId ?? "",
203
+ leasedAt: new Date(now).toISOString(),
204
+ });
205
+
206
+ console.log(
207
+ `${existed ? "Updated" : "Imported"} paired assistant '${localId}'.`,
208
+ );
209
+ console.log("");
210
+ console.log(` Connect with: vellum client ${localId}`);
211
+ console.log("");
212
+ console.log(
213
+ hasRefresh
214
+ ? "Note: this connection includes a refresh credential, so it can renew itself — re-pair only if it's revoked or the refresh credential expires."
215
+ : "Note: the token is access-only and will expire — re-run `vellum pair` and import again when it does.",
216
+ );
217
+ }
@@ -0,0 +1,31 @@
1
+ import { connectImport } from "./connect/import.js";
2
+
3
+ function printUsage(): void {
4
+ console.log("Usage: vellum connect <subcommand>");
5
+ console.log("");
6
+ console.log("Connect to an assistant paired from another machine.");
7
+ console.log("");
8
+ console.log("Subcommands:");
9
+ console.log(
10
+ " import Import a pairing bundle from `vellum pair` and register it",
11
+ );
12
+ }
13
+
14
+ export async function connect(): Promise<void> {
15
+ const args = process.argv.slice(3);
16
+ const subcommand = args[0];
17
+
18
+ if (subcommand === "--help" || subcommand === "-h" || !subcommand) {
19
+ printUsage();
20
+ process.exit(0);
21
+ }
22
+
23
+ if (subcommand === "import") {
24
+ await connectImport();
25
+ return;
26
+ }
27
+
28
+ console.error(`Unknown subcommand: ${subcommand}`);
29
+ printUsage();
30
+ process.exit(1);
31
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * `vellum devices [name]` and `vellum devices revoke <hashedDeviceId> [name] [--yes]`
3
+ *
4
+ * Host-side of pairing lifecycle: list and revoke the devices paired to a LOCAL
5
+ * self-hosted assistant. Calls the loopback-only gateway endpoints
6
+ * `GET /v1/devices` and `POST /v1/devices/revoke` (added in the gateway slice),
7
+ * which self-guard loopback + reject any browser/WebView Origin. The gateway
8
+ * only ever stores the HASHED device id, so list returns and revoke accepts the
9
+ * same `hashedDeviceId` (the raw device id is never persisted anywhere).
10
+ *
11
+ * This is the counterpart to `vellum unpair`: `unpair` forgets a connection on
12
+ * the *paired* machine (client side); `devices` revokes a device from the *host*
13
+ * that runs the assistant (server side).
14
+ */
15
+
16
+ import {
17
+ type AssistantEntry,
18
+ formatAssistantReference,
19
+ getAssistantDisplayName,
20
+ resolveTargetAssistant,
21
+ } from "../lib/assistant-config";
22
+ import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
23
+ import {
24
+ CLI_INTERFACE_ID,
25
+ getClientRegistrationHeaders,
26
+ } from "../lib/client-identity.js";
27
+ import {
28
+ canPromptForConfirmation,
29
+ confirmAction,
30
+ } from "../lib/confirm-action.js";
31
+
32
+ interface DeviceRecord {
33
+ hashedDeviceId: string;
34
+ platform: string;
35
+ issuedAt: number | null;
36
+ expiresAt: number | null;
37
+ lastUsedAt: number | null;
38
+ }
39
+
40
+ function printUsage(): void {
41
+ console.log(`vellum devices - List and revoke devices paired to a local assistant
42
+
43
+ USAGE:
44
+ vellum devices [name]
45
+ vellum devices revoke <hashedDeviceId> [name] [--yes]
46
+
47
+ ARGUMENTS:
48
+ [name] Name or id of the local assistant (defaults to the active/sole one)
49
+ <hashedDeviceId> The device's hashed id (copy it from the list output)
50
+
51
+ OPTIONS:
52
+ --yes Skip the interactive confirmation prompt when revoking (for automation)
53
+
54
+ Lists the devices paired to a local (host-side) assistant, or revokes one by its
55
+ hashed id. Runs on the machine that hosts the assistant — paired connections
56
+ imported from another machine are managed with 'vellum unpair' instead.
57
+
58
+ EXAMPLES:
59
+ vellum devices
60
+ vellum devices my-desk
61
+ vellum devices revoke 3f9a1c...
62
+ vellum devices revoke 3f9a1c... my-desk --yes
63
+ `);
64
+ }
65
+
66
+ /**
67
+ * Resolve the LOOPBACK gateway base URL for a host-side assistant, or exit with
68
+ * a helpful error. Refuses paired connections (they have no local gateway here)
69
+ * and never falls back to a non-loopback URL.
70
+ */
71
+ function resolveLoopbackBase(entry: AssistantEntry): string {
72
+ const displayName = getAssistantDisplayName(entry);
73
+
74
+ if (entry.cloud === "paired") {
75
+ console.error(
76
+ `Error: '${displayName}' is a paired connection imported from another machine.`,
77
+ );
78
+ console.error(
79
+ "Run `vellum devices` on the host that runs the assistant to manage its devices.",
80
+ );
81
+ console.error("To forget this connection here, use `vellum unpair`.");
82
+ process.exit(1);
83
+ }
84
+
85
+ const base =
86
+ entry.localUrl ||
87
+ (entry.resources?.gatewayPort
88
+ ? `http://127.0.0.1:${entry.resources.gatewayPort}`
89
+ : undefined);
90
+ if (!base) {
91
+ console.error(
92
+ `Error: no local gateway found for '${displayName}'. \`vellum devices\` runs on the machine hosting the assistant.`,
93
+ );
94
+ process.exit(1);
95
+ }
96
+
97
+ return base.replace(/\/+$/, "");
98
+ }
99
+
100
+ /** Format an epoch-ms timestamp as ISO, or a placeholder when absent. */
101
+ function formatTimestamp(ms: number | null, absent: string): string {
102
+ if (typeof ms !== "number" || !Number.isFinite(ms)) return absent;
103
+ return new Date(ms).toISOString();
104
+ }
105
+
106
+ async function listDevices(entry: AssistantEntry, base: string): Promise<void> {
107
+ const displayName = getAssistantDisplayName(entry);
108
+
109
+ let response: Response;
110
+ try {
111
+ response = await fetch(`${base}/v1/devices`, {
112
+ method: "GET",
113
+ headers: getClientRegistrationHeaders(CLI_INTERFACE_ID),
114
+ });
115
+ } catch (err) {
116
+ console.error(
117
+ `Error: could not reach the gateway for '${displayName}' at ${base}: ${
118
+ (err as Error).message
119
+ }`,
120
+ );
121
+ process.exit(1);
122
+ }
123
+
124
+ if (!response.ok) {
125
+ console.error(
126
+ `Error: gateway returned ${response.status} listing devices for '${displayName}'.`,
127
+ );
128
+ process.exit(1);
129
+ }
130
+
131
+ const body = (await response.json()) as { devices?: DeviceRecord[] };
132
+ const devices = body.devices ?? [];
133
+
134
+ if (devices.length === 0) {
135
+ console.log(`No devices are paired to ${displayName}.`);
136
+ return;
137
+ }
138
+
139
+ console.log(`Devices paired to ${formatAssistantReference(entry)}:`);
140
+ console.log("");
141
+ for (const device of devices) {
142
+ console.log(` ${device.hashedDeviceId}`);
143
+ console.log(` platform: ${device.platform}`);
144
+ console.log(` issued: ${formatTimestamp(device.issuedAt, "—")}`);
145
+ console.log(` expires: ${formatTimestamp(device.expiresAt, "—")}`);
146
+ console.log(
147
+ ` last used: ${formatTimestamp(device.lastUsedAt, "never")}`,
148
+ );
149
+ console.log("");
150
+ }
151
+ console.log(
152
+ `${devices.length} device(s). Revoke one with: vellum devices revoke <hashedDeviceId>`,
153
+ );
154
+ }
155
+
156
+ async function revokeDevice(
157
+ entry: AssistantEntry,
158
+ base: string,
159
+ hashedDeviceId: string,
160
+ yes: boolean,
161
+ ): Promise<void> {
162
+ const displayName = getAssistantDisplayName(entry);
163
+
164
+ // Print the resolved identity before acting (cli/AGENTS.md).
165
+ console.log("Device to revoke:");
166
+ console.log(` Assistant: ${formatAssistantReference(entry)}`);
167
+ console.log(` Device: ${hashedDeviceId}`);
168
+ console.log("");
169
+
170
+ if (!yes) {
171
+ if (!canPromptForConfirmation()) {
172
+ console.error(
173
+ "Error: Refusing to revoke without confirmation in a non-interactive terminal.",
174
+ );
175
+ console.error("Re-run with --yes to confirm from automation.");
176
+ process.exit(1);
177
+ }
178
+ const confirmed = await confirmAction(
179
+ "Press Enter to revoke, or Esc/q to cancel: ",
180
+ );
181
+ if (!confirmed) {
182
+ console.log("Revoke cancelled.");
183
+ process.exit(1);
184
+ }
185
+ }
186
+
187
+ let response: Response;
188
+ try {
189
+ response = await fetch(`${base}/v1/devices/revoke`, {
190
+ method: "POST",
191
+ headers: {
192
+ "Content-Type": "application/json",
193
+ ...getClientRegistrationHeaders(CLI_INTERFACE_ID),
194
+ },
195
+ body: JSON.stringify({ hashedDeviceId }),
196
+ });
197
+ } catch (err) {
198
+ console.error(
199
+ `Error: could not reach the gateway for '${displayName}' at ${base}: ${
200
+ (err as Error).message
201
+ }`,
202
+ );
203
+ process.exit(1);
204
+ }
205
+
206
+ if (!response.ok) {
207
+ console.error(
208
+ `Error: gateway returned ${response.status} revoking device for '${displayName}'.`,
209
+ );
210
+ process.exit(1);
211
+ }
212
+
213
+ console.log(
214
+ `Revoked device ${hashedDeviceId} from ${displayName}. Its tokens are invalidated; that machine must re-pair to reconnect.`,
215
+ );
216
+ }
217
+
218
+ export async function devices(): Promise<void> {
219
+ const args = process.argv.slice(3);
220
+
221
+ if (args.includes("--help") || args.includes("-h")) {
222
+ printUsage();
223
+ return;
224
+ }
225
+
226
+ if (args[0] === "revoke") {
227
+ const rest = args.slice(1);
228
+ const yes = rest.includes("--yes");
229
+ const positionals = rest.filter((a) => !a.startsWith("-"));
230
+ const hashedDeviceId = positionals[0];
231
+ if (!hashedDeviceId) {
232
+ console.error("Error: a hashedDeviceId is required to revoke.");
233
+ printUsage();
234
+ process.exit(1);
235
+ }
236
+ const nameArg = positionals.slice(1).join(" ") || undefined;
237
+ const entry = resolveTargetAssistant(nameArg);
238
+ const base = resolveLoopbackBase(entry);
239
+ await revokeDevice(entry, base, hashedDeviceId, yes);
240
+ return;
241
+ }
242
+
243
+ const nameArg = parseAssistantTargetArg(args, []);
244
+ const entry = resolveTargetAssistant(nameArg);
245
+ const base = resolveLoopbackBase(entry);
246
+ await listDevices(entry, base);
247
+ }