@mycadet/cli 0.12.10 → 0.13.0-dev.202610171617.9ea363e
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 +13 -0
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/package.json +8 -3
- package/src/__tests__/assistant-client-cloud-auth.test.ts +2 -2
- package/src/__tests__/backup-ops-matching.test.ts +15 -13
- package/src/__tests__/backup-ops.test.ts +105 -0
- package/src/__tests__/backup.test.ts +114 -673
- package/src/__tests__/cloud-login-poll.test.ts +69 -0
- package/src/__tests__/cloud-releases.test.ts +82 -0
- package/src/__tests__/commands-mock.ts +101 -0
- package/src/__tests__/debug.test.ts +17 -91
- package/src/__tests__/devices.test.ts +64 -2
- package/src/__tests__/ensure-binary.test.ts +52 -0
- package/src/__tests__/gateway-token.test.ts +267 -0
- package/src/__tests__/hatch-next-steps.test.ts +38 -0
- package/src/__tests__/host-teleport-docker.test.ts +371 -0
- package/src/__tests__/host-teleport.test.ts +405 -0
- package/src/__tests__/multi-local.test.ts +3 -3
- package/src/__tests__/nginx-ingress.test.ts +587 -36
- package/src/__tests__/orphan-detection.test.ts +1 -1
- package/src/__tests__/paired-lifecycle.test.ts +2 -2
- package/src/__tests__/ps-cloud-status.test.ts +8 -7
- package/src/__tests__/root-backup-docker.test.ts +294 -0
- package/src/__tests__/root-backup.test.ts +527 -0
- package/src/__tests__/root-restore-command.test.ts +147 -0
- package/src/__tests__/root-signing-key.test.ts +50 -0
- package/src/__tests__/setup.test.ts +17 -7
- package/src/__tests__/shell-mode.test.ts +83 -0
- package/src/__tests__/sleep.test.ts +11 -0
- package/src/__tests__/teleport.test.ts +68 -2996
- package/src/__tests__/tunnel-startup-summary.test.ts +110 -0
- package/src/__tests__/tunnel-stop.test.ts +226 -0
- package/src/__tests__/tunnel.test.ts +22 -4
- package/src/__tests__/upgrade-local.test.ts +21 -11
- package/src/__tests__/upgrade-replay-env.test.ts +2 -2
- package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
- package/src/__tests__/wake.test.ts +4 -1
- package/src/__tests__/windows-lifecycle.test.ts +25 -0
- package/src/commands/backup.ts +44 -399
- package/src/commands/client.ts +3 -3
- package/src/commands/completions.ts +0 -2
- package/src/commands/debug.ts +75 -131
- package/src/commands/devices.ts +117 -47
- package/src/commands/gateway/token.ts +77 -23
- package/src/commands/hatch.ts +10 -52
- package/src/commands/pair.ts +9 -3
- package/src/commands/ps.ts +4 -2
- package/src/commands/restore.ts +77 -444
- package/src/commands/retire.ts +2 -3
- package/src/commands/sleep.ts +15 -2
- package/src/commands/teleport.ts +47 -1559
- package/src/commands/tunnel.ts +91 -11
- package/src/commands/upgrade.ts +84 -157
- package/src/commands/wake.ts +1 -1
- package/src/index.ts +0 -4
- package/src/lib/__tests__/http-client-version.test.ts +29 -0
- package/src/lib/__tests__/runtime-url.test.ts +3 -75
- package/src/lib/__tests__/web-dist.test.ts +27 -1
- package/src/lib/assistant-client.ts +1 -1
- package/src/lib/assistant-config.ts +43 -23
- package/src/lib/backup-ops.ts +37 -251
- package/src/lib/cloud-client.ts +10 -13
- package/src/lib/cloud-login-poll.ts +75 -0
- package/src/lib/cloud-login.ts +5 -2
- package/src/lib/cloud-releases.ts +54 -10
- package/src/lib/cloud-values.ts +1 -1
- package/src/lib/confirm-action.ts +2 -2
- package/src/lib/docker.ts +65 -34
- package/src/lib/ensure-binary.ts +65 -6
- package/src/lib/github-repository.ts +2 -0
- package/src/lib/guardian-token.ts +1 -23
- package/src/lib/hatch-local.ts +18 -52
- package/src/lib/hatch-next-steps.ts +15 -2
- package/src/lib/host-teleport.ts +506 -0
- package/src/lib/http-client.ts +25 -0
- package/src/lib/ingress-config.ts +31 -0
- package/src/lib/live-voice/tunnel.test.ts +1 -1
- package/src/lib/live-voice/tunnel.ts +3 -3
- package/src/lib/local.ts +10 -8
- package/src/lib/nginx-ingress.ts +572 -95
- package/src/lib/process.ts +13 -0
- package/src/lib/root-backup.ts +917 -0
- package/src/lib/root-signing-key.ts +31 -0
- package/src/lib/runtime-url.ts +0 -29
- package/src/lib/teleport-backup.ts +3 -16
- package/src/lib/tunnel.ts +87 -34
- package/src/lib/tunnels/cadet.ts +12 -1
- package/src/lib/tunnels/cloudflare.ts +36 -1
- package/src/lib/tunnels/ngrok.ts +16 -2
- package/src/lib/tunnels/process-lifecycle.ts +26 -0
- package/src/lib/tunnels/tailscale.ts +79 -1
- package/src/lib/upgrade-lifecycle.ts +63 -141
- package/src/lib/web-dist.ts +79 -1
- package/src/lib/xdg-log.ts +11 -0
- package/src/__tests__/bundle-staging.test.ts +0 -268
- package/src/__tests__/unpair.test.ts +0 -160
- package/src/commands/rollback.ts +0 -479
- package/src/commands/unpair.ts +0 -122
- package/src/lib/__tests__/job-polling.test.ts +0 -278
- package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
- package/src/lib/bundle-staging.ts +0 -220
- package/src/lib/job-polling.ts +0 -195
- package/src/lib/local-runtime-client.ts +0 -374
package/src/lib/nginx-ingress.ts
CHANGED
|
@@ -13,9 +13,12 @@ import {
|
|
|
13
13
|
rmSync,
|
|
14
14
|
writeFileSync,
|
|
15
15
|
} from "node:fs";
|
|
16
|
+
import { createServer } from "node:net";
|
|
16
17
|
import { networkInterfaces } from "node:os";
|
|
17
18
|
import { join } from "node:path";
|
|
18
19
|
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
|
|
19
22
|
import { cloudAssistantHubUrl } from "@mycadet/environments";
|
|
20
23
|
|
|
21
24
|
import {
|
|
@@ -27,17 +30,24 @@ import {
|
|
|
27
30
|
} from "./assistant-config.js";
|
|
28
31
|
import type { RemoteHost } from "./constants.js";
|
|
29
32
|
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
30
|
-
import { waitForDaemonReady } from "./http-client.js";
|
|
33
|
+
import { fetchCurrentVersion, waitForDaemonReady } from "./http-client.js";
|
|
31
34
|
import {
|
|
32
35
|
getDefaultWorkspaceDir,
|
|
33
36
|
isLocalContainerEntry,
|
|
34
37
|
loadRawConfig,
|
|
35
38
|
parseGatewayPortFromEntryUrls,
|
|
39
|
+
readShellMode,
|
|
36
40
|
saveRawConfig,
|
|
37
41
|
} from "./ingress-config.js";
|
|
38
|
-
import {
|
|
42
|
+
import { stripVersionPrefix } from "./version-compat.js";
|
|
43
|
+
import {
|
|
44
|
+
findAppDistDir,
|
|
45
|
+
findWebDistDir,
|
|
46
|
+
readWebReleaseVersion,
|
|
47
|
+
} from "./web-dist.js";
|
|
39
48
|
|
|
40
49
|
export { findWebDistDir } from "./web-dist.js";
|
|
50
|
+
export { findAppDistDir } from "./web-dist.js";
|
|
41
51
|
|
|
42
52
|
/**
|
|
43
53
|
* CLI-managed nginx reverse proxy that fronts the gateway as the canonical
|
|
@@ -92,8 +102,8 @@ function nginxDirPath(dir: string): string {
|
|
|
92
102
|
return dir.endsWith("/") ? dir : `${dir}/`;
|
|
93
103
|
}
|
|
94
104
|
|
|
95
|
-
function gatewayProxyBlock(gatewayPort: number): string {
|
|
96
|
-
return ` proxy_pass http://127.0.0.1:${gatewayPort};
|
|
105
|
+
function gatewayProxyBlock(gatewayPort: number, upstreamPath = ""): string {
|
|
106
|
+
return ` proxy_pass http://127.0.0.1:${gatewayPort}${upstreamPath};
|
|
97
107
|
proxy_http_version 1.1;
|
|
98
108
|
proxy_request_buffering off;
|
|
99
109
|
proxy_buffering off;
|
|
@@ -106,40 +116,71 @@ function gatewayProxyBlock(gatewayPort: number): string {
|
|
|
106
116
|
}
|
|
107
117
|
|
|
108
118
|
/**
|
|
109
|
-
* Sensitive local-only
|
|
110
|
-
* regardless of
|
|
119
|
+
* Sensitive local-only gateway paths the edge must never expose to the
|
|
120
|
+
* internet, regardless of which client bundles are being served. Each exact
|
|
121
|
+
* path is blocked with and without its trailing slash.
|
|
111
122
|
*/
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
123
|
+
const SENSITIVE_GATEWAY_ROUTES = [
|
|
124
|
+
"/auth/token",
|
|
125
|
+
"/v1/pair",
|
|
126
|
+
"/v1/pair/web-init",
|
|
127
|
+
"/v1/devices",
|
|
128
|
+
"/v1/devices/revoke",
|
|
129
|
+
"/v1/guardian/init",
|
|
130
|
+
"/v1/guardian/reset-bootstrap",
|
|
131
|
+
"/v1/remote-web/pairing-requests",
|
|
132
|
+
"/v1/remote-web/pairing-requests/approve",
|
|
133
|
+
"/v1/remote-web/pairing-requests/deny",
|
|
134
|
+
"/v1/remote-web/pairing-verification",
|
|
135
|
+
"/v1/shared/invites/redeem",
|
|
136
|
+
] as const;
|
|
137
|
+
|
|
138
|
+
/** Sensitive path prefixes, blocked with a prefix match. */
|
|
139
|
+
const SENSITIVE_GATEWAY_PREFIXES = [
|
|
140
|
+
"/assistant/__local/",
|
|
141
|
+
"/assistant/__gateway/",
|
|
142
|
+
"/assistant/__gateway-paired/",
|
|
143
|
+
] as const;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Denylist locations for gateway paths under one public prefix. The edge
|
|
147
|
+
* serves from the origin root (web mode) and — in app mode — from the
|
|
148
|
+
* `/web` iframe prefix, where the web UI's prefix-aware API paths
|
|
149
|
+
* (`remoteGatewayApiPath`) put the same gateway routes at `/web/v1/...`;
|
|
150
|
+
* the sensitive ones must be blocked in both spellings.
|
|
151
|
+
*/
|
|
152
|
+
function denylistLocations(prefix = ""): string {
|
|
153
|
+
const exact = SENSITIVE_GATEWAY_ROUTES.map((route) => {
|
|
154
|
+
const block = (path: string) =>
|
|
155
|
+
` location = ${prefix}${path} { return 404; }`;
|
|
156
|
+
return `${block(route)}\n${block(`${route}/`)}`;
|
|
157
|
+
});
|
|
158
|
+
const prefixes = SENSITIVE_GATEWAY_PREFIXES.map((route) => {
|
|
159
|
+
return ` location ^~ ${prefix}${route} { return 404; }`;
|
|
160
|
+
});
|
|
161
|
+
return [...exact, ...prefixes].join("\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const DENYLIST_LOCATIONS = denylistLocations();
|
|
139
165
|
|
|
140
166
|
export interface RemoteWebIngressOptions {
|
|
141
167
|
webDistDir: string;
|
|
168
|
+
/**
|
|
169
|
+
* Standalone app-shell bundle. Present only in app shell mode: the edge
|
|
170
|
+
* serves this bundle as the outer account/client shell from the origin
|
|
171
|
+
* root and iframes the web SPA from /web/assistant/. Absent (web mode)
|
|
172
|
+
* keeps serving the web SPA directly from /assistant/.
|
|
173
|
+
*/
|
|
174
|
+
appDistDir?: string;
|
|
175
|
+
/**
|
|
176
|
+
* The config-injected app-shell document. Defaults to the app bundle's own
|
|
177
|
+
* index; the edge start path points this at the ingress dir, where the
|
|
178
|
+
* injected copy is written.
|
|
179
|
+
*/
|
|
180
|
+
appIndexHtmlPath?: string;
|
|
142
181
|
indexHtmlPath?: string;
|
|
182
|
+
/** Assistant release verified against the served UI artifact. */
|
|
183
|
+
version?: string;
|
|
143
184
|
config?: Record<string, unknown>;
|
|
144
185
|
/** Serving assistant's display name, stamped into the served config so
|
|
145
186
|
* remote clients can label this origin. Absent in older served configs. */
|
|
@@ -149,24 +190,41 @@ export interface RemoteWebIngressOptions {
|
|
|
149
190
|
* configs. */
|
|
150
191
|
assistantId?: string;
|
|
151
192
|
/** Cloud web SPA base the remote client can hand this origin to (see
|
|
152
|
-
* `
|
|
193
|
+
* `cloudAssistantHubUrl`). Absent in older served configs. */
|
|
153
194
|
hubUrl?: string;
|
|
154
195
|
}
|
|
155
196
|
|
|
156
197
|
/**
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
198
|
+
* The web UI entry the assistant detail API hands the shell, relative to the
|
|
199
|
+
* origin root. Web mode serves the web SPA directly at /assistant/; app mode
|
|
200
|
+
* serves it from the distinct /web/assistant/ iframe namespace so the shell's
|
|
201
|
+
* own /assistant/* host routes can never load the shell recursively.
|
|
160
202
|
*/
|
|
161
|
-
export function
|
|
162
|
-
return
|
|
203
|
+
export function assistantEntryUrl(appDistDir: string | undefined): string {
|
|
204
|
+
return appDistDir ? "web/assistant/" : "assistant/";
|
|
163
205
|
}
|
|
164
206
|
|
|
165
207
|
// The edge serves remote browsers, so the bundle's remote-gateway machinery
|
|
166
208
|
// must always run: it is reached through this self-hosted edge however the
|
|
167
209
|
// cadet itself is hosted. "cloud" is never stamped (the cloud serves its
|
|
168
210
|
// own bundles).
|
|
169
|
-
|
|
211
|
+
/** The lockfile's recorded remote kind for the assistant, if one is found. */
|
|
212
|
+
function lockfileAssistantRemote(assistantId: string): string | undefined {
|
|
213
|
+
const result = lookupAssistantByIdentifier(assistantId);
|
|
214
|
+
return result.status === "found" ? result.entry.cloud : undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The remote kind stamped into the served SPA config. Docker and custom
|
|
219
|
+
* cadets are stamped truthfully; every other hosting shape — a local cadet
|
|
220
|
+
* reached through this edge, paired remotes, unknown entries — reads as
|
|
221
|
+
* "custom" so the served bundle always runs the remote-gateway machinery
|
|
222
|
+
* (this edge serves remote browsers however the cadet itself is hosted).
|
|
223
|
+
* "cloud" is never stamped: the cloud serves its own bundles.
|
|
224
|
+
*/
|
|
225
|
+
export function edgeServedRemote(recorded: string | undefined): RemoteHost {
|
|
226
|
+
return recorded === "docker" ? "docker" : "custom";
|
|
227
|
+
}
|
|
170
228
|
|
|
171
229
|
function remoteWebIngressConfig(
|
|
172
230
|
opts: Pick<
|
|
@@ -175,7 +233,9 @@ function remoteWebIngressConfig(
|
|
|
175
233
|
>,
|
|
176
234
|
): Record<string, unknown> {
|
|
177
235
|
return {
|
|
178
|
-
remote:
|
|
236
|
+
remote: edgeServedRemote(
|
|
237
|
+
opts.assistantId ? lockfileAssistantRemote(opts.assistantId) : undefined,
|
|
238
|
+
),
|
|
179
239
|
apiBaseUrl: "/v1",
|
|
180
240
|
...(opts.assistantName ? { assistantName: opts.assistantName } : {}),
|
|
181
241
|
...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
|
|
@@ -189,17 +249,43 @@ function remoteWebIngressConfig(
|
|
|
189
249
|
* fingerprint matches, so this must change whenever the generated index or
|
|
190
250
|
* nginx template does.
|
|
191
251
|
*/
|
|
192
|
-
export const EDGE_TEMPLATE_VERSION =
|
|
252
|
+
export const EDGE_TEMPLATE_VERSION = 13;
|
|
193
253
|
|
|
194
254
|
/**
|
|
195
|
-
* Stable fingerprint of the SPA config
|
|
196
|
-
*
|
|
255
|
+
* Stable fingerprint of the injected SPA config, its source bundles, the
|
|
256
|
+
* shell mode, and the template that renders them. Recorded
|
|
197
257
|
* alongside the edge state so a reuse decision can tell whether a running edge
|
|
198
258
|
* already serves the requested config (see `IngressState.remoteWebConfigHash`).
|
|
199
259
|
*/
|
|
200
|
-
function remoteWebConfigFingerprint(
|
|
260
|
+
function remoteWebConfigFingerprint(
|
|
261
|
+
config: Record<string, unknown>,
|
|
262
|
+
webDistDir: string,
|
|
263
|
+
version?: string,
|
|
264
|
+
appDistDir?: string,
|
|
265
|
+
): string {
|
|
201
266
|
return createHash("sha256")
|
|
202
|
-
.update(
|
|
267
|
+
.update(
|
|
268
|
+
JSON.stringify({
|
|
269
|
+
template: EDGE_TEMPLATE_VERSION,
|
|
270
|
+
config,
|
|
271
|
+
webDistDir,
|
|
272
|
+
version,
|
|
273
|
+
// The shell mode is part of edge identity: flipping the persisted
|
|
274
|
+
// shell mode must rotate a running edge onto the requested shell
|
|
275
|
+
// even though every other fingerprint input may be unchanged.
|
|
276
|
+
shellMode: appDistDir ? "app" : "web",
|
|
277
|
+
...(appDistDir
|
|
278
|
+
? {
|
|
279
|
+
appDistDir,
|
|
280
|
+
appIndexHtml: readFileSync(
|
|
281
|
+
join(appDistDir, "index.html"),
|
|
282
|
+
"utf-8",
|
|
283
|
+
),
|
|
284
|
+
}
|
|
285
|
+
: {}),
|
|
286
|
+
indexHtml: readFileSync(join(webDistDir, "index.html"), "utf-8"),
|
|
287
|
+
}),
|
|
288
|
+
)
|
|
203
289
|
.digest("hex");
|
|
204
290
|
}
|
|
205
291
|
|
|
@@ -278,9 +364,140 @@ export function hasIpv6Loopback(): boolean {
|
|
|
278
364
|
.some((addr) => addr.address === "::1");
|
|
279
365
|
}
|
|
280
366
|
|
|
367
|
+
/** Whether a TCP listener already holds `port` on the given loopback host. */
|
|
368
|
+
async function isPortInUse(
|
|
369
|
+
port: number,
|
|
370
|
+
host: "127.0.0.1" | "::1",
|
|
371
|
+
): Promise<boolean> {
|
|
372
|
+
return await new Promise((resolve) => {
|
|
373
|
+
const probe = createServer();
|
|
374
|
+
probe.once("error", () => resolve(true));
|
|
375
|
+
probe.once("listening", () => {
|
|
376
|
+
probe.close(() => resolve(false));
|
|
377
|
+
});
|
|
378
|
+
probe.listen(port, host);
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
281
382
|
/**
|
|
282
|
-
*
|
|
383
|
+
* The first free loopback port at or above `startPort`, scanning a short band,
|
|
384
|
+
* or null when the whole band is busy. Both stacks the edge may bind are
|
|
385
|
+
* probed (the IPv4 loopback always, the IPv6 loopback when the host has one),
|
|
386
|
+
* so the returned port is one nginx can actually take.
|
|
283
387
|
*/
|
|
388
|
+
export async function findAvailableIngressPort(
|
|
389
|
+
startPort: number,
|
|
390
|
+
attempts = 20,
|
|
391
|
+
): Promise<number | null> {
|
|
392
|
+
const checkIpv6 = hasIpv6Loopback();
|
|
393
|
+
for (let port = startPort; port < startPort + attempts; port++) {
|
|
394
|
+
if (await isPortInUse(port, "127.0.0.1")) {
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (checkIpv6 && (await isPortInUse(port, "::1"))) {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
return port;
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const AssistantHostConfigSchema = z.object({
|
|
406
|
+
assistantId: z.string().min(1).optional(),
|
|
407
|
+
assistantName: z.string().optional(),
|
|
408
|
+
version: z.string().min(1).optional(),
|
|
409
|
+
hubUrl: z.string().url().optional(),
|
|
410
|
+
appAllowedOrigins: z.array(z.string().url()).default([]),
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
function assistantHostConfig(config: Record<string, unknown>) {
|
|
414
|
+
const result = AssistantHostConfigSchema.safeParse(config);
|
|
415
|
+
if (!result.success) {
|
|
416
|
+
throw new Error("Invalid assistant host configuration.");
|
|
417
|
+
}
|
|
418
|
+
return result.data;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function appOrigins(config: Record<string, unknown>): string[] {
|
|
422
|
+
const metadata = assistantHostConfig(config);
|
|
423
|
+
const origins = [
|
|
424
|
+
...metadata.appAllowedOrigins,
|
|
425
|
+
...(metadata.hubUrl ? [new URL(metadata.hubUrl).origin] : []),
|
|
426
|
+
];
|
|
427
|
+
return [...new Set(origins)].map((origin) => {
|
|
428
|
+
const url = new URL(origin);
|
|
429
|
+
if (
|
|
430
|
+
(url.protocol !== "https:" &&
|
|
431
|
+
!(
|
|
432
|
+
url.protocol === "http:" &&
|
|
433
|
+
["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
|
|
434
|
+
)) ||
|
|
435
|
+
url.origin !== origin.replace(/\/$/, "")
|
|
436
|
+
) {
|
|
437
|
+
throw new Error(
|
|
438
|
+
"App origins must be HTTPS origins or loopback HTTP origins.",
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
return url.origin;
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function assistantMetadataLocations(config: Record<string, unknown>): string {
|
|
446
|
+
const metadata = assistantHostConfig(config);
|
|
447
|
+
const id = metadata.assistantId;
|
|
448
|
+
const entryUrl =
|
|
449
|
+
typeof config.entryUrl === "string" ? config.entryUrl : undefined;
|
|
450
|
+
const choice = id
|
|
451
|
+
? {
|
|
452
|
+
id,
|
|
453
|
+
...(metadata.assistantName ? { name: metadata.assistantName } : {}),
|
|
454
|
+
}
|
|
455
|
+
: undefined;
|
|
456
|
+
const responses = [
|
|
457
|
+
{
|
|
458
|
+
path: "/v1/assistants",
|
|
459
|
+
status: 200,
|
|
460
|
+
body: { results: choice ? [choice] : [], next: null },
|
|
461
|
+
},
|
|
462
|
+
...(choice && id
|
|
463
|
+
? [
|
|
464
|
+
{
|
|
465
|
+
path: `/v1/assistants/${encodeURIComponent(id)}`,
|
|
466
|
+
status: metadata.version ? 200 : 409,
|
|
467
|
+
body: metadata.version
|
|
468
|
+
? {
|
|
469
|
+
...choice,
|
|
470
|
+
status: "active",
|
|
471
|
+
version: metadata.version,
|
|
472
|
+
url: entryUrl ?? "assistant/",
|
|
473
|
+
}
|
|
474
|
+
: { detail: "assistant is not ready" },
|
|
475
|
+
},
|
|
476
|
+
]
|
|
477
|
+
: []),
|
|
478
|
+
];
|
|
479
|
+
return responses
|
|
480
|
+
.flatMap(({ path, status, body }) =>
|
|
481
|
+
[path, `${path}/`].map(
|
|
482
|
+
(route) => `
|
|
483
|
+
location = ${nginxQuoted(route, "assistant API path")} {
|
|
484
|
+
default_type application/json;
|
|
485
|
+
add_header Cache-Control "no-store" always;
|
|
486
|
+
add_header Access-Control-Allow-Origin $cadet_app_origin always;
|
|
487
|
+
add_header Access-Control-Allow-Credentials "true" always;
|
|
488
|
+
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
|
|
489
|
+
add_header Access-Control-Allow-Headers "Accept, Cadet-Organization-Id" always;
|
|
490
|
+
add_header Vary "Origin" always;
|
|
491
|
+
if ($request_method = OPTIONS) { return 204; }
|
|
492
|
+
if ($request_method !~ ^(GET|HEAD)$) { return 405; }
|
|
493
|
+
return ${status} ${nginxQuoted(JSON.stringify(body), "assistant metadata")};
|
|
494
|
+
}`,
|
|
495
|
+
),
|
|
496
|
+
)
|
|
497
|
+
.join("\n");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Build the nginx config for the tunnel edge and its public assistant metadata. */
|
|
284
501
|
export function buildIngressNginxConfig(opts: {
|
|
285
502
|
gatewayPort: number;
|
|
286
503
|
listenPort: number;
|
|
@@ -296,12 +513,18 @@ export function buildIngressNginxConfig(opts: {
|
|
|
296
513
|
? ` listen [::1]:${opts.listenPort};\n`
|
|
297
514
|
: "";
|
|
298
515
|
const remoteWebIngress = opts.remoteWebIngress;
|
|
516
|
+
const allowedOrigins = remoteWebIngress
|
|
517
|
+
? appOrigins(remoteWebIngressConfig(remoteWebIngress))
|
|
518
|
+
: [];
|
|
299
519
|
const serverLocations = remoteWebIngress
|
|
300
520
|
? buildRemoteWebIngressLocations({
|
|
301
521
|
gatewayPort: opts.gatewayPort,
|
|
302
522
|
webDistDir: remoteWebIngress.webDistDir,
|
|
523
|
+
appDistDir: remoteWebIngress.appDistDir,
|
|
524
|
+
appIndexHtmlPath: remoteWebIngress.appIndexHtmlPath,
|
|
303
525
|
indexHtmlPath: remoteWebIngress.indexHtmlPath,
|
|
304
526
|
config: remoteWebIngressConfig(remoteWebIngress),
|
|
527
|
+
version: remoteWebIngress.version,
|
|
305
528
|
})
|
|
306
529
|
: `${DENYLIST_LOCATIONS}
|
|
307
530
|
|
|
@@ -350,6 +573,11 @@ http {
|
|
|
350
573
|
text/plain txt;
|
|
351
574
|
}
|
|
352
575
|
|
|
576
|
+
map $http_origin $cadet_app_origin {
|
|
577
|
+
default "";
|
|
578
|
+
${allowedOrigins.map((origin) => ` ${nginxQuoted(origin, "app origin")} ${nginxQuoted(origin, "app origin")};`).join("\n")}
|
|
579
|
+
}
|
|
580
|
+
|
|
353
581
|
map $http_upgrade $connection_upgrade {
|
|
354
582
|
default upgrade;
|
|
355
583
|
"" close;
|
|
@@ -385,22 +613,13 @@ ${serverLocations}
|
|
|
385
613
|
`;
|
|
386
614
|
}
|
|
387
615
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
const webDistDir = nginxDirPath(opts.webDistDir);
|
|
396
|
-
const webAssetsDir = join(opts.webDistDir, "assets");
|
|
397
|
-
const indexHtmlPath =
|
|
398
|
-
opts.indexHtmlPath ?? join(opts.webDistDir, "index.html");
|
|
399
|
-
const configJson = JSON.stringify(opts.config);
|
|
400
|
-
|
|
401
|
-
return `${DENYLIST_LOCATIONS}
|
|
402
|
-
|
|
403
|
-
location = /healthz {
|
|
616
|
+
/**
|
|
617
|
+
* Gateway-facing locations shared by both shell modes: liveness probes and
|
|
618
|
+
* the runtime API surface, all proxied verbatim.
|
|
619
|
+
*/
|
|
620
|
+
function gatewayProxyLocations(gatewayPort: number): string {
|
|
621
|
+
const proxyBlock = gatewayProxyBlock(gatewayPort);
|
|
622
|
+
return ` location = /healthz {
|
|
404
623
|
${proxyBlock}
|
|
405
624
|
}
|
|
406
625
|
|
|
@@ -414,18 +633,34 @@ ${proxyBlock}
|
|
|
414
633
|
|
|
415
634
|
location ^~ /webhooks/ {
|
|
416
635
|
${proxyBlock}
|
|
417
|
-
}
|
|
636
|
+
}`;
|
|
637
|
+
}
|
|
418
638
|
|
|
419
|
-
|
|
420
|
-
|
|
639
|
+
/**
|
|
640
|
+
* Serve the web SPA from one path prefix: the exact-mount rewrites, the
|
|
641
|
+
* injected index, the inline config, and the SPA fallback. Used for the
|
|
642
|
+
* /assistant/ mount in web mode and the /web/assistant/ iframe mount in
|
|
643
|
+
* app mode. `prefix` carries both a leading and trailing slash.
|
|
644
|
+
*/
|
|
645
|
+
function webSpaLocations(opts: {
|
|
646
|
+
prefix: string;
|
|
647
|
+
webDistDir: string;
|
|
648
|
+
indexHtmlPath: string;
|
|
649
|
+
config: Record<string, unknown>;
|
|
650
|
+
}): string {
|
|
651
|
+
const indexHtmlPath = opts.indexHtmlPath;
|
|
652
|
+
const configJson = JSON.stringify(opts.config);
|
|
653
|
+
const prefix = opts.prefix.replace(/\/+$/, "");
|
|
654
|
+
return ` location = ${prefix} {
|
|
655
|
+
return 302 ${prefix}/;
|
|
421
656
|
}
|
|
422
657
|
|
|
423
|
-
location = /
|
|
424
|
-
rewrite ^ /
|
|
658
|
+
location = ${prefix}/ {
|
|
659
|
+
rewrite ^ ${prefix}/__remote-index.html last;
|
|
425
660
|
}
|
|
426
661
|
|
|
427
|
-
location = /
|
|
428
|
-
rewrite ^ /
|
|
662
|
+
location = ${prefix}/index.html {
|
|
663
|
+
rewrite ^ ${prefix}/__remote-index.html last;
|
|
429
664
|
}
|
|
430
665
|
|
|
431
666
|
# The shell and the unhashed files beside it revalidate rather than
|
|
@@ -433,39 +668,173 @@ ${proxyBlock}
|
|
|
433
668
|
# disk with a validator, so a repeat load costs a 304 instead of the whole
|
|
434
669
|
# document. __config is the exception below: it is returned inline, so
|
|
435
670
|
# nginx attaches no validator for a revalidation to match against.
|
|
436
|
-
location = /
|
|
671
|
+
location = ${prefix}/__remote-index.html {
|
|
437
672
|
internal;
|
|
438
673
|
gzip on;
|
|
439
674
|
alias ${nginxQuoted(indexHtmlPath, "remote web ingress index path")};
|
|
440
675
|
add_header Cache-Control "no-cache";
|
|
441
676
|
}
|
|
442
677
|
|
|
443
|
-
location = /
|
|
678
|
+
location = ${prefix}/__config {
|
|
444
679
|
default_type application/json;
|
|
445
680
|
add_header Cache-Control "no-store";
|
|
446
681
|
return 200 ${nginxQuoted(configJson, "remote web ingress config")};
|
|
447
682
|
}
|
|
448
683
|
|
|
449
|
-
location ^~ /
|
|
684
|
+
location ^~ ${prefix}/ {
|
|
685
|
+
gzip on;
|
|
686
|
+
alias ${nginxQuoted(nginxDirPath(opts.webDistDir), "web dist path")};
|
|
687
|
+
try_files $uri $uri/ ${prefix}/__remote-index.html;
|
|
688
|
+
add_header Cache-Control "no-cache";
|
|
689
|
+
}`;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** The web bundle's hashed chunks, cached immutably under its dist root. */
|
|
693
|
+
function webAssetsLocations(webDistDir: string): string {
|
|
694
|
+
return ` location ^~ /assistant/assets/ {
|
|
450
695
|
gzip on;
|
|
451
|
-
alias ${nginxQuoted(
|
|
696
|
+
alias ${nginxQuoted(
|
|
697
|
+
nginxDirPath(join(webDistDir, "assets")),
|
|
698
|
+
"web assets path",
|
|
699
|
+
)};
|
|
452
700
|
try_files $uri =404;
|
|
453
701
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
|
702
|
+
}`;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function buildRemoteWebIngressLocations(opts: {
|
|
706
|
+
gatewayPort: number;
|
|
707
|
+
webDistDir: string;
|
|
708
|
+
appDistDir?: string;
|
|
709
|
+
appIndexHtmlPath?: string;
|
|
710
|
+
indexHtmlPath?: string;
|
|
711
|
+
config: Record<string, unknown>;
|
|
712
|
+
version?: string;
|
|
713
|
+
}): string {
|
|
714
|
+
const webDistDir = nginxDirPath(opts.webDistDir);
|
|
715
|
+
const indexHtmlPath =
|
|
716
|
+
opts.indexHtmlPath ?? join(opts.webDistDir, "index.html");
|
|
717
|
+
|
|
718
|
+
if (!opts.appDistDir) {
|
|
719
|
+
// Web mode: the web SPA IS the served surface, at /assistant/.
|
|
720
|
+
return `${DENYLIST_LOCATIONS}
|
|
721
|
+
|
|
722
|
+
${assistantMetadataLocations({ ...opts.config, version: opts.version })}
|
|
723
|
+
|
|
724
|
+
${gatewayProxyLocations(opts.gatewayPort)}
|
|
725
|
+
|
|
726
|
+
${webAssetsLocations(opts.webDistDir)}
|
|
727
|
+
|
|
728
|
+
${webSpaLocations({
|
|
729
|
+
prefix: "/assistant",
|
|
730
|
+
webDistDir: opts.webDistDir,
|
|
731
|
+
indexHtmlPath,
|
|
732
|
+
config: opts.config,
|
|
733
|
+
})}
|
|
734
|
+
|
|
735
|
+
location = / {
|
|
736
|
+
return 302 /assistant/;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
location / {
|
|
740
|
+
return 404;
|
|
741
|
+
}`;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// App mode: the app bundle is the outer shell (origin root, including the
|
|
745
|
+
// app's own /assistant/* host routes) and the web SPA is iframed from the
|
|
746
|
+
// distinct /web/assistant/ namespace, so neither surface can load the
|
|
747
|
+
// other recursively. The web bundle's asset URLs keep their baked
|
|
748
|
+
// /assistant/ base, so its hashed chunks and public files stay served
|
|
749
|
+
// under that prefix even though the document now mounts from /web/.
|
|
750
|
+
const appIndexHtmlPath =
|
|
751
|
+
opts.appIndexHtmlPath ?? join(opts.appDistDir, "index.html");
|
|
752
|
+
return `${denylistLocations()}
|
|
753
|
+
|
|
754
|
+
${denylistLocations("/web")}
|
|
755
|
+
|
|
756
|
+
${assistantMetadataLocations({
|
|
757
|
+
...opts.config,
|
|
758
|
+
version: opts.version,
|
|
759
|
+
entryUrl: assistantEntryUrl(opts.appDistDir),
|
|
760
|
+
})}
|
|
761
|
+
|
|
762
|
+
${gatewayProxyLocations(opts.gatewayPort)}
|
|
763
|
+
|
|
764
|
+
location ^~ /web/v1/ {
|
|
765
|
+
${gatewayProxyBlock(opts.gatewayPort, "/v1/")}
|
|
454
766
|
}
|
|
455
767
|
|
|
768
|
+
${webSpaLocations({
|
|
769
|
+
prefix: "/web/assistant",
|
|
770
|
+
webDistDir: opts.webDistDir,
|
|
771
|
+
indexHtmlPath,
|
|
772
|
+
config: opts.config,
|
|
773
|
+
})}
|
|
774
|
+
|
|
775
|
+
location = /web {
|
|
776
|
+
return 302 /web/assistant/;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
# The iframe namespace has no other routes: anything under /web that is
|
|
780
|
+
# not the assistant mount or its prefixed API path is a probe, not a page.
|
|
781
|
+
location ^~ /web/ {
|
|
782
|
+
return 404;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
# The web bundle's hashed chunks and unhashed public files under its baked
|
|
786
|
+
# build base. Real files win; anything else falls through to the app
|
|
787
|
+
# shell, which owns the /assistant/* host routes.
|
|
788
|
+
location = /assistant {
|
|
789
|
+
rewrite ^ /__cadet-app-index.html last;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
location = /assistant/ {
|
|
793
|
+
rewrite ^ /__cadet-app-index.html last;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
${webAssetsLocations(opts.webDistDir)}
|
|
797
|
+
|
|
456
798
|
location ^~ /assistant/ {
|
|
457
799
|
gzip on;
|
|
458
800
|
alias ${nginxQuoted(webDistDir, "web dist path")};
|
|
459
|
-
try_files $uri
|
|
801
|
+
try_files $uri @cadet_app_shell;
|
|
460
802
|
add_header Cache-Control "no-cache";
|
|
461
803
|
}
|
|
462
804
|
|
|
805
|
+
# The app shell: its document is the config-injected standalone index, and
|
|
806
|
+
# every shell route (/, /account/*, /logout, and the /assistant/* host
|
|
807
|
+
# routes) resolves through the SPA fallback.
|
|
808
|
+
location = /__cadet-app-index.html {
|
|
809
|
+
internal;
|
|
810
|
+
gzip on;
|
|
811
|
+
alias ${nginxQuoted(appIndexHtmlPath, "app shell ingress index path")};
|
|
812
|
+
add_header Cache-Control "no-cache";
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
location @cadet_app_shell {
|
|
816
|
+
rewrite ^ /__cadet-app-index.html last;
|
|
817
|
+
}
|
|
818
|
+
|
|
463
819
|
location = / {
|
|
464
|
-
|
|
820
|
+
rewrite ^ /__cadet-app-index.html last;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
location ^~ /assets/ {
|
|
824
|
+
gzip on;
|
|
825
|
+
alias ${nginxQuoted(
|
|
826
|
+
nginxDirPath(join(opts.appDistDir, "assets")),
|
|
827
|
+
"app assets path",
|
|
828
|
+
)};
|
|
829
|
+
try_files $uri =404;
|
|
830
|
+
add_header Cache-Control "public, max-age=31536000, immutable";
|
|
465
831
|
}
|
|
466
832
|
|
|
467
833
|
location / {
|
|
468
|
-
|
|
834
|
+
gzip on;
|
|
835
|
+
alias ${nginxQuoted(nginxDirPath(opts.appDistDir), "app dist path")};
|
|
836
|
+
try_files $uri @cadet_app_shell;
|
|
837
|
+
add_header Cache-Control "no-cache";
|
|
469
838
|
}`;
|
|
470
839
|
}
|
|
471
840
|
|
|
@@ -574,7 +943,7 @@ export interface IngressState {
|
|
|
574
943
|
*/
|
|
575
944
|
gatewayPort?: number;
|
|
576
945
|
/**
|
|
577
|
-
* Fingerprint of the SPA config
|
|
946
|
+
* Fingerprint of the injected SPA config and its source bundle. Undefined
|
|
578
947
|
* for webhooks-only edges and for SPA records that predate the field; an
|
|
579
948
|
* SPA edge without a recorded fingerprint is treated as drifted and
|
|
580
949
|
* restarted so the served index provably carries the requested config.
|
|
@@ -661,6 +1030,19 @@ export function startIngressNginx(opts: {
|
|
|
661
1030
|
remoteWebIngress.indexHtmlPath,
|
|
662
1031
|
buildRemoteWebIndexHtml(rawIndexHtml, remoteWebIngress.config),
|
|
663
1032
|
);
|
|
1033
|
+
if (remoteWebIngress.appDistDir) {
|
|
1034
|
+
// The app shell gets its own injected document: same remote-web config
|
|
1035
|
+
// (its readers ignore the web-only apiBaseUrl key), written beside the
|
|
1036
|
+
// web one so the generated locations can alias it internally.
|
|
1037
|
+
const rawAppIndexHtml = readFileSync(
|
|
1038
|
+
join(remoteWebIngress.appDistDir, "index.html"),
|
|
1039
|
+
"utf-8",
|
|
1040
|
+
);
|
|
1041
|
+
writeFileSync(
|
|
1042
|
+
join(paths.dir, "app-index.html"),
|
|
1043
|
+
buildRemoteWebIndexHtml(rawAppIndexHtml, remoteWebIngress.config),
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
664
1046
|
}
|
|
665
1047
|
writeFileSync(
|
|
666
1048
|
paths.confPath,
|
|
@@ -688,6 +1070,9 @@ export function startIngressNginx(opts: {
|
|
|
688
1070
|
? {
|
|
689
1071
|
remoteWebConfigHash: remoteWebConfigFingerprint(
|
|
690
1072
|
remoteWebIngress.config,
|
|
1073
|
+
remoteWebIngress.webDistDir,
|
|
1074
|
+
remoteWebIngress.version,
|
|
1075
|
+
remoteWebIngress.appDistDir,
|
|
691
1076
|
),
|
|
692
1077
|
}
|
|
693
1078
|
: {}),
|
|
@@ -800,6 +1185,7 @@ export type StartRemoteWebIngressResult =
|
|
|
800
1185
|
}
|
|
801
1186
|
| { status: "nginx-missing" }
|
|
802
1187
|
| { status: "web-dist-missing" }
|
|
1188
|
+
| { status: "app-dist-missing" }
|
|
803
1189
|
| { status: "unreachable"; listenPort: number; logPath: string }
|
|
804
1190
|
| { status: "port-conflict"; listenPort: number; logPath: string };
|
|
805
1191
|
|
|
@@ -832,6 +1218,13 @@ export async function startRemoteWebIngress(opts: {
|
|
|
832
1218
|
* sensitive-route denylist (webhooks-only mode).
|
|
833
1219
|
*/
|
|
834
1220
|
includeWebApp?: boolean;
|
|
1221
|
+
/**
|
|
1222
|
+
* Which client bundle is the outer shell (default "web"). "app" additionally
|
|
1223
|
+
* serves the standalone app bundle as the outer account/client shell and
|
|
1224
|
+
* hands the shell a /web/assistant/ iframe URL for the web UI; "web" keeps
|
|
1225
|
+
* serving the web SPA directly from /assistant/.
|
|
1226
|
+
*/
|
|
1227
|
+
shellMode?: "web" | "app";
|
|
835
1228
|
/**
|
|
836
1229
|
* Serving assistant's display name, stamped into the served remote-web
|
|
837
1230
|
* config (`__CADET_CONFIG__.assistantName`) so remote clients can label
|
|
@@ -857,8 +1250,9 @@ export async function startRemoteWebIngress(opts: {
|
|
|
857
1250
|
listenPort: number;
|
|
858
1251
|
}) => void;
|
|
859
1252
|
}): Promise<StartRemoteWebIngressResult> {
|
|
860
|
-
|
|
1253
|
+
let listenPort = opts.listenPort ?? getNginxIngressPort();
|
|
861
1254
|
const includeWebApp = opts.includeWebApp ?? true;
|
|
1255
|
+
const shellMode = opts.shellMode ?? "web";
|
|
862
1256
|
|
|
863
1257
|
const version = getNginxVersion();
|
|
864
1258
|
if (!version) {
|
|
@@ -868,18 +1262,53 @@ export async function startRemoteWebIngress(opts: {
|
|
|
868
1262
|
const running = isIngressRunning(opts.workspaceDir);
|
|
869
1263
|
const recorded = running ? readIngressState(opts.workspaceDir) : null;
|
|
870
1264
|
const recordedMode = recorded?.includeWebApp ?? true;
|
|
1265
|
+
if (
|
|
1266
|
+
recorded &&
|
|
1267
|
+
opts.listenPort === undefined &&
|
|
1268
|
+
process.env.CADET_NGINX_INGRESS_PORT === undefined
|
|
1269
|
+
) {
|
|
1270
|
+
listenPort = recorded.listenPort;
|
|
1271
|
+
}
|
|
871
1272
|
// The SPA config the served index must carry, computed up front so the
|
|
872
1273
|
// reuse decision and the actual spawn share one value and cannot drift.
|
|
873
1274
|
const spaOptions = includeWebApp
|
|
874
1275
|
? {
|
|
875
|
-
hubUrl:
|
|
1276
|
+
hubUrl: cloudAssistantHubUrl(getCurrentEnvironment().name),
|
|
876
1277
|
...(opts.assistantName ? { assistantName: opts.assistantName } : {}),
|
|
877
1278
|
...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
|
|
878
1279
|
}
|
|
879
1280
|
: undefined;
|
|
880
|
-
const
|
|
881
|
-
|
|
1281
|
+
const webDistDir = includeWebApp ? findWebDistDir() : null;
|
|
1282
|
+
if (includeWebApp && !webDistDir) {
|
|
1283
|
+
return { status: "web-dist-missing" };
|
|
1284
|
+
}
|
|
1285
|
+
const appDistDir =
|
|
1286
|
+
includeWebApp && shellMode === "app"
|
|
1287
|
+
? (findAppDistDir() ?? undefined)
|
|
1288
|
+
: undefined;
|
|
1289
|
+
if (includeWebApp && shellMode === "app" && !appDistDir) {
|
|
1290
|
+
return { status: "app-dist-missing" };
|
|
1291
|
+
}
|
|
1292
|
+
const runtimeVersion = webDistDir
|
|
1293
|
+
? await fetchCurrentVersion(`http://127.0.0.1:${opts.gatewayPort}`)
|
|
1294
|
+
: undefined;
|
|
1295
|
+
const webVersion = webDistDir ? readWebReleaseVersion(webDistDir) : undefined;
|
|
1296
|
+
const assistantVersion = runtimeVersion
|
|
1297
|
+
? stripVersionPrefix(runtimeVersion)
|
|
882
1298
|
: undefined;
|
|
1299
|
+
const matchedVersion =
|
|
1300
|
+
assistantVersion && webVersion === assistantVersion
|
|
1301
|
+
? assistantVersion
|
|
1302
|
+
: undefined;
|
|
1303
|
+
const requestedConfigHash =
|
|
1304
|
+
spaOptions && webDistDir
|
|
1305
|
+
? remoteWebConfigFingerprint(
|
|
1306
|
+
remoteWebIngressConfig(spaOptions),
|
|
1307
|
+
webDistDir,
|
|
1308
|
+
matchedVersion,
|
|
1309
|
+
appDistDir,
|
|
1310
|
+
)
|
|
1311
|
+
: undefined;
|
|
883
1312
|
// Both sides are undefined in webhooks-only mode; an SPA record without a
|
|
884
1313
|
// fingerprint predates the field and counts as drifted (see IngressState).
|
|
885
1314
|
const configMatches = recorded?.remoteWebConfigHash === requestedConfigHash;
|
|
@@ -906,14 +1335,6 @@ export async function startRemoteWebIngress(opts: {
|
|
|
906
1335
|
return alreadyRunning();
|
|
907
1336
|
}
|
|
908
1337
|
|
|
909
|
-
let webDistDir: string | null = null;
|
|
910
|
-
if (includeWebApp) {
|
|
911
|
-
webDistDir = findWebDistDir();
|
|
912
|
-
if (!webDistDir) {
|
|
913
|
-
return { status: "web-dist-missing" };
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
|
|
917
1338
|
// The running edge serves the other mode, targets a different gateway
|
|
918
1339
|
// port, or injects a stale SPA config; restart it with the requested
|
|
919
1340
|
// config. A false stop can mean the old edge exited on its own after the
|
|
@@ -926,13 +1347,37 @@ export async function startRemoteWebIngress(opts: {
|
|
|
926
1347
|
return alreadyRunning();
|
|
927
1348
|
}
|
|
928
1349
|
|
|
1350
|
+
// A foreign process squatting the default edge port (another assistant's
|
|
1351
|
+
// edge, a stale dev server) must not kill the tunnel: the edge port is
|
|
1352
|
+
// loopback-local and the public tunnel URL never carries it, so moving to
|
|
1353
|
+
// the next free port is invisible to remote clients. Explicit pins
|
|
1354
|
+
// (opts.listenPort / CADET_NGINX_INGRESS_PORT) keep their intent and still
|
|
1355
|
+
// fail on a collision.
|
|
1356
|
+
if (
|
|
1357
|
+
opts.listenPort === undefined &&
|
|
1358
|
+
process.env.CADET_NGINX_INGRESS_PORT === undefined
|
|
1359
|
+
) {
|
|
1360
|
+
const discovered = await findAvailableIngressPort(listenPort);
|
|
1361
|
+
if (discovered !== null && discovered !== listenPort) {
|
|
1362
|
+
console.log(`Port ${listenPort} in use; using ${discovered} instead.`);
|
|
1363
|
+
listenPort = discovered;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
929
1367
|
opts.onStarting?.({ version, webDistDir, listenPort });
|
|
930
1368
|
|
|
931
1369
|
const child = startIngressNginx({
|
|
932
1370
|
workspaceDir: opts.workspaceDir,
|
|
933
1371
|
gatewayPort: opts.gatewayPort,
|
|
934
1372
|
listenPort,
|
|
935
|
-
remoteWebIngress: webDistDir
|
|
1373
|
+
remoteWebIngress: webDistDir
|
|
1374
|
+
? {
|
|
1375
|
+
webDistDir,
|
|
1376
|
+
...spaOptions,
|
|
1377
|
+
version: matchedVersion,
|
|
1378
|
+
...(appDistDir ? { appDistDir } : {}),
|
|
1379
|
+
}
|
|
1380
|
+
: undefined,
|
|
936
1381
|
});
|
|
937
1382
|
let exited = false;
|
|
938
1383
|
child.once("exit", () => {
|
|
@@ -1005,8 +1450,14 @@ function lockfileAssistantName(assistantId: string): string | undefined {
|
|
|
1005
1450
|
}
|
|
1006
1451
|
|
|
1007
1452
|
/** User-facing label for the edge mode, shared by every edge status line. */
|
|
1008
|
-
export function formatEdgeMode(
|
|
1009
|
-
|
|
1453
|
+
export function formatEdgeMode(
|
|
1454
|
+
includesWebApp: boolean,
|
|
1455
|
+
shellMode: "web" | "app" = "web",
|
|
1456
|
+
): string {
|
|
1457
|
+
if (!includesWebApp) return "webhooks only";
|
|
1458
|
+
return shellMode === "app"
|
|
1459
|
+
? "app shell + remote web + webhooks"
|
|
1460
|
+
: "remote web + webhooks";
|
|
1010
1461
|
}
|
|
1011
1462
|
|
|
1012
1463
|
/**
|
|
@@ -1042,6 +1493,7 @@ export async function stopContainerTunnelEdge(
|
|
|
1042
1493
|
function stampAppProcessEntry(
|
|
1043
1494
|
workspaceDir: string,
|
|
1044
1495
|
assistantId?: string,
|
|
1496
|
+
port = getNginxIngressPort(),
|
|
1045
1497
|
): void {
|
|
1046
1498
|
const pid = getIngressPid(workspaceDir);
|
|
1047
1499
|
if (pid !== null) {
|
|
@@ -1049,6 +1501,7 @@ function stampAppProcessEntry(
|
|
|
1049
1501
|
pid,
|
|
1050
1502
|
startedAt: new Date().toISOString(),
|
|
1051
1503
|
assistantId,
|
|
1504
|
+
port,
|
|
1052
1505
|
});
|
|
1053
1506
|
}
|
|
1054
1507
|
}
|
|
@@ -1059,6 +1512,8 @@ function stampAppProcessEntry(
|
|
|
1059
1512
|
/** True when this call started the edge, false when a running one was reused. */
|
|
1060
1513
|
started: boolean;
|
|
1061
1514
|
includesWebApp: boolean;
|
|
1515
|
+
/** The shell mode the edge serves (the persisted lockfile setting). */
|
|
1516
|
+
shellMode: "web" | "app";
|
|
1062
1517
|
}
|
|
1063
1518
|
|
|
1064
1519
|
/**
|
|
@@ -1105,10 +1560,15 @@ export async function ensureTunnelEdge(opts: {
|
|
|
1105
1560
|
const assistantName = opts.assistantId
|
|
1106
1561
|
? lockfileAssistantName(opts.assistantId)
|
|
1107
1562
|
: undefined;
|
|
1563
|
+
// The persisted lockfile setting owns the shell choice; every edge caller
|
|
1564
|
+
// (tunnel, wake restore, upgrade restore) reads it through here so a mode
|
|
1565
|
+
// flip only needs `cadet tunnel --shell` once.
|
|
1566
|
+
const shellMode = readShellMode();
|
|
1108
1567
|
const result = await startRemoteWebIngress({
|
|
1109
1568
|
workspaceDir: opts.workspaceDir,
|
|
1110
1569
|
gatewayPort: opts.gatewayPort,
|
|
1111
1570
|
includeWebApp: true,
|
|
1571
|
+
shellMode,
|
|
1112
1572
|
...(assistantName ? { assistantName } : {}),
|
|
1113
1573
|
...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
|
|
1114
1574
|
...(opts.onStarting ? { onStarting: opts.onStarting } : {}),
|
|
@@ -1116,11 +1576,16 @@ export async function ensureTunnelEdge(opts: {
|
|
|
1116
1576
|
|
|
1117
1577
|
switch (result.status) {
|
|
1118
1578
|
case "started":
|
|
1119
|
-
stampAppProcessEntry(
|
|
1579
|
+
stampAppProcessEntry(
|
|
1580
|
+
opts.workspaceDir,
|
|
1581
|
+
opts.assistantId,
|
|
1582
|
+
result.listenPort,
|
|
1583
|
+
);
|
|
1120
1584
|
return {
|
|
1121
1585
|
port: result.listenPort,
|
|
1122
1586
|
started: true,
|
|
1123
1587
|
includesWebApp: true,
|
|
1588
|
+
shellMode,
|
|
1124
1589
|
};
|
|
1125
1590
|
case "already-running": {
|
|
1126
1591
|
// `already-running` also covers a drifted edge whose restart failed, so
|
|
@@ -1150,11 +1615,16 @@ export async function ensureTunnelEdge(opts: {
|
|
|
1150
1615
|
stuckEdgeRecoveryHint(opts.workspaceDir),
|
|
1151
1616
|
);
|
|
1152
1617
|
}
|
|
1153
|
-
stampAppProcessEntry(
|
|
1618
|
+
stampAppProcessEntry(
|
|
1619
|
+
opts.workspaceDir,
|
|
1620
|
+
opts.assistantId,
|
|
1621
|
+
result.listenPort,
|
|
1622
|
+
);
|
|
1154
1623
|
return {
|
|
1155
1624
|
port: result.listenPort,
|
|
1156
1625
|
started: false,
|
|
1157
1626
|
includesWebApp: result.includeWebApp,
|
|
1627
|
+
shellMode,
|
|
1158
1628
|
};
|
|
1159
1629
|
}
|
|
1160
1630
|
case "nginx-missing":
|
|
@@ -1169,6 +1639,13 @@ export async function ensureTunnelEdge(opts: {
|
|
|
1169
1639
|
"Build the SPA with `cd clients/web && VITE_CLOUD_MODE=false bun run build`, " +
|
|
1170
1640
|
"or install @mycadet/web so its packaged dist directory is available.",
|
|
1171
1641
|
);
|
|
1642
|
+
case "app-dist-missing":
|
|
1643
|
+
throw new Error(
|
|
1644
|
+
"The app shell edge mode is selected but no " +
|
|
1645
|
+
"built app bundle is available. Build it with `cd clients/app && bun run build`, " +
|
|
1646
|
+
"or install @mycadet/app so its packaged standalone directory is available. " +
|
|
1647
|
+
"Switch back to the web shell with `cadet tunnel --shell web`.",
|
|
1648
|
+
);
|
|
1172
1649
|
case "unreachable":
|
|
1173
1650
|
throw new Error(
|
|
1174
1651
|
`nginx edge did not become reachable on 127.0.0.1:${result.listenPort}. ` +
|