@vellumai/cli 0.8.12 → 0.9.0-dev.202606162243.4268db3
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/README.md +1 -1
- package/bun.lock +49 -56
- package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +224 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +19 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +8 -1
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +0 -15
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +8 -4
- package/node_modules/@vellumai/local-mode/src/sleep.ts +80 -0
- package/node_modules/@vellumai/local-mode/src/status.ts +342 -0
- package/node_modules/@vellumai/local-mode/src/wake.ts +12 -1
- package/package.json +3 -3
- package/src/__tests__/assistant-config.test.ts +1 -2
- package/src/__tests__/device-id.test.ts +6 -14
- package/src/__tests__/helpers/os-mock.ts +27 -0
- package/src/__tests__/login-loopback.test.ts +71 -0
- package/src/__tests__/multi-local.test.ts +2 -10
- package/src/__tests__/nginx-ingress-command.test.ts +69 -0
- package/src/__tests__/nginx-ingress.test.ts +403 -0
- package/src/__tests__/sleep.test.ts +4 -0
- package/src/__tests__/teleport.test.ts +6 -9
- package/src/__tests__/tunnel.test.ts +164 -0
- package/src/__tests__/wake.test.ts +15 -4
- package/src/__tests__/workos-pkce.test.ts +314 -0
- package/src/commands/flags.ts +1 -22
- package/src/commands/hatch.ts +90 -9
- package/src/commands/login.ts +123 -59
- package/src/commands/nginx-ingress.ts +291 -0
- package/src/commands/rollback.ts +0 -6
- package/src/commands/sleep.ts +17 -0
- package/src/commands/teleport.ts +23 -36
- package/src/commands/tunnel.ts +69 -11
- package/src/commands/upgrade.ts +0 -2
- package/src/commands/wake.ts +7 -5
- package/src/commands/workflows.ts +301 -0
- package/src/index.ts +8 -0
- package/src/lib/arg-utils.ts +48 -0
- package/src/lib/assistant-client.ts +2 -0
- package/src/lib/assistant-config.ts +0 -7
- package/src/lib/cloudflare-tunnel.ts +15 -2
- package/src/lib/docker.ts +103 -49
- package/src/lib/environments/resolve.ts +3 -4
- package/src/lib/feature-flags.test.ts +157 -0
- package/src/lib/feature-flags.ts +38 -0
- package/src/lib/hatch-local.ts +0 -1
- package/src/lib/local.ts +5 -0
- package/src/lib/nginx-ingress.ts +576 -0
- package/src/lib/ngrok.ts +26 -4
- package/src/lib/platform-client.ts +0 -1
- package/src/lib/retire-local.ts +5 -0
- package/src/lib/statefulset.ts +73 -21
- package/src/lib/sync-cloud-assistants.ts +4 -17
- package/src/lib/upgrade-lifecycle.ts +1 -2
- package/src/lib/workos-pkce.ts +160 -0
- package/src/__tests__/env-drift.test.ts +0 -53
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
formatAssistantLookupError,
|
|
6
|
+
lookupAssistantByIdentifier,
|
|
7
|
+
resolveAssistant,
|
|
8
|
+
} from "../lib/assistant-config.js";
|
|
9
|
+
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
10
|
+
import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
|
|
11
|
+
import { GATEWAY_PORT } from "../lib/constants.js";
|
|
12
|
+
import {
|
|
13
|
+
formatFeatureFlagGateMessage,
|
|
14
|
+
isAssistantFeatureFlagEnabled,
|
|
15
|
+
WEB_REMOTE_INGRESS_FLAG,
|
|
16
|
+
} from "../lib/feature-flags.js";
|
|
17
|
+
import { waitForDaemonReady } from "../lib/http-client.js";
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_NGINX_INGRESS_PORT,
|
|
20
|
+
findWebDistDir,
|
|
21
|
+
getIngressPaths,
|
|
22
|
+
getIngressPid,
|
|
23
|
+
getNginxIngressPort,
|
|
24
|
+
getNginxVersion,
|
|
25
|
+
isIngressRunning,
|
|
26
|
+
resolveTunnelTargetPort,
|
|
27
|
+
startIngressNginx,
|
|
28
|
+
stopIngressNginx,
|
|
29
|
+
} from "../lib/nginx-ingress.js";
|
|
30
|
+
|
|
31
|
+
const READY_TIMEOUT_MS = 5_000;
|
|
32
|
+
|
|
33
|
+
function printHelp(): void {
|
|
34
|
+
console.log("Usage: vellum nginx-ingress <subcommand> [<name>] [options]");
|
|
35
|
+
console.log("");
|
|
36
|
+
console.log(
|
|
37
|
+
"Manage the nginx web edge that serves the SPA and fronts the gateway",
|
|
38
|
+
);
|
|
39
|
+
console.log(
|
|
40
|
+
"for remote web access: browser → tunnel (TLS) → nginx@127.0.0.1.",
|
|
41
|
+
);
|
|
42
|
+
console.log("While nginx ingress is running, `vellum tunnel` targets it.");
|
|
43
|
+
console.log("");
|
|
44
|
+
console.log("Subcommands:");
|
|
45
|
+
console.log(" up Generate the nginx config and start the proxy");
|
|
46
|
+
console.log(" down Stop the proxy");
|
|
47
|
+
console.log(" status Show whether the proxy is running and where");
|
|
48
|
+
console.log("");
|
|
49
|
+
console.log("Arguments:");
|
|
50
|
+
console.log(
|
|
51
|
+
" <name> Name of the assistant (defaults to active or only local)",
|
|
52
|
+
);
|
|
53
|
+
console.log("");
|
|
54
|
+
console.log("Options:");
|
|
55
|
+
console.log(" --help, -h Show this help");
|
|
56
|
+
console.log("");
|
|
57
|
+
console.log("Environment:");
|
|
58
|
+
console.log(
|
|
59
|
+
` VELLUM_NGINX_INGRESS_PORT nginx ingress loopback listen port (default ${DEFAULT_NGINX_INGRESS_PORT})`,
|
|
60
|
+
);
|
|
61
|
+
console.log(" NGINX_BIN Path to the nginx binary");
|
|
62
|
+
console.log("");
|
|
63
|
+
console.log("Examples:");
|
|
64
|
+
console.log(" $ vellum nginx-ingress up");
|
|
65
|
+
console.log(" $ vellum nginx-ingress status");
|
|
66
|
+
console.log(" $ vellum nginx-ingress down my-assistant");
|
|
67
|
+
console.log("");
|
|
68
|
+
console.log("Feature flags:");
|
|
69
|
+
console.log(
|
|
70
|
+
` ${WEB_REMOTE_INGRESS_FLAG} must be enabled to start nginx ingress`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface NginxIngressTarget {
|
|
75
|
+
assistantId?: string;
|
|
76
|
+
workspaceDir: string;
|
|
77
|
+
gatewayPort: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parsePortFromUrl(url: unknown): number | undefined {
|
|
81
|
+
if (typeof url !== "string" || !url.trim()) return undefined;
|
|
82
|
+
try {
|
|
83
|
+
const port = Number(new URL(url).port);
|
|
84
|
+
return Number.isInteger(port) && port > 0 && port <= 65535
|
|
85
|
+
? port
|
|
86
|
+
: undefined;
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function resolveEntryGatewayPort(entry: AssistantEntry | undefined): number {
|
|
93
|
+
return (
|
|
94
|
+
parsePortFromUrl(entry?.localUrl) ??
|
|
95
|
+
parsePortFromUrl(entry?.runtimeUrl) ??
|
|
96
|
+
GATEWAY_PORT
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve which assistant nginx ingress fronts. Multi-instance hatches allocate
|
|
102
|
+
* per-assistant gateway ports and workspaces, so both must come from the
|
|
103
|
+
* resolved entry's resources. Entries without resources still record their
|
|
104
|
+
* reachable gateway URL, so derive the port from localUrl/runtimeUrl before
|
|
105
|
+
* falling back to the legacy default. Explicit names go through the shared
|
|
106
|
+
* identifier lookup (see cli/AGENTS.md "Assistant targeting convention") so
|
|
107
|
+
* display names resolve and ambiguous matches fail loudly.
|
|
108
|
+
*/
|
|
109
|
+
export function resolveNginxIngressTarget(
|
|
110
|
+
assistantName: string | null,
|
|
111
|
+
): NginxIngressTarget {
|
|
112
|
+
let entry: AssistantEntry | undefined;
|
|
113
|
+
if (assistantName) {
|
|
114
|
+
const result = lookupAssistantByIdentifier(assistantName);
|
|
115
|
+
if (result.status !== "found") {
|
|
116
|
+
throw new Error(formatAssistantLookupError(assistantName, result));
|
|
117
|
+
}
|
|
118
|
+
entry = result.entry;
|
|
119
|
+
} else {
|
|
120
|
+
entry = resolveAssistant() ?? undefined;
|
|
121
|
+
}
|
|
122
|
+
if (entry?.resources) {
|
|
123
|
+
return {
|
|
124
|
+
assistantId: entry.assistantId,
|
|
125
|
+
workspaceDir: join(entry.resources.instanceDir, ".vellum", "workspace"),
|
|
126
|
+
gatewayPort: entry.resources.gatewayPort,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
assistantId: entry?.assistantId,
|
|
131
|
+
workspaceDir:
|
|
132
|
+
process.env.VELLUM_WORKSPACE_DIR?.trim() ||
|
|
133
|
+
join(homedir(), ".vellum", "workspace"),
|
|
134
|
+
gatewayPort: resolveEntryGatewayPort(entry),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function assertWebRemoteIngressEnabled(
|
|
139
|
+
target: NginxIngressTarget,
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
if (!target.assistantId) {
|
|
142
|
+
throw new Error(formatFeatureFlagGateMessage(WEB_REMOTE_INGRESS_FLAG));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let enabled: boolean;
|
|
146
|
+
try {
|
|
147
|
+
enabled = await isAssistantFeatureFlagEnabled(
|
|
148
|
+
target.assistantId,
|
|
149
|
+
WEB_REMOTE_INGRESS_FLAG,
|
|
150
|
+
{ runtimeUrl: `http://127.0.0.1:${target.gatewayPort}` },
|
|
151
|
+
);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`Could not verify the \`${WEB_REMOTE_INGRESS_FLAG}\` feature flag. Is the assistant running? Try \`vellum wake\` and retry. ${
|
|
155
|
+
err instanceof Error ? err.message : String(err)
|
|
156
|
+
}`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!enabled) {
|
|
161
|
+
throw new Error(formatFeatureFlagGateMessage(WEB_REMOTE_INGRESS_FLAG));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function up(target: NginxIngressTarget): Promise<void> {
|
|
166
|
+
const { workspaceDir, gatewayPort } = target;
|
|
167
|
+
const listenPort = getNginxIngressPort();
|
|
168
|
+
|
|
169
|
+
await assertWebRemoteIngressEnabled(target);
|
|
170
|
+
|
|
171
|
+
const version = getNginxVersion();
|
|
172
|
+
if (!version) {
|
|
173
|
+
console.error("Error: nginx is not installed.");
|
|
174
|
+
console.error("");
|
|
175
|
+
console.error("Install nginx:");
|
|
176
|
+
console.error(" macOS: brew install nginx");
|
|
177
|
+
console.error(" Linux: sudo apt install nginx");
|
|
178
|
+
console.error("");
|
|
179
|
+
console.error(
|
|
180
|
+
"Or point NGINX_BIN at an existing binary: NGINX_BIN=/path/to/nginx",
|
|
181
|
+
);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (isIngressRunning(workspaceDir)) {
|
|
186
|
+
console.log("nginx ingress is already running.");
|
|
187
|
+
await status(target);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const webDistDir = findWebDistDir();
|
|
192
|
+
if (!webDistDir) {
|
|
193
|
+
console.error(
|
|
194
|
+
"Error: unable to locate built web assets for remote web ingress.",
|
|
195
|
+
);
|
|
196
|
+
console.error("");
|
|
197
|
+
console.error("Build the SPA first:");
|
|
198
|
+
console.error(" cd apps/web && VITE_PLATFORM_MODE=false bun run build");
|
|
199
|
+
console.error("");
|
|
200
|
+
console.error(
|
|
201
|
+
"Or install @vellumai/web so its packaged dist directory is available.",
|
|
202
|
+
);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
console.log(`Using ${version}`);
|
|
207
|
+
console.log(
|
|
208
|
+
`Starting nginx ingress on 127.0.0.1:${listenPort} → web ${webDistDir} + gateway 127.0.0.1:${gatewayPort}...`,
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const child = startIngressNginx({
|
|
212
|
+
workspaceDir,
|
|
213
|
+
gatewayPort,
|
|
214
|
+
listenPort,
|
|
215
|
+
remoteWebIngress: { webDistDir },
|
|
216
|
+
});
|
|
217
|
+
child.unref();
|
|
218
|
+
|
|
219
|
+
// /healthz proxies through nginx to the gateway, so a 200 proves the whole
|
|
220
|
+
// ingress → gateway path works.
|
|
221
|
+
const ready = await waitForDaemonReady(listenPort, READY_TIMEOUT_MS);
|
|
222
|
+
if (!ready) {
|
|
223
|
+
const { logPath } = getIngressPaths(workspaceDir);
|
|
224
|
+
await stopIngressNginx(workspaceDir);
|
|
225
|
+
console.error(
|
|
226
|
+
`Error: nginx ingress did not become reachable on 127.0.0.1:${listenPort}.`,
|
|
227
|
+
);
|
|
228
|
+
console.error(`Check the nginx log: ${logPath}`);
|
|
229
|
+
process.exit(1);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
console.log("");
|
|
233
|
+
console.log(`nginx ingress running: http://127.0.0.1:${listenPort}`);
|
|
234
|
+
console.log("");
|
|
235
|
+
console.log("Next steps:");
|
|
236
|
+
console.log(
|
|
237
|
+
" vellum tunnel --provider ngrok # tunnel now targets nginx ingress",
|
|
238
|
+
);
|
|
239
|
+
console.log(" vellum nginx-ingress down # stop the proxy");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function down(target: NginxIngressTarget): Promise<void> {
|
|
243
|
+
const stopped = await stopIngressNginx(target.workspaceDir);
|
|
244
|
+
if (!stopped && isIngressRunning(target.workspaceDir)) {
|
|
245
|
+
console.error("Error: nginx ingress is still running; could not stop it.");
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
console.log(
|
|
249
|
+
stopped ? "nginx ingress stopped." : "nginx ingress is not running.",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function status(target: NginxIngressTarget): Promise<void> {
|
|
254
|
+
const { workspaceDir, gatewayPort } = target;
|
|
255
|
+
const { confPath, logPath } = getIngressPaths(workspaceDir);
|
|
256
|
+
const pid = getIngressPid(workspaceDir);
|
|
257
|
+
if (pid === null) {
|
|
258
|
+
console.log("nginx ingress: not running");
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const { port } = resolveTunnelTargetPort(workspaceDir, gatewayPort);
|
|
262
|
+
console.log("nginx ingress: running");
|
|
263
|
+
console.log(` PID: ${pid}`);
|
|
264
|
+
console.log(` Listen: http://127.0.0.1:${port}`);
|
|
265
|
+
console.log(` Gateway: http://127.0.0.1:${gatewayPort}`);
|
|
266
|
+
console.log(` Config: ${confPath}`);
|
|
267
|
+
console.log(` Log: ${logPath}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function nginxIngress(): Promise<void> {
|
|
271
|
+
const args = process.argv.slice(3);
|
|
272
|
+
const sub = args[0];
|
|
273
|
+
|
|
274
|
+
if (!sub || sub === "--help" || sub === "-h") {
|
|
275
|
+
printHelp();
|
|
276
|
+
process.exit(sub ? 0 : 1);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Joins all remaining positionals so unquoted multi-word display names
|
|
280
|
+
// resolve as one identifier (cli/AGENTS.md "Assistant targeting convention").
|
|
281
|
+
const assistantName = parseAssistantTargetArg(args.slice(1));
|
|
282
|
+
const target = resolveNginxIngressTarget(assistantName ?? null);
|
|
283
|
+
|
|
284
|
+
if (sub === "up") return up(target);
|
|
285
|
+
if (sub === "down") return down(target);
|
|
286
|
+
if (sub === "status") return status(target);
|
|
287
|
+
|
|
288
|
+
console.error(`Error: Unknown subcommand '${sub}'.`);
|
|
289
|
+
console.error("Run 'vellum nginx-ingress --help' for usage.");
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
package/src/commands/rollback.ts
CHANGED
|
@@ -2,7 +2,6 @@ import {
|
|
|
2
2
|
findAssistantByName,
|
|
3
3
|
getActiveAssistant,
|
|
4
4
|
loadAllAssistants,
|
|
5
|
-
normalizeVersion,
|
|
6
5
|
resolveCloud,
|
|
7
6
|
saveAssistantEntry,
|
|
8
7
|
type AssistantEntry,
|
|
@@ -403,11 +402,6 @@ export async function rollback(): Promise<void> {
|
|
|
403
402
|
networkName: res.network,
|
|
404
403
|
},
|
|
405
404
|
previousContainerInfo: entry.containerInfo,
|
|
406
|
-
// Cleared (not preserved) when the rolled-back-to version is unknown
|
|
407
|
-
version:
|
|
408
|
-
previousVersion !== "unknown"
|
|
409
|
-
? normalizeVersion(previousVersion)
|
|
410
|
-
: undefined,
|
|
411
405
|
// Clear the backup path — it belonged to the upgrade we just rolled back
|
|
412
406
|
preUpgradeBackupPath: undefined,
|
|
413
407
|
previousDbMigrationVersion: undefined,
|
package/src/commands/sleep.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from "../lib/assistant-config.js";
|
|
8
8
|
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
9
9
|
import { dockerResourceNames, sleepContainers } from "../lib/docker.js";
|
|
10
|
+
import { stopIngressNginx } from "../lib/nginx-ingress.js";
|
|
10
11
|
import { isProcessAlive, stopProcessByPidFile } from "../lib/process";
|
|
11
12
|
|
|
12
13
|
const ACTIVE_CALL_LEASES_FILE = "active-call-leases.json";
|
|
@@ -134,9 +135,18 @@ export async function sleep(): Promise<void> {
|
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
// Stop assistant — use a generous timeout. On SIGTERM the daemon runs a
|
|
139
|
+
// WAL checkpoint before exiting, which can take several seconds on a
|
|
140
|
+
// multi-GB database. The default 2s grace in stopProcess() would SIGKILL a
|
|
141
|
+
// healthy daemon mid-checkpoint, forcing a costly multi-minute WAL recovery
|
|
142
|
+
// on the next start. The timeout is only a SIGKILL ceiling — stopProcess
|
|
143
|
+
// returns as soon as the process exits, so this adds no delay in the common
|
|
144
|
+
// case and only applies when the daemon is genuinely wedged.
|
|
137
145
|
const assistantStopped = await stopProcessByPidFile(
|
|
138
146
|
assistantPidFile,
|
|
139
147
|
"assistant",
|
|
148
|
+
undefined,
|
|
149
|
+
120_000,
|
|
140
150
|
);
|
|
141
151
|
if (!assistantStopped) {
|
|
142
152
|
console.log("Assistant is not running.");
|
|
@@ -157,4 +167,11 @@ export async function sleep(): Promise<void> {
|
|
|
157
167
|
} else {
|
|
158
168
|
console.log("Gateway stopped.");
|
|
159
169
|
}
|
|
170
|
+
|
|
171
|
+
// Stop the nginx ingress if one is fronting this gateway — otherwise it
|
|
172
|
+
// keeps running against a dead upstream and serves 502s.
|
|
173
|
+
const ingressStopped = await stopIngressNginx(join(vellumDir, "workspace"));
|
|
174
|
+
if (ingressStopped) {
|
|
175
|
+
console.log("nginx ingress stopped.");
|
|
176
|
+
}
|
|
160
177
|
}
|
package/src/commands/teleport.ts
CHANGED
|
@@ -391,9 +391,8 @@ async function exportFromAssistant(
|
|
|
391
391
|
// daemon, the CLI is updated separately).
|
|
392
392
|
let sourceRuntimeVersion: string;
|
|
393
393
|
try {
|
|
394
|
-
const identity = await callRuntimeWithAuthRetry(
|
|
395
|
-
entry,
|
|
396
|
-
async (token) => localRuntimeIdentity(entry, token),
|
|
394
|
+
const identity = await callRuntimeWithAuthRetry(entry, async (token) =>
|
|
395
|
+
localRuntimeIdentity(entry, token),
|
|
397
396
|
);
|
|
398
397
|
sourceRuntimeVersion = identity.version;
|
|
399
398
|
} catch (err) {
|
|
@@ -427,16 +426,13 @@ async function exportFromAssistant(
|
|
|
427
426
|
let jobId: string;
|
|
428
427
|
let accessToken: string;
|
|
429
428
|
try {
|
|
430
|
-
const result = await callRuntimeWithAuthRetry(
|
|
431
|
-
entry,
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
return { jobId: r.jobId, token };
|
|
438
|
-
},
|
|
439
|
-
);
|
|
429
|
+
const result = await callRuntimeWithAuthRetry(entry, async (token) => {
|
|
430
|
+
const r = await localRuntimeExportToGcs(entry, token, {
|
|
431
|
+
uploadUrl,
|
|
432
|
+
description: "teleport export",
|
|
433
|
+
});
|
|
434
|
+
return { jobId: r.jobId, token };
|
|
435
|
+
});
|
|
440
436
|
jobId = result.jobId;
|
|
441
437
|
accessToken = result.token;
|
|
442
438
|
} catch (err) {
|
|
@@ -734,9 +730,8 @@ async function importToAssistant(
|
|
|
734
730
|
// target can't actually load) whenever the two drift apart.
|
|
735
731
|
let targetRuntimeVersion: string;
|
|
736
732
|
try {
|
|
737
|
-
const identity = await callRuntimeWithAuthRetry(
|
|
738
|
-
entry,
|
|
739
|
-
(token) => localRuntimeIdentity(entry, token),
|
|
733
|
+
const identity = await callRuntimeWithAuthRetry(entry, (token) =>
|
|
734
|
+
localRuntimeIdentity(entry, token),
|
|
740
735
|
);
|
|
741
736
|
targetRuntimeVersion = identity.version;
|
|
742
737
|
} catch (err) {
|
|
@@ -779,15 +774,12 @@ async function importToAssistant(
|
|
|
779
774
|
let jobId: string;
|
|
780
775
|
let accessToken: string;
|
|
781
776
|
try {
|
|
782
|
-
const result = await callRuntimeWithAuthRetry(
|
|
783
|
-
entry,
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
return { jobId: r.jobId, token };
|
|
789
|
-
},
|
|
790
|
-
);
|
|
777
|
+
const result = await callRuntimeWithAuthRetry(entry, async (token) => {
|
|
778
|
+
const r = await localRuntimeImportFromGcs(entry, token, {
|
|
779
|
+
bundleUrl,
|
|
780
|
+
});
|
|
781
|
+
return { jobId: r.jobId, token };
|
|
782
|
+
});
|
|
791
783
|
jobId = result.jobId;
|
|
792
784
|
accessToken = result.token;
|
|
793
785
|
} catch (err) {
|
|
@@ -910,17 +902,12 @@ export async function resolveOrHatchTarget(
|
|
|
910
902
|
|
|
911
903
|
if (targetEnv === "docker") {
|
|
912
904
|
const beforeIds = new Set(loadAllAssistants().map((e) => e.assistantId));
|
|
913
|
-
await hatchDocker(
|
|
914
|
-
"vellum",
|
|
915
|
-
false,
|
|
916
|
-
targetName ?? null,
|
|
917
|
-
false,
|
|
918
|
-
|
|
919
|
-
{},
|
|
920
|
-
{
|
|
921
|
-
setupProviderCredentials: false,
|
|
922
|
-
},
|
|
923
|
-
);
|
|
905
|
+
await hatchDocker({
|
|
906
|
+
species: "vellum",
|
|
907
|
+
detached: false,
|
|
908
|
+
name: targetName ?? null,
|
|
909
|
+
setupProviderCredentials: false,
|
|
910
|
+
});
|
|
924
911
|
const entry = targetName
|
|
925
912
|
? findAssistantByName(targetName)
|
|
926
913
|
: (loadAllAssistants().find((e) => !beforeIds.has(e.assistantId)) ??
|
package/src/commands/tunnel.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { join } from "path";
|
|
2
2
|
|
|
3
|
-
import { resolveAssistant } from "../lib/assistant-config";
|
|
3
|
+
import { resolveAssistant, type AssistantEntry } from "../lib/assistant-config";
|
|
4
4
|
import { runCloudflareTunnel } from "../lib/cloudflare-tunnel.js";
|
|
5
|
+
import { GATEWAY_PORT } from "../lib/constants.js";
|
|
6
|
+
import {
|
|
7
|
+
isAssistantFeatureFlagEnabled,
|
|
8
|
+
WEB_REMOTE_INGRESS_FLAG,
|
|
9
|
+
} from "../lib/feature-flags.js";
|
|
5
10
|
import { runNgrokTunnel } from "../lib/ngrok";
|
|
6
11
|
|
|
7
12
|
const VALID_PROVIDERS = ["vellum", "ngrok", "cloudflare", "tailscale"] as const;
|
|
@@ -88,6 +93,46 @@ function parseArgs(): TunnelArgs {
|
|
|
88
93
|
return { assistantName, provider };
|
|
89
94
|
}
|
|
90
95
|
|
|
96
|
+
function parsePortFromUrl(url: unknown): number | undefined {
|
|
97
|
+
if (typeof url !== "string" || !url.trim()) return undefined;
|
|
98
|
+
try {
|
|
99
|
+
const port = Number(new URL(url).port);
|
|
100
|
+
return Number.isInteger(port) && port > 0 && port <= 65535
|
|
101
|
+
? port
|
|
102
|
+
: undefined;
|
|
103
|
+
} catch {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function resolveEntryGatewayPort(entry: AssistantEntry): number {
|
|
109
|
+
return (
|
|
110
|
+
entry.resources?.gatewayPort ??
|
|
111
|
+
parsePortFromUrl(entry.localUrl) ??
|
|
112
|
+
parsePortFromUrl(entry.runtimeUrl) ??
|
|
113
|
+
GATEWAY_PORT
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function shouldPreferNginxIngress(
|
|
118
|
+
assistantId: string,
|
|
119
|
+
gatewayPort: number,
|
|
120
|
+
): Promise<boolean> {
|
|
121
|
+
try {
|
|
122
|
+
return await isAssistantFeatureFlagEnabled(
|
|
123
|
+
assistantId,
|
|
124
|
+
WEB_REMOTE_INGRESS_FLAG,
|
|
125
|
+
{ runtimeUrl: `http://127.0.0.1:${gatewayPort}` },
|
|
126
|
+
);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Could not verify the \`${WEB_REMOTE_INGRESS_FLAG}\` feature flag before starting the tunnel. Is the assistant running? Try \`vellum wake\` and retry. ${
|
|
130
|
+
err instanceof Error ? err.message : String(err)
|
|
131
|
+
}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
91
136
|
export async function tunnel(): Promise<void> {
|
|
92
137
|
const { assistantName, provider } = parseArgs();
|
|
93
138
|
|
|
@@ -104,21 +149,34 @@ export async function tunnel(): Promise<void> {
|
|
|
104
149
|
process.exit(1);
|
|
105
150
|
}
|
|
106
151
|
|
|
152
|
+
const resources = entry.resources;
|
|
153
|
+
const gatewayPort = resolveEntryGatewayPort(entry);
|
|
154
|
+
const baseTunnelOpts = {
|
|
155
|
+
port: gatewayPort,
|
|
156
|
+
...(resources
|
|
157
|
+
? { workspaceDir: join(resources.instanceDir, ".vellum", "workspace") }
|
|
158
|
+
: {}),
|
|
159
|
+
};
|
|
160
|
+
|
|
107
161
|
if (provider === "ngrok") {
|
|
108
|
-
await runNgrokTunnel(
|
|
162
|
+
await runNgrokTunnel({
|
|
163
|
+
...baseTunnelOpts,
|
|
164
|
+
preferNginxIngress: await shouldPreferNginxIngress(
|
|
165
|
+
entry.assistantId,
|
|
166
|
+
gatewayPort,
|
|
167
|
+
),
|
|
168
|
+
});
|
|
109
169
|
return;
|
|
110
170
|
}
|
|
111
171
|
|
|
112
172
|
if (provider === "cloudflare") {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
: {},
|
|
121
|
-
);
|
|
173
|
+
await runCloudflareTunnel({
|
|
174
|
+
...baseTunnelOpts,
|
|
175
|
+
preferNginxIngress: await shouldPreferNginxIngress(
|
|
176
|
+
entry.assistantId,
|
|
177
|
+
gatewayPort,
|
|
178
|
+
),
|
|
179
|
+
});
|
|
122
180
|
return;
|
|
123
181
|
}
|
|
124
182
|
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
findAssistantByName,
|
|
7
7
|
getActiveAssistant,
|
|
8
8
|
loadAllAssistants,
|
|
9
|
-
normalizeVersion,
|
|
10
9
|
resolveCloud,
|
|
11
10
|
saveAssistantEntry,
|
|
12
11
|
type AssistantEntry,
|
|
@@ -496,7 +495,6 @@ async function upgradeDocker(
|
|
|
496
495
|
previousContainerInfo: entry.containerInfo,
|
|
497
496
|
previousDbMigrationVersion: preMigrationState.dbVersion,
|
|
498
497
|
previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
|
|
499
|
-
version: normalizeVersion(versionTag),
|
|
500
498
|
// Preserve the backup path so `vellum rollback` can restore it later
|
|
501
499
|
preUpgradeBackupPath: backupPath ?? undefined,
|
|
502
500
|
};
|
package/src/commands/wake.ts
CHANGED
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
import { dockerResourceNames, wakeContainers } from "../lib/docker.js";
|
|
10
10
|
import {
|
|
11
11
|
leaseGuardianToken,
|
|
12
|
-
loadGuardianToken,
|
|
13
12
|
resetGuardianBootstrap,
|
|
14
13
|
seedGuardianTokenFromSiblingEnv,
|
|
15
14
|
} from "../lib/guardian-token.js";
|
|
@@ -43,7 +42,7 @@ export async function wake(): Promise<void> {
|
|
|
43
42
|
" --foreground Run assistant in foreground with logs printed to terminal",
|
|
44
43
|
);
|
|
45
44
|
console.log(
|
|
46
|
-
" --repair-guardian
|
|
45
|
+
" --repair-guardian Force-re-provision the guardian token (resets the\n" +
|
|
47
46
|
" gateway bootstrap and re-leases — REVOKES other device-bound\n" +
|
|
48
47
|
" tokens, so only use deliberately, never from auto-repair)",
|
|
49
48
|
);
|
|
@@ -238,8 +237,11 @@ export async function wake(): Promise<void> {
|
|
|
238
237
|
console.log(" Seeded guardian token from sibling environment.");
|
|
239
238
|
}
|
|
240
239
|
|
|
241
|
-
// Last-resort recovery (explicit `--repair-guardian` only):
|
|
242
|
-
//
|
|
240
|
+
// Last-resort recovery (explicit `--repair-guardian` only): force a
|
|
241
|
+
// re-provision. Token health can't be judged locally — a connect can 401
|
|
242
|
+
// off a token whose local expiry looks fine (revoked, mis-seeded, wrong
|
|
243
|
+
// principal) — and the user explicitly confirmed the destructive repair,
|
|
244
|
+
// so guessing "looks healthy, skip" just recreates the no-op loop. The
|
|
243
245
|
// single-use bootstrap secret may already be spent — a prior connect can
|
|
244
246
|
// lease a token that's then lost, or the gateway marks the secret consumed
|
|
245
247
|
// before the client persists it — which otherwise bricks connect into a
|
|
@@ -248,7 +250,7 @@ export async function wake(): Promise<void> {
|
|
|
248
250
|
// by the lockfile secret — mirrors the macOS client's forceReBootstrap), then
|
|
249
251
|
// re-lease. Gated behind the flag because the re-lease revokes other
|
|
250
252
|
// device-bound tokens; it must never run from the automatic repair path.
|
|
251
|
-
if (repairGuardian
|
|
253
|
+
if (repairGuardian) {
|
|
252
254
|
const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`;
|
|
253
255
|
const maxAttempts = 3;
|
|
254
256
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|