@vellumai/cli 0.11.2 → 0.11.3-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.
- package/knip.json +1 -0
- package/node_modules/@vellumai/environments/package.json +2 -1
- package/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
- package/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
- package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
- package/node_modules/@vellumai/environments/src/index.ts +17 -6
- package/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
- package/node_modules/@vellumai/environments/src/seeds.ts +29 -0
- package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
- package/node_modules/@vellumai/ipc-server-utils/src/endpoint.test.ts +36 -0
- package/node_modules/@vellumai/ipc-server-utils/src/endpoint.ts +142 -0
- package/node_modules/@vellumai/ipc-server-utils/src/index.ts +18 -0
- package/node_modules/@vellumai/ipc-server-utils/src/ipc-framing.ts +295 -0
- package/node_modules/@vellumai/ipc-server-utils/src/listen-options.ts +3 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +444 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +236 -0
- package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/package.json +2 -1
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/index.ts +17 -6
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/seeds.ts +29 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/LICENSE +20 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/README.md +38 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/bin/nanoid.js +55 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.browser.js +29 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.d.ts +106 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.js +47 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/nanoid.js +1 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.d.ts +48 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.js +21 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/package.json +46 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/url-alphabet/index.js +2 -0
- package/node_modules/@vellumai/local-mode/package.json +1 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +63 -1
- package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +503 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/guardian-token.test.ts +203 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/loopback-auth.test.ts +46 -1
- package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +6 -4
- package/node_modules/@vellumai/local-mode/src/__tests__/pair.test.ts +621 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +5 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +232 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +126 -20
- package/node_modules/@vellumai/local-mode/src/environment.ts +36 -15
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +313 -25
- package/node_modules/@vellumai/local-mode/src/guardian-token.ts +222 -13
- package/node_modules/@vellumai/local-mode/src/index.ts +35 -3
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +18 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +72 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +130 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +134 -45
- package/node_modules/@vellumai/local-mode/src/pair.ts +354 -0
- package/node_modules/@vellumai/local-mode/src/paths.ts +49 -0
- package/node_modules/@vellumai/local-mode/src/status.ts +10 -8
- package/node_modules/@vellumai/local-mode/src/unpair.ts +101 -0
- package/node_modules/@vellumai/local-mode/src/util.ts +34 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +39 -0
- package/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
- package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +6 -0
- package/package.json +3 -1
- package/src/__tests__/client-token.test.ts +26 -1
- package/src/__tests__/guardian-token.test.ts +4 -0
- package/src/__tests__/nginx-ingress-command.test.ts +176 -16
- package/src/__tests__/nginx-ingress.test.ts +1821 -101
- package/src/__tests__/pair.test.ts +104 -2
- package/src/__tests__/sleep.test.ts +7 -3
- package/src/__tests__/tailscale-tunnel.test.ts +0 -1
- package/src/__tests__/tunnel.test.ts +1099 -55
- package/src/__tests__/wake.test.ts +166 -99
- package/src/commands/client.ts +182 -27
- package/src/commands/connect/import.ts +35 -156
- package/src/commands/nginx-ingress.ts +87 -110
- package/src/commands/pair.ts +84 -21
- package/src/commands/tunnel.ts +196 -85
- package/src/commands/upgrade.ts +3 -2
- package/src/commands/wake.ts +5 -163
- package/src/index.ts +1 -1
- package/src/lib/__tests__/local-ces.test.ts +10 -1
- package/src/lib/cloudflare-tunnel.ts +3 -16
- package/src/lib/environments/__tests__/paths.test.ts +20 -1
- package/src/lib/environments/paths.ts +29 -41
- package/src/lib/environments/resolve.ts +7 -5
- package/src/lib/guardian-token.ts +22 -62
- package/src/lib/ingress-config.ts +25 -0
- package/src/lib/local.ts +77 -20
- package/src/lib/nginx-ingress.ts +523 -76
- package/src/lib/ngrok.ts +154 -61
- package/src/lib/orphan-detection.test.ts +167 -0
- package/src/lib/orphan-detection.ts +40 -0
- package/src/lib/pair.test.ts +87 -0
- package/src/lib/pair.ts +55 -0
- package/src/lib/platform-client.ts +8 -5
- package/src/lib/tailscale-tunnel.ts +7 -18
- package/src/lib/tunnel-edge.ts +114 -0
- package/src/lib/xdg-log.ts +2 -2
package/src/lib/ngrok.ts
CHANGED
|
@@ -6,11 +6,12 @@ import { GATEWAY_PORT } from "./constants.js";
|
|
|
6
6
|
import {
|
|
7
7
|
clearIngressUrl,
|
|
8
8
|
getDefaultWorkspaceDir,
|
|
9
|
+
loadNgrokDomain,
|
|
9
10
|
loadRawConfig,
|
|
10
11
|
saveIngressUrl,
|
|
12
|
+
saveNgrokDomain,
|
|
11
13
|
} from "./ingress-config.js";
|
|
12
14
|
import { loopbackSafeFetch } from "./loopback-fetch.js";
|
|
13
|
-
import { resolveTunnelTargetPort } from "./nginx-ingress.js";
|
|
14
15
|
|
|
15
16
|
const NGROK_API_URL = "http://127.0.0.1:4040/api/tunnels";
|
|
16
17
|
const NGROK_POLL_INTERVAL_MS = 500;
|
|
@@ -59,40 +60,59 @@ async function queryNgrokTunnels(): Promise<NgrokTunnel[] | null> {
|
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
* Returns the HTTPS public URL if found, null otherwise.
|
|
65
|
-
*/
|
|
66
|
-
export async function findExistingTunnel(
|
|
67
|
-
targetPort: number,
|
|
68
|
-
): Promise<string | null> {
|
|
69
|
-
const tunnels = await queryNgrokTunnels();
|
|
70
|
-
if (!tunnels || tunnels.length === 0) return null;
|
|
71
|
-
|
|
63
|
+
/** Whether a tunnel targets the given local port, under any addr spelling. */
|
|
64
|
+
function tunnelTargetsPort(tunnel: NgrokTunnel, targetPort: number): boolean {
|
|
72
65
|
const targetAddrs = [
|
|
73
66
|
`localhost:${targetPort}`,
|
|
74
67
|
`127.0.0.1:${targetPort}`,
|
|
75
68
|
`http://localhost:${targetPort}`,
|
|
76
69
|
`http://127.0.0.1:${targetPort}`,
|
|
77
70
|
];
|
|
71
|
+
return targetAddrs.includes(tunnel.config?.addr ?? "");
|
|
72
|
+
}
|
|
78
73
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
74
|
+
/** Pick the tunnel for the target port (and domain, when set), HTTPS first. */
|
|
75
|
+
function pickMatchingTunnel(
|
|
76
|
+
tunnels: NgrokTunnel[],
|
|
77
|
+
targetPort: number,
|
|
78
|
+
domain?: string,
|
|
79
|
+
): string | null {
|
|
80
|
+
const matches = tunnels.filter(
|
|
81
|
+
(t) =>
|
|
82
|
+
tunnelTargetsPort(t, targetPort) &&
|
|
83
|
+
(!domain || urlMatchesDomain(t.public_url, domain)),
|
|
84
|
+
);
|
|
85
|
+
const httpsTunnel = matches.find((t) => t.public_url.startsWith("https://"));
|
|
86
|
+
if (httpsTunnel) return httpsTunnel.public_url;
|
|
87
|
+
return matches.find((t) => t.public_url)?.public_url ?? null;
|
|
88
|
+
}
|
|
86
89
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
/** Render listed tunnels as `url -> addr` pairs for mismatch diagnostics. */
|
|
91
|
+
function describeTunnels(tunnels: NgrokTunnel[]): string {
|
|
92
|
+
return tunnels
|
|
93
|
+
.map((t) => `${t.public_url} -> ${t.config?.addr ?? "unknown target"}`)
|
|
94
|
+
.join(", ");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Diagnostic for a running ngrok agent whose tunnels all miss the target port. */
|
|
98
|
+
function staleAgentDiagnostic(tunnels: NgrokTunnel[], port: number): string {
|
|
99
|
+
return `an ngrok agent is already running but tunnels a different local port (${describeTunnels(tunnels)}), not ${port}. It was likely started before the tunnel edge unification or by an external process.`;
|
|
100
|
+
}
|
|
94
101
|
|
|
95
|
-
|
|
102
|
+
/** Recovery copy for a saved domain whose reservation may have lapsed. */
|
|
103
|
+
function savedDomainRecoveryHint(domain: string): string {
|
|
104
|
+
return `The saved ngrok domain '${domain}' (ingress.ngrok.domain in the workspace config) may no longer be reserved. Run \`vellum tunnel --provider ngrok --clear-domain\` to drop it and tunnel without one.`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Whether a tunnel public URL's host equals the given reserved domain. */
|
|
108
|
+
function urlMatchesDomain(publicUrl: string, domain: string): boolean {
|
|
109
|
+
try {
|
|
110
|
+
return (
|
|
111
|
+
new URL(publicUrl).hostname.toLowerCase() === domain.trim().toLowerCase()
|
|
112
|
+
);
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
96
116
|
}
|
|
97
117
|
|
|
98
118
|
/**
|
|
@@ -103,11 +123,15 @@ export async function findExistingTunnel(
|
|
|
103
123
|
* parent process — which would either prevent the CLI from exiting (if
|
|
104
124
|
* handles are left open) or send SIGPIPE to ngrok (if destroyed).
|
|
105
125
|
*
|
|
126
|
+
* When `domain` is set, the tunnel binds that reserved ngrok domain via
|
|
127
|
+
* `--domain=<domain>`.
|
|
128
|
+
*
|
|
106
129
|
* Returns the spawned child process.
|
|
107
130
|
*/
|
|
108
131
|
export function startNgrokProcess(
|
|
109
132
|
targetPort: number,
|
|
110
133
|
logFilePath?: string,
|
|
134
|
+
domain?: string,
|
|
111
135
|
): ChildProcess {
|
|
112
136
|
let stdio: ("ignore" | "pipe" | number)[] = ["ignore", "pipe", "pipe"];
|
|
113
137
|
let fd: number | undefined;
|
|
@@ -118,7 +142,14 @@ export function startNgrokProcess(
|
|
|
118
142
|
stdio = ["ignore", fd, fd];
|
|
119
143
|
}
|
|
120
144
|
|
|
121
|
-
|
|
145
|
+
// Explicit over a bare port, which ngrok expands to `localhost`, whose ::1
|
|
146
|
+
// answer resolves first on macOS and races the edge's IPv4 bind.
|
|
147
|
+
const args = ["http", `127.0.0.1:${targetPort}`, "--log=stdout"];
|
|
148
|
+
if (domain) {
|
|
149
|
+
args.push(`--domain=${domain}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const child = spawn("ngrok", args, {
|
|
122
153
|
detached: true,
|
|
123
154
|
stdio,
|
|
124
155
|
});
|
|
@@ -134,22 +165,21 @@ export function startNgrokProcess(
|
|
|
134
165
|
}
|
|
135
166
|
|
|
136
167
|
/**
|
|
137
|
-
* Poll the ngrok local API until
|
|
168
|
+
* Poll the ngrok local API until a tunnel appears for the target port (and
|
|
169
|
+
* reserved domain, when one is requested), preferring HTTPS.
|
|
138
170
|
* Returns the public URL, or throws if the timeout is exceeded.
|
|
139
171
|
*/
|
|
140
172
|
export async function waitForNgrokUrl(
|
|
173
|
+
targetPort: number,
|
|
174
|
+
domain?: string,
|
|
141
175
|
timeoutMs: number = NGROK_POLL_TIMEOUT_MS,
|
|
142
176
|
): Promise<string> {
|
|
143
177
|
const start = Date.now();
|
|
144
178
|
while (Date.now() - start < timeoutMs) {
|
|
145
179
|
const tunnels = await queryNgrokTunnels();
|
|
146
180
|
if (tunnels && tunnels.length > 0) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
t.public_url.startsWith("https://"),
|
|
150
|
-
);
|
|
151
|
-
if (httpsTunnel) return httpsTunnel.public_url;
|
|
152
|
-
if (tunnels[0]?.public_url) return tunnels[0].public_url;
|
|
181
|
+
const url = pickMatchingTunnel(tunnels, targetPort, domain);
|
|
182
|
+
if (url) return url;
|
|
153
183
|
}
|
|
154
184
|
await new Promise((r) => setTimeout(r, NGROK_POLL_INTERVAL_MS));
|
|
155
185
|
}
|
|
@@ -158,18 +188,27 @@ export async function waitForNgrokUrl(
|
|
|
158
188
|
);
|
|
159
189
|
}
|
|
160
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Check whether an already-loaded workspace config has webhook-based
|
|
193
|
+
* integrations (e.g. Telegram, Twilio) that require a public ingress URL.
|
|
194
|
+
*/
|
|
195
|
+
export function hasWebhookIntegrations(
|
|
196
|
+
config: Record<string, unknown>,
|
|
197
|
+
): boolean {
|
|
198
|
+
const telegram = config.telegram as Record<string, unknown> | undefined;
|
|
199
|
+
if (telegram?.botUsername) return true;
|
|
200
|
+
const twilio = config.twilio as Record<string, unknown> | undefined;
|
|
201
|
+
if (twilio?.accountSid || twilio?.phoneNumber) return true;
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
161
205
|
/**
|
|
162
206
|
* Check whether any webhook-based integrations (e.g. Telegram, Twilio) are
|
|
163
207
|
* configured that require a public ingress URL.
|
|
164
208
|
*/
|
|
165
209
|
function hasWebhookIntegrationsConfigured(workspaceDir: string): boolean {
|
|
166
210
|
try {
|
|
167
|
-
|
|
168
|
-
const telegram = config.telegram as Record<string, unknown> | undefined;
|
|
169
|
-
if (telegram?.botUsername) return true;
|
|
170
|
-
const twilio = config.twilio as Record<string, unknown> | undefined;
|
|
171
|
-
if (twilio?.accountSid || twilio?.phoneNumber) return true;
|
|
172
|
-
return false;
|
|
211
|
+
return hasWebhookIntegrations(loadRawConfig(workspaceDir));
|
|
173
212
|
} catch {
|
|
174
213
|
return false;
|
|
175
214
|
}
|
|
@@ -215,13 +254,34 @@ export async function maybeStartNgrokTunnel(
|
|
|
215
254
|
const version = getNgrokVersion();
|
|
216
255
|
if (!version) return null;
|
|
217
256
|
|
|
257
|
+
const savedDomain = loadNgrokDomain(workspaceDir) ?? undefined;
|
|
258
|
+
|
|
218
259
|
// Reuse an existing tunnel if one is already running
|
|
219
|
-
const
|
|
260
|
+
const runningTunnels = (await queryNgrokTunnels()) ?? [];
|
|
261
|
+
const existingUrl = pickMatchingTunnel(runningTunnels, targetPort);
|
|
220
262
|
if (existingUrl) {
|
|
263
|
+
if (savedDomain && !urlMatchesDomain(existingUrl, savedDomain)) {
|
|
264
|
+
// Spawning a second agent would collide (ERR_NGROK_334), and saving the
|
|
265
|
+
// mismatched URL would clobber the reserved-domain intent. Leave the
|
|
266
|
+
// tunnel running but refuse to bless it in config.
|
|
267
|
+
console.warn(
|
|
268
|
+
` ⚠ Existing ngrok tunnel ${existingUrl} does not match the reserved domain '${savedDomain}'. Ignoring it. Stop the running ngrok agent and run \`vellum tunnel --provider ngrok --domain ${savedDomain}\` to bind the reserved domain.`,
|
|
269
|
+
);
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
221
272
|
console.log(` Found existing ngrok tunnel: ${existingUrl}`);
|
|
222
273
|
saveIngressUrl(workspaceDir, existingUrl);
|
|
223
274
|
return null;
|
|
224
275
|
}
|
|
276
|
+
if (runningTunnels.length > 0) {
|
|
277
|
+
// An agent is up but tunnels some other local port (likely started before
|
|
278
|
+
// the edge unification, or by an external process). Spawning a second
|
|
279
|
+
// agent would collide (ERR_NGROK_334) on single-agent plans, so skip.
|
|
280
|
+
console.warn(
|
|
281
|
+
` ⚠ ${staleAgentDiagnostic(runningTunnels, targetPort)} Stop that ngrok agent, then run \`vellum tunnel --provider ngrok\` to tunnel the local edge.`,
|
|
282
|
+
);
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
225
285
|
|
|
226
286
|
console.log(` Starting ngrok tunnel for webhook integrations...`);
|
|
227
287
|
|
|
@@ -232,33 +292,39 @@ export async function maybeStartNgrokTunnel(
|
|
|
232
292
|
// Writing to a log file sidesteps both issues — the file descriptor is
|
|
233
293
|
// inherited by the detached ngrok process and remains valid after CLI exit.
|
|
234
294
|
const ngrokLogPath = join(workspaceDir, "data", "logs", "ngrok.log");
|
|
235
|
-
const ngrokProcess = startNgrokProcess(targetPort, ngrokLogPath);
|
|
295
|
+
const ngrokProcess = startNgrokProcess(targetPort, ngrokLogPath, savedDomain);
|
|
236
296
|
ngrokProcess.unref();
|
|
237
297
|
|
|
238
298
|
try {
|
|
239
|
-
const publicUrl = await waitForNgrokUrl();
|
|
299
|
+
const publicUrl = await waitForNgrokUrl(targetPort, savedDomain);
|
|
240
300
|
saveIngressUrl(workspaceDir, publicUrl);
|
|
241
301
|
console.log(` Tunnel established: ${publicUrl}`);
|
|
242
302
|
|
|
243
303
|
return ngrokProcess;
|
|
244
304
|
} catch {
|
|
245
305
|
console.warn(
|
|
246
|
-
` ⚠ Could not start ngrok tunnel. Webhook integrations may not work until you run \`vellum tunnel\`.`,
|
|
306
|
+
` ⚠ Could not start ngrok tunnel. Webhook integrations may not work until you run \`vellum tunnel --provider ngrok\`.`,
|
|
247
307
|
);
|
|
308
|
+
if (savedDomain) {
|
|
309
|
+
console.warn(` ⚠ ${savedDomainRecoveryHint(savedDomain)}`);
|
|
310
|
+
}
|
|
248
311
|
if (!ngrokProcess.killed) ngrokProcess.kill("SIGTERM");
|
|
249
312
|
return null;
|
|
250
313
|
}
|
|
251
314
|
}
|
|
252
315
|
|
|
253
316
|
export interface RunNgrokTunnelOptions {
|
|
254
|
-
/**
|
|
317
|
+
/** Local edge port to forward. Defaults to the global GATEWAY_PORT. */
|
|
255
318
|
port?: number;
|
|
256
319
|
/** Workspace directory for config read/write. Defaults to ~/.vellum/workspace. */
|
|
257
320
|
workspaceDir?: string;
|
|
258
|
-
/** Prefer nginx ingress over the gateway port when it is running. */
|
|
259
|
-
preferNginxIngress?: boolean;
|
|
260
321
|
/** Lockfile entry to mirror the ingress URL onto (`ingressUrl`). */
|
|
261
322
|
assistantId?: string;
|
|
323
|
+
/**
|
|
324
|
+
* Reserved ngrok domain to bind. Persisted so wake restores reuse it.
|
|
325
|
+
* When omitted, a previously saved domain is reused without being rewritten.
|
|
326
|
+
*/
|
|
327
|
+
domain?: string;
|
|
262
328
|
}
|
|
263
329
|
|
|
264
330
|
/**
|
|
@@ -286,23 +352,42 @@ export async function runNgrokTunnel(
|
|
|
286
352
|
console.log(`Using ${version}`);
|
|
287
353
|
|
|
288
354
|
const workspaceDir = opts.workspaceDir ?? getDefaultWorkspaceDir();
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
)
|
|
295
|
-
|
|
296
|
-
console.log(
|
|
297
|
-
`nginx ingress detected — tunneling to it on 127.0.0.1:${port}.`,
|
|
298
|
-
);
|
|
355
|
+
const port = opts.port ?? GATEWAY_PORT;
|
|
356
|
+
|
|
357
|
+
// The saved domain is standing intent: a run without --domain reuses it,
|
|
358
|
+
// and only an explicit --domain rewrites it.
|
|
359
|
+
const domain = opts.domain ?? loadNgrokDomain(workspaceDir) ?? undefined;
|
|
360
|
+
if (domain && !opts.domain) {
|
|
361
|
+
console.log(`Using saved ngrok domain: ${domain}`);
|
|
299
362
|
}
|
|
300
363
|
|
|
301
|
-
// Check for an existing ngrok tunnel pointing at the
|
|
302
|
-
const
|
|
364
|
+
// Check for an existing ngrok tunnel pointing at the local edge
|
|
365
|
+
const runningTunnels = (await queryNgrokTunnels()) ?? [];
|
|
366
|
+
const existingUrl = pickMatchingTunnel(runningTunnels, port);
|
|
367
|
+
if (!existingUrl && runningTunnels.length > 0) {
|
|
368
|
+
// Spawning a second agent would collide (ERR_NGROK_334) on single-agent
|
|
369
|
+
// plans; fail loudly instead, matching the domain-mismatch path.
|
|
370
|
+
console.error(`Error: ${staleAgentDiagnostic(runningTunnels, port)}`);
|
|
371
|
+
console.error(
|
|
372
|
+
"Stop the existing ngrok agent first, then re-run this command to tunnel the local edge.",
|
|
373
|
+
);
|
|
374
|
+
process.exit(1);
|
|
375
|
+
}
|
|
303
376
|
if (existingUrl) {
|
|
377
|
+
if (domain && !urlMatchesDomain(existingUrl, domain)) {
|
|
378
|
+
console.error(
|
|
379
|
+
`Error: an ngrok tunnel is already running on port ${port} at ${existingUrl}, which does not match the ${opts.domain ? "requested" : "saved"} domain '${domain}'.`,
|
|
380
|
+
);
|
|
381
|
+
console.error(
|
|
382
|
+
"Stop the existing ngrok agent first, then re-run this command to bind the reserved domain.",
|
|
383
|
+
);
|
|
384
|
+
process.exit(1);
|
|
385
|
+
}
|
|
304
386
|
console.log(`Found existing ngrok tunnel: ${existingUrl}`);
|
|
305
387
|
saveIngressUrl(workspaceDir, existingUrl, opts.assistantId);
|
|
388
|
+
if (opts.domain) {
|
|
389
|
+
saveNgrokDomain(workspaceDir, opts.domain);
|
|
390
|
+
}
|
|
306
391
|
console.log("Ingress URL saved to config.");
|
|
307
392
|
console.log("");
|
|
308
393
|
console.log(
|
|
@@ -321,7 +406,7 @@ export async function runNgrokTunnel(
|
|
|
321
406
|
|
|
322
407
|
let publicUrl: string | undefined;
|
|
323
408
|
|
|
324
|
-
const ngrokProcess = startNgrokProcess(port);
|
|
409
|
+
const ngrokProcess = startNgrokProcess(port, undefined, domain);
|
|
325
410
|
|
|
326
411
|
const cleanup = () => {
|
|
327
412
|
if (!ngrokProcess.killed) {
|
|
@@ -368,9 +453,12 @@ export async function runNgrokTunnel(
|
|
|
368
453
|
});
|
|
369
454
|
|
|
370
455
|
try {
|
|
371
|
-
publicUrl = await waitForNgrokUrl();
|
|
456
|
+
publicUrl = await waitForNgrokUrl(port, domain);
|
|
372
457
|
} catch (err) {
|
|
373
458
|
cleanup();
|
|
459
|
+
if (domain && !opts.domain) {
|
|
460
|
+
console.error(savedDomainRecoveryHint(domain));
|
|
461
|
+
}
|
|
374
462
|
throw err;
|
|
375
463
|
}
|
|
376
464
|
|
|
@@ -378,7 +466,12 @@ export async function runNgrokTunnel(
|
|
|
378
466
|
console.log(`Tunnel established: ${publicUrl}`);
|
|
379
467
|
console.log(`Forwarding to: localhost:${port}`);
|
|
380
468
|
|
|
469
|
+
// The domain is standing intent, not tunnel state: cleanup clears the
|
|
470
|
+
// ingress URL but leaves the domain saved for wake/daemon restores.
|
|
381
471
|
saveIngressUrl(workspaceDir, publicUrl, opts.assistantId);
|
|
472
|
+
if (opts.domain) {
|
|
473
|
+
saveNgrokDomain(workspaceDir, opts.domain);
|
|
474
|
+
}
|
|
382
475
|
console.log("Ingress URL saved to config.");
|
|
383
476
|
console.log("");
|
|
384
477
|
console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { afterAll, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
classifyProcess,
|
|
5
|
+
detectOrphanedProcesses,
|
|
6
|
+
isInteractiveCliSession,
|
|
7
|
+
} from "./orphan-detection.js";
|
|
8
|
+
|
|
9
|
+
// `mock.restore()` does not undo `mock.module()`; keep the real module so it
|
|
10
|
+
// can be restored in afterAll for later test files in the same run.
|
|
11
|
+
const realStepRunner = { ...(await import("./step-runner")) };
|
|
12
|
+
|
|
13
|
+
describe("isInteractiveCliSession", () => {
|
|
14
|
+
test("matches a live tunnel session launched via a script path", () => {
|
|
15
|
+
expect(
|
|
16
|
+
isInteractiveCliSession(
|
|
17
|
+
"bun /Users/x/.nvm/versions/node/v22.14.0/bin/vellum tunnel --provider ngrok",
|
|
18
|
+
),
|
|
19
|
+
).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("matches interactive subcommands like logs and terminal", () => {
|
|
23
|
+
expect(isInteractiveCliSession("vellum logs foo")).toBe(true);
|
|
24
|
+
expect(isInteractiveCliSession("vellum terminal")).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("matches the other long-running interactive subcommands", () => {
|
|
28
|
+
expect(isInteractiveCliSession("vellum events")).toBe(true);
|
|
29
|
+
expect(isInteractiveCliSession("vellum client")).toBe(true);
|
|
30
|
+
expect(isInteractiveCliSession("vellum ssh quiet-finch")).toBe(true);
|
|
31
|
+
expect(isInteractiveCliSession("vellum message quiet-finch hi")).toBe(true);
|
|
32
|
+
expect(isInteractiveCliSession("vellum workflows")).toBe(true);
|
|
33
|
+
expect(isInteractiveCliSession("vellum-cli tunnel --provider ngrok")).toBe(
|
|
34
|
+
true,
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("allows the known global flags before the subcommand", () => {
|
|
39
|
+
expect(isInteractiveCliSession("vellum --plain tunnel")).toBe(true);
|
|
40
|
+
expect(isInteractiveCliSession("vellum --no-color logs foo")).toBe(true);
|
|
41
|
+
expect(isInteractiveCliSession("vellum --no-color --plain events")).toBe(
|
|
42
|
+
true,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("matches exec sessions even when argv mentions a service name", () => {
|
|
47
|
+
expect(
|
|
48
|
+
isInteractiveCliSession(
|
|
49
|
+
"vellum exec -it --service vellum-gateway -- /bin/sh",
|
|
50
|
+
),
|
|
51
|
+
).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("does not match non-interactive CLI wrappers", () => {
|
|
55
|
+
expect(isInteractiveCliSession("vellum hatch")).toBe(false);
|
|
56
|
+
expect(isInteractiveCliSession("/usr/bin/vellum sleep")).toBe(false);
|
|
57
|
+
expect(isInteractiveCliSession("vellum wake")).toBe(false);
|
|
58
|
+
expect(isInteractiveCliSession("vellum wake --watch")).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("matches the implicit TUI client (bare vellum)", () => {
|
|
62
|
+
expect(isInteractiveCliSession("vellum")).toBe(true);
|
|
63
|
+
expect(isInteractiveCliSession("bun /Users/x/.nvm/bin/vellum")).toBe(true);
|
|
64
|
+
expect(isInteractiveCliSession("vellum --plain")).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("matches a foreground wake session", () => {
|
|
68
|
+
expect(isInteractiveCliSession("vellum wake --foreground")).toBe(true);
|
|
69
|
+
expect(isInteractiveCliSession("vellum wake --watch --foreground")).toBe(
|
|
70
|
+
true,
|
|
71
|
+
);
|
|
72
|
+
expect(
|
|
73
|
+
isInteractiveCliSession("bun /Users/x/bin/vellum wake --foreground"),
|
|
74
|
+
).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("interactive names in later argv tokens do not match", () => {
|
|
78
|
+
expect(isInteractiveCliSession("vellum hatch --name logs")).toBe(false);
|
|
79
|
+
expect(isInteractiveCliSession("vellum sleep logs")).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("unknown flags before the subcommand do not match", () => {
|
|
83
|
+
expect(isInteractiveCliSession("vellum --verbose tunnel")).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("repo paths containing vellum do not match", () => {
|
|
87
|
+
expect(
|
|
88
|
+
isInteractiveCliSession(
|
|
89
|
+
"node /Users/runner/work/vellum-assistant/vellum-assistant/scripts/build.js",
|
|
90
|
+
),
|
|
91
|
+
).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("classifyProcess", () => {
|
|
96
|
+
test("labels interactive CLI sessions as vellum like any other wrapper", () => {
|
|
97
|
+
expect(classifyProcess("vellum tunnel --provider ngrok")).toBe("vellum");
|
|
98
|
+
expect(classifyProcess("vellum logs foo")).toBe("vellum");
|
|
99
|
+
expect(classifyProcess("vellum-cli tunnel --provider ngrok")).toBe(
|
|
100
|
+
"vellum",
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("classifies non-interactive CLI wrappers as vellum", () => {
|
|
105
|
+
expect(classifyProcess("vellum hatch")).toBe("vellum");
|
|
106
|
+
expect(classifyProcess("vellum")).toBe("vellum");
|
|
107
|
+
expect(classifyProcess("/usr/bin/vellum sleep")).toBe("vellum");
|
|
108
|
+
expect(classifyProcess("vellum hatch --name logs")).toBe("vellum");
|
|
109
|
+
expect(classifyProcess("vellum sleep logs")).toBe("vellum");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("service process classifications are unchanged", () => {
|
|
113
|
+
expect(classifyProcess("/opt/homebrew/bin/qdrant --config foo")).toBe(
|
|
114
|
+
"qdrant",
|
|
115
|
+
);
|
|
116
|
+
expect(classifyProcess("bun /x/bin/vellum-gateway --port 7830")).toBe(
|
|
117
|
+
"gateway",
|
|
118
|
+
);
|
|
119
|
+
expect(classifyProcess("bun /x/bin/vellum-daemon start")).toBe("assistant");
|
|
120
|
+
expect(classifyProcess("node daemon start")).toBe("assistant");
|
|
121
|
+
expect(
|
|
122
|
+
classifyProcess("bun /x/bin/vellum-openclaw-adapter --port 9000"),
|
|
123
|
+
).toBe("openclaw-adapter");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("macOS desktop app processes stay excluded", () => {
|
|
127
|
+
expect(
|
|
128
|
+
classifyProcess("/Applications/Vellum.app/Contents/MacOS/Vellum"),
|
|
129
|
+
).toBe("unknown");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("repo paths containing vellum do not match", () => {
|
|
133
|
+
expect(
|
|
134
|
+
classifyProcess(
|
|
135
|
+
"node /Users/runner/work/vellum-assistant/vellum-assistant/scripts/build.js",
|
|
136
|
+
),
|
|
137
|
+
).toBe("unknown");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("detectOrphanedProcesses", () => {
|
|
142
|
+
afterAll(() => {
|
|
143
|
+
mock.module("./step-runner", () => realStepRunner);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("skips live interactive sessions but still reports orphaned services", async () => {
|
|
147
|
+
const psOutput = [
|
|
148
|
+
"101 1 bun /Users/x/bin/vellum tunnel --provider ngrok",
|
|
149
|
+
"102 1 vellum exec -it --service vellum-gateway -- /bin/sh",
|
|
150
|
+
"103 1 vellum --plain logs foo",
|
|
151
|
+
"104 1 bun /x/bin/vellum-gateway --port 7830",
|
|
152
|
+
"105 1 vellum hatch",
|
|
153
|
+
].join("\n");
|
|
154
|
+
mock.module("./step-runner", () => ({
|
|
155
|
+
...realStepRunner,
|
|
156
|
+
execOutput: async () => psOutput,
|
|
157
|
+
}));
|
|
158
|
+
|
|
159
|
+
const orphans = await detectOrphanedProcesses({
|
|
160
|
+
excludePids: new Set<string>(),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(orphans.map((o) => o.pid).sort()).toEqual(["104", "105"]);
|
|
164
|
+
expect(orphans.find((o) => o.pid === "104")?.name).toBe("gateway");
|
|
165
|
+
expect(orphans.find((o) => o.pid === "105")?.name).toBe("vellum");
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -37,6 +37,41 @@ export function classifyProcess(command: string): string {
|
|
|
37
37
|
return "unknown";
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* True when the command line is a deliberate long-running interactive CLI
|
|
42
|
+
* session (e.g. a live `vellum tunnel` in someone's terminal). Such sessions
|
|
43
|
+
* have no PID-file registration, so `vellum clean` would otherwise kill them
|
|
44
|
+
* mid-session. The command line may be "bun /path/to/bin/vellum tunnel ...",
|
|
45
|
+
* so match the subcommand right after the vellum binary/script path (allowing
|
|
46
|
+
* only the known global flags in between; see GLOBAL_FLAGS in cli/src/index.ts).
|
|
47
|
+
* Later argv tokens (e.g. "vellum hatch --name logs") must not match.
|
|
48
|
+
*
|
|
49
|
+
* `detectOrphanedProcesses` consults this before classification so that
|
|
50
|
+
* service-like substrings in later argv (e.g. `vellum exec -it --service
|
|
51
|
+
* vellum-gateway -- /bin/sh`) cannot re-flag a live session, while
|
|
52
|
+
* `classifyProcess` stays a pure display label for `vellum ps`.
|
|
53
|
+
*
|
|
54
|
+
* Also spared: bare `vellum` (no subcommand launches the implicit TUI client
|
|
55
|
+
* via tryLaunchClient) and `vellum wake --foreground` (stays attached with
|
|
56
|
+
* logs in the terminal).
|
|
57
|
+
*/
|
|
58
|
+
export function isInteractiveCliSession(command: string): boolean {
|
|
59
|
+
const vellumToken = /(?:^|\/)vellum(?:-cli)?(?:\s+--(?:no-color|plain))*/;
|
|
60
|
+
const interactiveSubcommand = new RegExp(
|
|
61
|
+
vellumToken.source +
|
|
62
|
+
String.raw`\s+(?:tunnel|events|logs|client|terminal|ssh|exec|message|workflows)\b`,
|
|
63
|
+
);
|
|
64
|
+
const implicitTuiClient = new RegExp(vellumToken.source + String.raw`\s*$`);
|
|
65
|
+
const foregroundWake = new RegExp(
|
|
66
|
+
vellumToken.source + String.raw`\s+wake\b(?:\s+\S+)*\s--foreground\b`,
|
|
67
|
+
);
|
|
68
|
+
return (
|
|
69
|
+
interactiveSubcommand.test(command) ||
|
|
70
|
+
implicitTuiClient.test(command) ||
|
|
71
|
+
foregroundWake.test(command)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
40
75
|
export function parseRemotePs(output: string): RemoteProcess[] {
|
|
41
76
|
return output
|
|
42
77
|
.trim()
|
|
@@ -152,6 +187,11 @@ export async function detectOrphanedProcesses(
|
|
|
152
187
|
for (const p of procs) {
|
|
153
188
|
if (p.pid === ownPid || seenPids.has(p.pid)) continue;
|
|
154
189
|
if (knownPids.has(p.pid)) continue;
|
|
190
|
+
// Live interactive sessions are spared before classification so that
|
|
191
|
+
// service substrings in their argv cannot mark them as orphans.
|
|
192
|
+
if (isInteractiveCliSession(p.command)) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
155
195
|
const type = classifyProcess(p.command);
|
|
156
196
|
if (type === "unknown") continue;
|
|
157
197
|
results.push({ name: type, pid: p.pid, source: "process table" });
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { REMOTE_WEB_PAIRING_CODE_TTL_MS } from "@vellumai/service-contracts/remote-web-pairing";
|
|
4
|
+
|
|
5
|
+
import { formatWebApproveFailure, parseGatewayErrorCode } from "./pair.js";
|
|
6
|
+
|
|
7
|
+
const GATEWAY_URL = "http://127.0.0.1:20100";
|
|
8
|
+
// Callers pass formatAssistantReference() output: display name plus stable ID.
|
|
9
|
+
const ASSISTANT_REFERENCE = "example-assistant (asst_0123456789abcdef)";
|
|
10
|
+
const ENV_NAME = "local";
|
|
11
|
+
const TTL_NOTE = `expire after ${Math.round(REMOTE_WEB_PAIRING_CODE_TTL_MS / 60_000)} minutes`;
|
|
12
|
+
|
|
13
|
+
describe("formatWebApproveFailure", () => {
|
|
14
|
+
test("INVALID_USER_CODE names the gateway, assistant reference, and environment", () => {
|
|
15
|
+
const message = formatWebApproveFailure(
|
|
16
|
+
GATEWAY_URL,
|
|
17
|
+
ASSISTANT_REFERENCE,
|
|
18
|
+
ENV_NAME,
|
|
19
|
+
"INVALID_USER_CODE",
|
|
20
|
+
);
|
|
21
|
+
expect(message).not.toBeNull();
|
|
22
|
+
expect(message).toContain(`No such pairing code on ${GATEWAY_URL}`);
|
|
23
|
+
expect(message).toContain(`assistant "${ASSISTANT_REFERENCE}"`);
|
|
24
|
+
expect(message).toContain("asst_0123456789abcdef");
|
|
25
|
+
expect(message).toContain(`environment "${ENV_NAME}"`);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("INVALID_USER_CODE includes the TTL note and the cross-environment hint", () => {
|
|
29
|
+
const message = formatWebApproveFailure(
|
|
30
|
+
GATEWAY_URL,
|
|
31
|
+
ASSISTANT_REFERENCE,
|
|
32
|
+
ENV_NAME,
|
|
33
|
+
"INVALID_USER_CODE",
|
|
34
|
+
);
|
|
35
|
+
expect(message).toContain(TTL_NOTE);
|
|
36
|
+
expect(message).toContain("single-use");
|
|
37
|
+
expect(message).toContain("different assistant or environment");
|
|
38
|
+
expect(message).toContain("VELLUM_ENVIRONMENT");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("EXPIRED_USER_CODE gets the same diagnostic with an expiry lead line", () => {
|
|
42
|
+
const message = formatWebApproveFailure(
|
|
43
|
+
GATEWAY_URL,
|
|
44
|
+
ASSISTANT_REFERENCE,
|
|
45
|
+
ENV_NAME,
|
|
46
|
+
"EXPIRED_USER_CODE",
|
|
47
|
+
);
|
|
48
|
+
expect(message).toContain(`Pairing code expired on ${GATEWAY_URL}`);
|
|
49
|
+
expect(message).toContain(TTL_NOTE);
|
|
50
|
+
expect(message).toContain("VELLUM_ENVIRONMENT");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("unknown error codes return null so callers fall back to the generic HTTP error", () => {
|
|
54
|
+
for (const code of ["RATE_LIMITED", "BAD_REQUEST", "", null]) {
|
|
55
|
+
expect(
|
|
56
|
+
formatWebApproveFailure(
|
|
57
|
+
GATEWAY_URL,
|
|
58
|
+
ASSISTANT_REFERENCE,
|
|
59
|
+
ENV_NAME,
|
|
60
|
+
code,
|
|
61
|
+
),
|
|
62
|
+
).toBeNull();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("parseGatewayErrorCode", () => {
|
|
68
|
+
test("extracts the code from a gateway error envelope", () => {
|
|
69
|
+
const body = JSON.stringify({
|
|
70
|
+
error: { code: "INVALID_USER_CODE", message: "invalid pairing code" },
|
|
71
|
+
});
|
|
72
|
+
expect(parseGatewayErrorCode(body)).toBe("INVALID_USER_CODE");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("returns null for non-JSON bodies", () => {
|
|
76
|
+
expect(parseGatewayErrorCode("Not Found")).toBeNull();
|
|
77
|
+
expect(parseGatewayErrorCode("")).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("returns null when the envelope carries no string code", () => {
|
|
81
|
+
expect(parseGatewayErrorCode(JSON.stringify({ error: {} }))).toBeNull();
|
|
82
|
+
expect(
|
|
83
|
+
parseGatewayErrorCode(JSON.stringify({ error: { code: 404 } })),
|
|
84
|
+
).toBeNull();
|
|
85
|
+
expect(parseGatewayErrorCode(JSON.stringify({}))).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
});
|