@vellumai/cli 0.11.8 → 0.11.9-staging.2

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 (38) hide show
  1. package/README.md +6 -7
  2. package/knip.json +0 -1
  3. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/package.json +4 -1
  4. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
  5. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  6. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  7. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
  8. package/node_modules/@vellumai/local-mode/src/__tests__/guardian-token-refresh.test.ts +2 -8
  9. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +2 -8
  10. package/node_modules/@vellumai/local-mode/src/__tests__/helpers/child-process-mock.ts +55 -0
  11. package/node_modules/@vellumai/local-mode/src/__tests__/retire.test.ts +2 -8
  12. package/node_modules/@vellumai/local-mode/src/__tests__/upgrade.test.ts +2 -8
  13. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +2 -8
  14. package/node_modules/@vellumai/local-mode/src/devices.test.ts +5 -8
  15. package/node_modules/@vellumai/local-mode/src/index.ts +1 -0
  16. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +6 -0
  17. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +165 -0
  18. package/node_modules/@vellumai/local-mode/src/lockfile.ts +63 -6
  19. package/node_modules/@vellumai/service-contracts/package.json +4 -1
  20. package/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
  21. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  22. package/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  23. package/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
  24. package/package.json +1 -1
  25. package/src/__tests__/assistant-api-key-resolution.test.ts +102 -0
  26. package/src/__tests__/constants.test.ts +6 -2
  27. package/src/commands/hatch.ts +1 -367
  28. package/src/commands/login.ts +16 -29
  29. package/src/commands/teleport.ts +14 -26
  30. package/src/lib/assistant-api-key-resolution.ts +89 -0
  31. package/src/lib/aws.ts +0 -593
  32. package/src/lib/constants.ts +0 -2
  33. package/src/lib/gcp.ts +3 -671
  34. package/src/lib/platform-client.ts +58 -5
  35. package/src/__tests__/ssh-user-guard.test.ts +0 -28
  36. package/src/adapters/openclaw-http-server.ts +0 -209
  37. package/src/adapters/openclaw.ts +0 -131
  38. package/src/lib/openclaw-runtime-server.ts +0 -28
@@ -1,3 +1,7 @@
1
+ import {
2
+ type PlatformCredentialVerificationStatus,
3
+ PlatformVerifyCredentialResponseSchema,
4
+ } from "@vellumai/service-contracts/platform-credential";
1
5
  import {
2
6
  chmodSync,
3
7
  readFileSync,
@@ -289,7 +293,10 @@ export interface ReprovisionApiKeyResponse {
289
293
  * Reprovision (rotate) the API key for a self-hosted local assistant.
290
294
  *
291
295
  * Calls `POST /v1/assistants/self-hosted-local/reprovision-api-key/`.
292
- * Returns a fresh API key. The previous key is revoked server-side.
296
+ * Returns a fresh API key. The platform keeps the previous key valid for a
297
+ * grace period rather than revoking it on the spot, so a rotation whose
298
+ * replacement never gets stored leaves the assistant on a key that still
299
+ * works until that window closes.
293
300
  */
294
301
  export async function reprovisionAssistantApiKey(
295
302
  token: string,
@@ -349,6 +356,51 @@ export interface GatewayCredentialResult {
349
356
  unreachable: boolean;
350
357
  }
351
358
 
359
+ /**
360
+ * Ask a running assistant to check its stored managed credential against the
361
+ * platform, via the gateway-proxied `POST /v1/platform/verify-credential`.
362
+ *
363
+ * Returns `"rejected"` only when the platform settled that answer. Every other
364
+ * case, including a daemon too old to serve the route and any failure to reach
365
+ * one, returns null: the answer decides whether a key is rotated, so an absent
366
+ * answer must never read as a rejection.
367
+ *
368
+ * Never throws.
369
+ */
370
+ export async function verifyGatewayManagedCredential(
371
+ gatewayUrl: string,
372
+ bearerToken?: string,
373
+ ): Promise<PlatformCredentialVerificationStatus | null> {
374
+ try {
375
+ const headers: Record<string, string> = { Accept: "application/json" };
376
+ if (bearerToken) {
377
+ headers["Authorization"] = `Bearer ${bearerToken}`;
378
+ }
379
+
380
+ const response = await loopbackSafeFetch(
381
+ `${gatewayUrl}/v1/platform/verify-credential`,
382
+ {
383
+ method: "POST",
384
+ headers,
385
+ signal: AbortSignal.timeout(10_000),
386
+ },
387
+ );
388
+ if (!response.ok) {
389
+ return null;
390
+ }
391
+
392
+ const parsed = PlatformVerifyCredentialResponseSchema.safeParse(
393
+ await response.json(),
394
+ );
395
+ if (parsed.success) {
396
+ return parsed.data.status;
397
+ }
398
+ return null;
399
+ } catch {
400
+ return null;
401
+ }
402
+ }
403
+
352
404
  /**
353
405
  * Read an existing credential from the assistant's secret store via the
354
406
  * gateway-proxied `POST /v1/secrets/read` endpoint (with `reveal: true`).
@@ -356,9 +408,10 @@ export interface GatewayCredentialResult {
356
408
  * Returns a result distinguishing "key not found" (`value: null,
357
409
  * unreachable: false`) from "gateway unreachable" (`value: null,
358
410
  * unreachable: true`). Callers should only reprovision when the gateway
359
- * is reachable but the key is genuinely missing — reprovisioning while
360
- * the gateway is down would revoke the old key server-side without being
361
- * able to inject the replacement.
411
+ * is reachable: the platform keeps the outgoing key valid for a grace
412
+ * period, so reprovisioning while the gateway is down does not break the
413
+ * assistant immediately, but it starts a clock on a key the caller cannot
414
+ * yet replace.
362
415
  *
363
416
  * Never throws.
364
417
  */
@@ -385,7 +438,7 @@ export async function readGatewayCredential(
385
438
 
386
439
  if (!response.ok) {
387
440
  // 5xx means the gateway/daemon backend is down — treat as unreachable
388
- // so callers don't revoke a potentially valid key.
441
+ // so callers don't rotate away from a working key they cannot replace.
389
442
  return { value: null, unreachable: response.status >= 500 };
390
443
  }
391
444
 
@@ -1,28 +0,0 @@
1
- import { describe, test, expect } from "bun:test";
2
- import { readFileSync } from "fs";
3
- import { join } from "path";
4
-
5
- // Regression guard for PR #25292: hatchAws/hatchGcp must abort before
6
- // launching a cloud instance when sshUser is empty. Otherwise `useradd ""`
7
- // in the generated startup script fails after billable resources are live.
8
- describe("sshUser empty-string guard", () => {
9
- const files = [
10
- join(import.meta.dir, "..", "lib", "aws.ts"),
11
- join(import.meta.dir, "..", "lib", "gcp.ts"),
12
- ];
13
-
14
- for (const file of files) {
15
- test(`${file.split("/").slice(-2).join("/")} aborts on empty sshUser`, () => {
16
- const source = readFileSync(file, "utf8");
17
- const fallbackIdx = source.indexOf('sshUser = process.env.USER ?? ""');
18
- expect(fallbackIdx).toBeGreaterThan(-1);
19
-
20
- const afterFallback = source.slice(fallbackIdx);
21
- const guardIdx = afterFallback.search(/if\s*\(\s*!sshUser\s*\)/);
22
- expect(guardIdx).toBeGreaterThan(-1);
23
-
24
- const guardBlock = afterFallback.slice(guardIdx, guardIdx + 400);
25
- expect(guardBlock).toContain("process.exit(1)");
26
- });
27
- }
28
- });
@@ -1,209 +0,0 @@
1
- import crypto from "node:crypto";
2
- import { execSync, spawn } from "node:child_process";
3
- import http from "node:http";
4
-
5
- const PORT = parseInt(process.env.PORT || "7830", 10);
6
-
7
- interface StoredMessage {
8
- id: string;
9
- role: "user" | "assistant";
10
- content: string;
11
- timestamp: string;
12
- }
13
-
14
- const messages: Record<string, StoredMessage[]> = {};
15
-
16
- function parseBody(
17
- req: http.IncomingMessage,
18
- ): Promise<Record<string, unknown>> {
19
- return new Promise((resolve, reject) => {
20
- let body = "";
21
- req.on("data", (chunk: Buffer) => (body += chunk.toString()));
22
- req.on("end", () => {
23
- try {
24
- resolve(JSON.parse(body));
25
- } catch (e) {
26
- reject(e);
27
- }
28
- });
29
- req.on("error", reject);
30
- });
31
- }
32
-
33
- function log(method: string, path: string, status: number): void {
34
- const ts = new Date().toISOString();
35
- console.log(`[${ts}] ${method} ${path} -> ${status}`);
36
- }
37
-
38
- const server = http.createServer(async (req, res) => {
39
- const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
40
- const method = req.method ?? "UNKNOWN";
41
-
42
- res.setHeader("Content-Type", "application/json");
43
-
44
- if (req.method === "POST" && url.pathname === "/upgrade") {
45
- try {
46
- const npmEnv = {
47
- ...process.env,
48
- PATH: `${process.env.HOME || "/root"}/.npm-global/bin:${process.env.HOME || "/root"}/.local/bin:/usr/local/bin:${process.env.PATH}`,
49
- };
50
- execSync("npm install -g @vellum/openclaw-adapter@latest", {
51
- encoding: "utf-8",
52
- timeout: 120000,
53
- env: npmEnv,
54
- windowsHide: true,
55
- });
56
- const child = spawn("vellum-openclaw-adapter", [], {
57
- detached: true,
58
- stdio: "ignore",
59
- env: npmEnv,
60
- windowsHide: true,
61
- });
62
- child.unref();
63
- const responseBody = JSON.stringify({
64
- status: "success",
65
- message:
66
- "HTTPS adapter installed and started. HTTP adapter shutting down.",
67
- });
68
- res.writeHead(200);
69
- res.end(responseBody, () => {
70
- log(method, url.pathname, 200);
71
- server.close(() => process.exit(0));
72
- });
73
- } catch (e) {
74
- const responseBody = JSON.stringify({
75
- status: "error",
76
- message: e instanceof Error ? e.message : String(e),
77
- });
78
- res.writeHead(500);
79
- res.end(responseBody);
80
- log(method, url.pathname, 500);
81
- }
82
- return;
83
- }
84
-
85
- if (req.method === "GET" && url.pathname === "/healthz") {
86
- const execEnv = {
87
- ...process.env,
88
- PATH: `${process.env.HOME || "/root"}/.npm-global/bin:${process.env.HOME || "/root"}/.local/bin:/usr/local/bin:${process.env.PATH}`,
89
- };
90
- let responseBody: string;
91
- try {
92
- const output = execSync("openclaw health --json", {
93
- encoding: "utf-8",
94
- timeout: 10000,
95
- env: execEnv,
96
- windowsHide: true,
97
- });
98
- const health = JSON.parse(output.trim()) as Record<string, unknown>;
99
- const result: Record<string, unknown> = {
100
- status: health.status ?? "healthy",
101
- message: health.message,
102
- };
103
-
104
- const healthStr = JSON.stringify(health);
105
- if (
106
- healthStr.includes("1006") ||
107
- healthStr.includes("abnormal closure")
108
- ) {
109
- try {
110
- const gatewayOutput = execSync("openclaw gateway status", {
111
- encoding: "utf-8",
112
- timeout: 10000,
113
- env: execEnv,
114
- windowsHide: true,
115
- });
116
- result.message = `${result.message}\n\nGateway Status:\n${gatewayOutput.trim()}`;
117
- } catch (gatewayErr) {
118
- const gatewayErrMsg =
119
- gatewayErr instanceof Error
120
- ? gatewayErr.message
121
- : String(gatewayErr);
122
- result.message = `${result.message}\n\nGateway Status Error:\n${gatewayErrMsg}`;
123
- }
124
- }
125
-
126
- responseBody = JSON.stringify(result);
127
- res.writeHead(200);
128
- res.end(responseBody);
129
- } catch (e) {
130
- const errorMessage = e instanceof Error ? e.message : String(e);
131
- const result: Record<string, unknown> = {
132
- status: "unhealthy",
133
- message: errorMessage,
134
- };
135
-
136
- if (
137
- errorMessage.includes("1006") ||
138
- errorMessage.includes("abnormal closure")
139
- ) {
140
- try {
141
- const gatewayOutput = execSync("openclaw gateway status", {
142
- encoding: "utf-8",
143
- timeout: 10000,
144
- env: execEnv,
145
- windowsHide: true,
146
- });
147
- result.message = `${result.message}\n\nGateway Status:\n${gatewayOutput.trim()}`;
148
- } catch (gatewayErr) {
149
- const gatewayErrMsg =
150
- gatewayErr instanceof Error
151
- ? gatewayErr.message
152
- : String(gatewayErr);
153
- result.message = `${result.message}\n\nGateway Status Error:\n${gatewayErrMsg}`;
154
- }
155
- }
156
-
157
- responseBody = JSON.stringify(result);
158
- res.writeHead(200);
159
- res.end(responseBody);
160
- }
161
- log(method, url.pathname, 200);
162
- return;
163
- }
164
-
165
- const messagesMatch = url.pathname.match(
166
- /^\/v1\/assistants\/([^/]+)\/messages$/,
167
- );
168
- if (messagesMatch) {
169
- const assistantId = messagesMatch[1];
170
-
171
- if (req.method === "GET") {
172
- const key = url.searchParams.get("conversationKey") ?? assistantId;
173
- const msgs = messages[key] ?? [];
174
- res.writeHead(200);
175
- res.end(JSON.stringify({ messages: msgs }));
176
- return;
177
- }
178
-
179
- if (req.method === "POST") {
180
- try {
181
- const parsed = await parseBody(req);
182
- const key = (parsed.conversationKey as string) || assistantId;
183
- if (!messages[key]) messages[key] = [];
184
- const messageId = crypto.randomUUID();
185
- messages[key].push({
186
- id: messageId,
187
- role: "user",
188
- content: parsed.content as string,
189
- timestamp: new Date().toISOString(),
190
- });
191
- res.writeHead(200);
192
- res.end(JSON.stringify({ accepted: true, messageId }));
193
- } catch {
194
- res.writeHead(400);
195
- res.end(JSON.stringify({ error: "Invalid request body" }));
196
- }
197
- return;
198
- }
199
- }
200
-
201
- const notFoundBody = JSON.stringify({ error: "Not found" });
202
- res.writeHead(404);
203
- res.end(notFoundBody);
204
- log(method, url.pathname, 404);
205
- });
206
-
207
- server.listen(PORT, "0.0.0.0", () => {
208
- console.log(`OpenClaw runtime server listening on port ${PORT}`);
209
- });
@@ -1,131 +0,0 @@
1
- import { GATEWAY_PORT } from "../lib/constants";
2
- import { buildOpenclawRuntimeServer } from "../lib/openclaw-runtime-server";
3
-
4
- export async function buildOpenclawStartupScript(
5
- sshUser: string,
6
- providerApiKeys: Record<string, string>,
7
- timestampRedirect: string,
8
- userSetup: string,
9
- ownershipFixup: string,
10
- ): Promise<string> {
11
- const runtimeServer = await buildOpenclawRuntimeServer();
12
-
13
- return `#!/bin/bash
14
- set -e
15
-
16
- ${timestampRedirect}
17
-
18
- trap 'EXIT_CODE=\$?; if [ \$EXIT_CODE -ne 0 ]; then echo "Startup script failed with exit code \$EXIT_CODE" > /var/log/startup-error; fi' EXIT
19
- ${userSetup}
20
-
21
- export OPENCLAW_NPM_LOGLEVEL=verbose
22
- export OPENCLAW_NO_ONBOARD=1
23
- export OPENCLAW_NO_PROMPT=1
24
-
25
- echo "=== Pre-install diagnostics ==="
26
- echo "Date: $(date -u)"
27
- echo "Disk:" && df -h / 2>&1 || true
28
- echo "Memory:" && free -m 2>&1 || true
29
- echo "DNS:" && nslookup registry.npmjs.org 2>&1 || true
30
- echo "Registry ping:" && curl -sSf --max-time 10 https://registry.npmjs.org/-/ping 2>&1 || echo "WARN: npm registry unreachable"
31
- echo "=== End pre-install diagnostics ==="
32
-
33
- echo "=== Installing build dependencies ==="
34
- apt-get update -y
35
- apt-get install -y build-essential python3 python3-pip git
36
- pip3 install cmake
37
- echo "cmake version: $(cmake --version | head -1)"
38
- echo "=== Build dependencies installed ==="
39
-
40
- curl -fsSL https://openclaw.ai/install.sh -o /tmp/openclaw-install.sh
41
- chmod +x /tmp/openclaw-install.sh
42
-
43
- set +e
44
- bash /tmp/openclaw-install.sh
45
- INSTALL_EXIT_CODE=\$?
46
- set -e
47
-
48
- if [ \$INSTALL_EXIT_CODE -ne 0 ]; then
49
- echo "=== OpenClaw install failed (exit code: \$INSTALL_EXIT_CODE) ==="
50
- echo "=== npm debug logs ==="
51
- find \$HOME/.npm/_logs -name '*.log' -type f 2>/dev/null | sort | while read -r logfile; do
52
- echo "--- \$logfile ---"
53
- tail -n 200 "\$logfile" 2>/dev/null || true
54
- done
55
- echo "=== Post-failure diagnostics ==="
56
- echo "Disk:" && df -h / 2>&1 || true
57
- echo "Memory:" && free -m 2>&1 || true
58
- echo "node version:" && node --version 2>&1 || echo "node not found"
59
- echo "npm version:" && npm --version 2>&1 || echo "npm not found"
60
- echo "npm config:" && npm config list 2>&1 || true
61
- echo "cmake version:" && cmake --version 2>&1 || echo "cmake not found"
62
- echo "PATH: \$PATH"
63
- echo "=== End diagnostics ==="
64
- exit \$INSTALL_EXIT_CODE
65
- fi
66
-
67
- export PATH="\$HOME/.npm-global/bin:\$HOME/.local/bin:/usr/local/bin:\$PATH"
68
-
69
- if ! command -v openclaw >/dev/null 2>&1; then
70
- echo "ERROR: openclaw CLI installation failed. The 'openclaw' command is not available."
71
- echo "PATH: \$PATH"
72
- echo "which openclaw:" && which openclaw 2>&1 || true
73
- echo "npm global bin:" && npm bin -g 2>&1 || true
74
- echo "npm global list:" && npm list -g --depth=0 2>&1 || true
75
- exit 1
76
- fi
77
-
78
- export XDG_RUNTIME_DIR="/run/user/\$(id -u)"
79
- export DBUS_SESSION_BUS_ADDRESS="unix:path=\$XDG_RUNTIME_DIR/bus"
80
- mkdir -p "\$XDG_RUNTIME_DIR"
81
- loginctl enable-linger root 2>/dev/null || true
82
- systemctl --user daemon-reexec 2>/dev/null || true
83
-
84
- if ! command -v bun >/dev/null 2>&1; then
85
- echo "=== Installing bun ==="
86
- if ! command -v unzip >/dev/null 2>&1; then
87
- echo "Installing unzip (required by bun)..."
88
- apt-get install -y unzip
89
- fi
90
- # Pinned. Keep in sync with .tool-versions.
91
- curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.11"
92
- export BUN_INSTALL="\$HOME/.bun"
93
- export PATH="\$BUN_INSTALL/bin:\$PATH"
94
- echo "bun version: $(bun --version)"
95
- echo "=== Bun installed ==="
96
- else
97
- echo "bun already installed: $(bun --version)"
98
- fi
99
-
100
- set +e
101
- openclaw gateway install
102
- GATEWAY_INSTALL_EXIT=\$?
103
- set -e
104
-
105
- if [ \$GATEWAY_INSTALL_EXIT -ne 0 ]; then
106
- echo "WARN: openclaw gateway install exited with \$GATEWAY_INSTALL_EXIT (expected systemd mismatch), continuing with user-level systemd setup"
107
- fi
108
-
109
- OPENCLAW_GW_TOKEN=$(openssl rand -hex 32)
110
- echo -n "\$OPENCLAW_GW_TOKEN" > /tmp/openclaw-gateway-token
111
- chmod 600 /tmp/openclaw-gateway-token
112
-
113
- mkdir -p /root/.openclaw
114
- ${Object.entries(providerApiKeys)
115
- .map(([envVar, value]) => `openclaw config set env.${envVar} "${value}"`)
116
- .join("\n")}
117
- openclaw config set agents.defaults.model.primary "anthropic/claude-opus-4-6"
118
- openclaw config set gateway.auth.token "\$OPENCLAW_GW_TOKEN"
119
-
120
- echo "=== Starting openclaw gateway at user level ==="
121
- systemctl --user daemon-reload
122
- systemctl --user enable --now openclaw-gateway.service
123
-
124
- export PORT=${GATEWAY_PORT}
125
-
126
- echo "=== Starting OpenClaw runtime server ==="
127
- ${runtimeServer}
128
- echo "=== OpenClaw runtime server started ==="
129
- ${ownershipFixup}
130
- `;
131
- }
@@ -1,28 +0,0 @@
1
- // Read source file using Bun.file() with string concatenation (not join())
2
- // so Bun's bundler can statically analyze the path and embed the file
3
- // in the compiled binary ($bunfs). The file must also be passed via --embed
4
- // in the bun build --compile invocation.
5
-
6
- export async function buildOpenclawRuntimeServer(): Promise<string> {
7
- try {
8
- const serverSource = await Bun.file(
9
- import.meta.dir + "/../adapters/openclaw-http-server.ts",
10
- ).text();
11
-
12
- return `
13
- cat > /opt/openclaw-runtime-server.ts << 'RUNTIME_SERVER_EOF'
14
- ${serverSource}
15
- RUNTIME_SERVER_EOF
16
-
17
- mkdir -p "\$HOME/.vellum"
18
- nohup bun run /opt/openclaw-runtime-server.ts >> "\$HOME/.vellum/http-gateway.log" 2>&1 &
19
- echo "OpenClaw runtime server started (PID: \$!)"
20
- `;
21
- } catch (err) {
22
- console.warn(
23
- "⚠️ Could not embed openclaw runtime server (expected in compiled binary without --embed):",
24
- (err as Error).message,
25
- );
26
- return "# openclaw-runtime-server: skipped (source files not available in compiled binary)";
27
- }
28
- }