@mycadet/cli 0.12.11 → 0.13.0-dev.202610171626.d97b8b8
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 +2 -2
- 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 +2 -2
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- 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 +2 -2
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- 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-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 +2 -2
- package/src/__tests__/gateway-token.test.ts +267 -0
- package/src/__tests__/host-teleport-docker.test.ts +371 -0
- package/src/__tests__/host-teleport.test.ts +405 -0
- package/src/__tests__/nginx-ingress.test.ts +536 -23
- 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 +6 -6
- 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 +3 -3
- 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.test.ts +10 -2
- 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 +51 -17
- package/src/commands/gateway/token.ts +77 -23
- package/src/commands/pair.ts +2 -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 +48 -7
- package/src/commands/upgrade.ts +78 -156
- 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 +23 -18
- package/src/lib/backup-ops.ts +37 -251
- package/src/lib/cloud-client.ts +10 -13
- package/src/lib/cloud-login.ts +2 -1
- 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 +59 -22
- package/src/lib/github-repository.ts +2 -0
- package/src/lib/guardian-token.ts +1 -23
- package/src/lib/hatch-local.ts +17 -1
- 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 +1 -1
- package/src/lib/local.ts +4 -2
- package/src/lib/nginx-ingress.ts +454 -79
- 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 +1 -0
- package/src/lib/upgrade-lifecycle.ts +63 -141
- package/src/lib/web-dist.ts +79 -1
- 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
|
@@ -26,6 +26,7 @@ import { z } from "zod";
|
|
|
26
26
|
|
|
27
27
|
import * as httpClient from "../lib/http-client.js";
|
|
28
28
|
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
29
|
+
import { saveShellMode } from "../lib/ingress-config.js";
|
|
29
30
|
|
|
30
31
|
const realChildProcess = { ...childProcess };
|
|
31
32
|
const realFs = { ...fsModule };
|
|
@@ -51,6 +52,10 @@ mock.module("node:fs", () => ({
|
|
|
51
52
|
readFileSync: readFileSyncMock,
|
|
52
53
|
}));
|
|
53
54
|
|
|
55
|
+
const fetchCurrentVersionMock = mock<typeof httpClient.fetchCurrentVersion>(
|
|
56
|
+
async () => "1.2.3",
|
|
57
|
+
);
|
|
58
|
+
|
|
54
59
|
const waitForDaemonReadyMock = mock<typeof httpClient.waitForDaemonReady>(
|
|
55
60
|
async () => true,
|
|
56
61
|
);
|
|
@@ -58,6 +63,7 @@ const waitForDaemonReadyMock = mock<typeof httpClient.waitForDaemonReady>(
|
|
|
58
63
|
mock.module("../lib/http-client.js", () => ({
|
|
59
64
|
...httpClient,
|
|
60
65
|
waitForDaemonReady: waitForDaemonReadyMock,
|
|
66
|
+
fetchCurrentVersion: fetchCurrentVersionMock,
|
|
61
67
|
}));
|
|
62
68
|
|
|
63
69
|
// Restore the real modules once this file finishes so the mocks do not leak
|
|
@@ -72,10 +78,10 @@ import {
|
|
|
72
78
|
buildIngressNginxConfig,
|
|
73
79
|
hasIpv6Loopback,
|
|
74
80
|
buildRemoteWebIndexHtml,
|
|
75
|
-
cloudWebHubUrl,
|
|
76
81
|
MAX_PRESERVED_MODULE_PRELOADS,
|
|
77
82
|
ensureTunnelEdge,
|
|
78
83
|
EDGE_TEMPLATE_VERSION,
|
|
84
|
+
findAppDistDir,
|
|
79
85
|
findWebDistDir,
|
|
80
86
|
startRemoteWebIngress,
|
|
81
87
|
stopContainerTunnelEdge,
|
|
@@ -124,6 +130,8 @@ afterEach(() => {
|
|
|
124
130
|
existsSyncMock.mockImplementation(realFs.existsSync);
|
|
125
131
|
readFileSyncMock.mockReset();
|
|
126
132
|
readFileSyncMock.mockImplementation(realFs.readFileSync);
|
|
133
|
+
fetchCurrentVersionMock.mockReset();
|
|
134
|
+
fetchCurrentVersionMock.mockImplementation(async () => "1.2.3");
|
|
127
135
|
waitForDaemonReadyMock.mockReset();
|
|
128
136
|
waitForDaemonReadyMock.mockImplementation(async () => true);
|
|
129
137
|
for (const dir of workspaces.splice(0)) {
|
|
@@ -307,7 +315,7 @@ describe("buildIngressNginxConfig", () => {
|
|
|
307
315
|
).toEqual([]);
|
|
308
316
|
});
|
|
309
317
|
|
|
310
|
-
test("revalidates the shell and forbids storing
|
|
318
|
+
test("revalidates the shell and forbids storing inline config", () => {
|
|
311
319
|
const blocks = locationBlocks(remoteConf);
|
|
312
320
|
const cacheControlOf = (matcher: string) =>
|
|
313
321
|
blocks
|
|
@@ -318,11 +326,8 @@ describe("buildIngressNginxConfig", () => {
|
|
|
318
326
|
expect(cacheControlOf("^~ /assistant/assets/")).toBe(
|
|
319
327
|
"public, max-age=31536000, immutable",
|
|
320
328
|
);
|
|
321
|
-
//
|
|
322
|
-
// refuses storage. A second no-store means a revalidating location
|
|
323
|
-
// regressed to re-sending its whole body on every load.
|
|
329
|
+
// Inline config has no validator and must not be stored.
|
|
324
330
|
expect(cacheControlOf("= /assistant/__config")).toBe("no-store");
|
|
325
|
-
expect(remoteConf.match(/no-store/g)).toHaveLength(1);
|
|
326
331
|
});
|
|
327
332
|
|
|
328
333
|
test("serves remote web config for the SPA", () => {
|
|
@@ -527,18 +532,6 @@ describe("buildRemoteWebIndexHtml", () => {
|
|
|
527
532
|
});
|
|
528
533
|
});
|
|
529
534
|
|
|
530
|
-
describe("cloudWebHubUrl", () => {
|
|
531
|
-
test("maps build environments to their cloud SPA base", () => {
|
|
532
|
-
expect(cloudWebHubUrl("production")).toBe("https://mycadet.ai/assistant");
|
|
533
|
-
expect(cloudWebHubUrl("staging")).toBe(
|
|
534
|
-
"https://staging.mycadet.ai/assistant",
|
|
535
|
-
);
|
|
536
|
-
expect(cloudWebHubUrl("dev")).toBe("https://dev.mycadet.ai/assistant");
|
|
537
|
-
expect(cloudWebHubUrl("local")).toBe("https://dev.mycadet.ai/assistant");
|
|
538
|
-
expect(cloudWebHubUrl(undefined)).toBe("https://dev.mycadet.ai/assistant");
|
|
539
|
-
});
|
|
540
|
-
});
|
|
541
|
-
|
|
542
535
|
describe("nginx ingress process state", () => {
|
|
543
536
|
function writeIngressState(workspaceDir: string, listenPort: number): void {
|
|
544
537
|
writeFileSync(
|
|
@@ -746,17 +739,19 @@ function mockWebDistMissing(): void {
|
|
|
746
739
|
}
|
|
747
740
|
|
|
748
741
|
/** Force findWebDistDir() to resolve a dist dir, regardless of checkout state. */
|
|
749
|
-
function mockWebDistPresent(): void {
|
|
742
|
+
function mockWebDistPresent(version: string | null = "1.2.3"): void {
|
|
750
743
|
existsSyncMock.mockImplementation((path) =>
|
|
751
744
|
String(path).endsWith(WEB_INDEX_SUFFIX) ? true : realFs.existsSync(path),
|
|
752
745
|
);
|
|
753
746
|
readFileSyncMock.mockImplementation(((path, options) =>
|
|
754
747
|
String(path).endsWith(WEB_INDEX_SUFFIX)
|
|
755
748
|
? FAKE_INDEX_HTML
|
|
756
|
-
:
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
749
|
+
: String(path).endsWith("release.json")
|
|
750
|
+
? JSON.stringify(version === null ? {} : { version })
|
|
751
|
+
: realFs.readFileSync(
|
|
752
|
+
path as never,
|
|
753
|
+
options as never,
|
|
754
|
+
)) as typeof readFileSync);
|
|
760
755
|
}
|
|
761
756
|
|
|
762
757
|
/** Record a running edge: ingress state, live pidfile, matching ps output. */
|
|
@@ -863,6 +858,10 @@ function spaConfigHash(
|
|
|
863
858
|
hubUrl: PRODUCTION_HUB_URL,
|
|
864
859
|
},
|
|
865
860
|
webDistDir: opts.webDistDir ?? findWebDistDir(),
|
|
861
|
+
version: "1.2.3",
|
|
862
|
+
// Web mode is the default shell; the fingerprint covers the mode so
|
|
863
|
+
// an edge built for the other shell is never mistaken for this one.
|
|
864
|
+
shellMode: "web",
|
|
866
865
|
indexHtml: opts.indexHtml ?? FAKE_INDEX_HTML,
|
|
867
866
|
}),
|
|
868
867
|
)
|
|
@@ -888,6 +887,70 @@ function pinProductionEnvironment(): void {
|
|
|
888
887
|
describe("startRemoteWebIngress", () => {
|
|
889
888
|
pinProductionEnvironment();
|
|
890
889
|
|
|
890
|
+
for (const [webVersion, runtimeVersion, ready] of [
|
|
891
|
+
["1.2.3", "1.2.3", true],
|
|
892
|
+
["1.2.3", "v1.2.3", true],
|
|
893
|
+
["1.2.2", "1.2.3", false],
|
|
894
|
+
["1.2.3", undefined, false],
|
|
895
|
+
[null, "1.2.3", false],
|
|
896
|
+
] as const) {
|
|
897
|
+
test(`release resolution: UI ${webVersion}, assistant ${runtimeVersion}`, async () => {
|
|
898
|
+
const ws = makeWorkspace();
|
|
899
|
+
mockNginxInstalled();
|
|
900
|
+
mockNginxSpawn();
|
|
901
|
+
mockWebDistPresent(webVersion);
|
|
902
|
+
fetchCurrentVersionMock.mockResolvedValue(runtimeVersion);
|
|
903
|
+
const result = await startRemoteWebIngress({
|
|
904
|
+
workspaceDir: ws,
|
|
905
|
+
gatewayPort: 7830,
|
|
906
|
+
listenPort: 7845,
|
|
907
|
+
assistantId: "assistant-1",
|
|
908
|
+
});
|
|
909
|
+
expect(result.status).toBe("started");
|
|
910
|
+
expect(fetchCurrentVersionMock).toHaveBeenCalledWith(
|
|
911
|
+
"http://127.0.0.1:7830",
|
|
912
|
+
);
|
|
913
|
+
const conf = realFs.readFileSync(ingressConfPath(ws), "utf8");
|
|
914
|
+
const detail = conf
|
|
915
|
+
.split('location = "/v1/assistants/assistant-1/" {')[1]
|
|
916
|
+
?.split("\n }")[0];
|
|
917
|
+
expect(detail).toContain(`return ${ready ? 200 : 409} `);
|
|
918
|
+
if (ready) {
|
|
919
|
+
expect(detail).toContain('\\"version\\":\\"1.2.3\\"');
|
|
920
|
+
} else {
|
|
921
|
+
expect(detail).not.toContain('\\"url\\"');
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
test("restarts an edge when its verified assistant/UI release changes", async () => {
|
|
927
|
+
const ws = makeWorkspace();
|
|
928
|
+
mockNginxInstalled();
|
|
929
|
+
mockNginxSpawn();
|
|
930
|
+
mockWebDistPresent("1.2.3");
|
|
931
|
+
fetchCurrentVersionMock.mockResolvedValue("1.2.4");
|
|
932
|
+
const pid = mockRunningEdge(ws, {
|
|
933
|
+
listenPort: 7845,
|
|
934
|
+
includeWebApp: true,
|
|
935
|
+
gatewayPort: 7830,
|
|
936
|
+
remoteWebConfigHash: spaConfigHash({ assistantId: "assistant-1" }),
|
|
937
|
+
});
|
|
938
|
+
const edge = mockKillableNginx(pid);
|
|
939
|
+
const result = await startRemoteWebIngress({
|
|
940
|
+
workspaceDir: ws,
|
|
941
|
+
gatewayPort: 7830,
|
|
942
|
+
listenPort: 7845,
|
|
943
|
+
assistantId: "assistant-1",
|
|
944
|
+
});
|
|
945
|
+
expect(result.status).toBe("started");
|
|
946
|
+
expect(edge.killed()).toBe(true);
|
|
947
|
+
const conf = realFs.readFileSync(ingressConfPath(ws), "utf8");
|
|
948
|
+
const detail = conf
|
|
949
|
+
.split('location = "/v1/assistants/assistant-1/" {')[1]
|
|
950
|
+
?.split("\n }")[0];
|
|
951
|
+
expect(detail).toContain("return 409 ");
|
|
952
|
+
});
|
|
953
|
+
|
|
891
954
|
test("webhooks-only mode starts the denylist+proxy edge without a web dist", async () => {
|
|
892
955
|
const ws = makeWorkspace();
|
|
893
956
|
mockNginxInstalled();
|
|
@@ -1857,6 +1920,7 @@ describe("ensureTunnelEdge", () => {
|
|
|
1857
1920
|
port: 7845,
|
|
1858
1921
|
started: false,
|
|
1859
1922
|
includesWebApp: true,
|
|
1923
|
+
shellMode: "web",
|
|
1860
1924
|
});
|
|
1861
1925
|
expect(edge.killed()).toBe(false);
|
|
1862
1926
|
expect(spawnMock).not.toHaveBeenCalled();
|
|
@@ -2010,6 +2074,7 @@ describe("ensureTunnelEdge", () => {
|
|
|
2010
2074
|
port: REQUESTED_PORT,
|
|
2011
2075
|
started: true,
|
|
2012
2076
|
includesWebApp: true,
|
|
2077
|
+
shellMode: "web",
|
|
2013
2078
|
});
|
|
2014
2079
|
expect(edge.killed()).toBe(true);
|
|
2015
2080
|
const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
|
|
@@ -2034,6 +2099,7 @@ describe("ensureTunnelEdge", () => {
|
|
|
2034
2099
|
port: REQUESTED_PORT,
|
|
2035
2100
|
started: true,
|
|
2036
2101
|
includesWebApp: true,
|
|
2102
|
+
shellMode: "web",
|
|
2037
2103
|
});
|
|
2038
2104
|
expect(edge.killed()).toBe(true);
|
|
2039
2105
|
const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
|
|
@@ -2056,6 +2122,7 @@ describe("ensureTunnelEdge", () => {
|
|
|
2056
2122
|
port: REQUESTED_PORT,
|
|
2057
2123
|
started: true,
|
|
2058
2124
|
includesWebApp: true,
|
|
2125
|
+
shellMode: "web",
|
|
2059
2126
|
});
|
|
2060
2127
|
const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
|
|
2061
2128
|
expect(conf).toContain("location ^~ /assistant/ {");
|
|
@@ -2111,6 +2178,7 @@ describe("ensureTunnelEdge", () => {
|
|
|
2111
2178
|
port: REQUESTED_PORT,
|
|
2112
2179
|
started: true,
|
|
2113
2180
|
includesWebApp: true,
|
|
2181
|
+
shellMode: "web",
|
|
2114
2182
|
});
|
|
2115
2183
|
const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
|
|
2116
2184
|
expect(conf).toContain("location ^~ /assistant/ {");
|
|
@@ -2261,3 +2329,448 @@ describe("stopContainerTunnelEdge", () => {
|
|
|
2261
2329
|
expect(await stopContainerTunnelEdge(containerEntry(7930))).toBe(false);
|
|
2262
2330
|
});
|
|
2263
2331
|
});
|
|
2332
|
+
|
|
2333
|
+
describe("assistant metadata API", () => {
|
|
2334
|
+
function config(id = "assistant-1") {
|
|
2335
|
+
return buildIngressNginxConfig({
|
|
2336
|
+
gatewayPort: 7830,
|
|
2337
|
+
listenPort: 7840,
|
|
2338
|
+
remoteWebIngress: {
|
|
2339
|
+
webDistDir: "/tmp/web",
|
|
2340
|
+
version: "1.2.3",
|
|
2341
|
+
assistantId: id,
|
|
2342
|
+
assistantName: "My assistant",
|
|
2343
|
+
config: { appAllowedOrigins: ["https://parent.example"] },
|
|
2344
|
+
},
|
|
2345
|
+
});
|
|
2346
|
+
}
|
|
2347
|
+
function body(conf: string, path: string, status = 200): unknown {
|
|
2348
|
+
const location = conf
|
|
2349
|
+
.split(`location = "${path}" {`)[1]
|
|
2350
|
+
?.split("\n }")[0];
|
|
2351
|
+
const value = location?.match(/return (200|409) ("(?:\\.|[^"\\])*");/);
|
|
2352
|
+
expect(Number(value?.[1])).toBe(status);
|
|
2353
|
+
if (!value) {
|
|
2354
|
+
throw new Error(`Missing metadata for ${path}`);
|
|
2355
|
+
}
|
|
2356
|
+
return JSON.parse(JSON.parse(value[2]));
|
|
2357
|
+
}
|
|
2358
|
+
test("lists only this host's assistant and returns its actual UI version and URL", () => {
|
|
2359
|
+
const conf = config();
|
|
2360
|
+
expect(body(conf, "/v1/assistants/")).toEqual({
|
|
2361
|
+
results: [{ id: "assistant-1", name: "My assistant" }],
|
|
2362
|
+
next: null,
|
|
2363
|
+
});
|
|
2364
|
+
expect(body(conf, "/v1/assistants/assistant-1/")).toEqual({
|
|
2365
|
+
id: "assistant-1",
|
|
2366
|
+
name: "My assistant",
|
|
2367
|
+
status: "active",
|
|
2368
|
+
version: "1.2.3",
|
|
2369
|
+
url: "assistant/",
|
|
2370
|
+
});
|
|
2371
|
+
expect(conf).not.toContain('location = "/v1/assistants/other/"');
|
|
2372
|
+
});
|
|
2373
|
+
test("metadata supports both slash forms and only read methods", () => {
|
|
2374
|
+
const conf = config();
|
|
2375
|
+
for (const path of [
|
|
2376
|
+
"/v1/assistants",
|
|
2377
|
+
"/v1/assistants/",
|
|
2378
|
+
"/v1/assistants/assistant-1",
|
|
2379
|
+
"/v1/assistants/assistant-1/",
|
|
2380
|
+
]) {
|
|
2381
|
+
expect(body(conf, path)).toBeDefined();
|
|
2382
|
+
}
|
|
2383
|
+
expect(conf).toContain(
|
|
2384
|
+
"if ($request_method !~ ^(GET|HEAD)$) { return 405; }",
|
|
2385
|
+
);
|
|
2386
|
+
expect(conf).toContain(
|
|
2387
|
+
'"https://parent.example" "https://parent.example";',
|
|
2388
|
+
);
|
|
2389
|
+
expect(conf).toContain(
|
|
2390
|
+
"add_header Access-Control-Allow-Origin $cadet_app_origin always;",
|
|
2391
|
+
);
|
|
2392
|
+
});
|
|
2393
|
+
test("unknown identity yields an empty list without inventing an assistant ID", () => {
|
|
2394
|
+
const conf = buildIngressNginxConfig({
|
|
2395
|
+
gatewayPort: 7830,
|
|
2396
|
+
listenPort: 7840,
|
|
2397
|
+
remoteWebIngress: { webDistDir: "/tmp/web" },
|
|
2398
|
+
});
|
|
2399
|
+
expect(body(conf, "/v1/assistants/")).toEqual({ results: [], next: null });
|
|
2400
|
+
});
|
|
2401
|
+
test("a known assistant without a UI bundle returns 409 on both detail paths", () => {
|
|
2402
|
+
const conf = buildIngressNginxConfig({
|
|
2403
|
+
gatewayPort: 7830,
|
|
2404
|
+
listenPort: 7840,
|
|
2405
|
+
remoteWebIngress: { webDistDir: "/tmp/web", assistantId: "assistant-1" },
|
|
2406
|
+
});
|
|
2407
|
+
expect(body(conf, "/v1/assistants/")).toEqual({
|
|
2408
|
+
results: [{ id: "assistant-1" }],
|
|
2409
|
+
next: null,
|
|
2410
|
+
});
|
|
2411
|
+
for (const path of [
|
|
2412
|
+
"/v1/assistants/assistant-1",
|
|
2413
|
+
"/v1/assistants/assistant-1/",
|
|
2414
|
+
]) {
|
|
2415
|
+
expect(body(conf, path, 409)).toEqual({
|
|
2416
|
+
detail: "assistant is not ready",
|
|
2417
|
+
});
|
|
2418
|
+
}
|
|
2419
|
+
});
|
|
2420
|
+
test("invalid browser origins fail before nginx starts", () => {
|
|
2421
|
+
expect(() =>
|
|
2422
|
+
buildIngressNginxConfig({
|
|
2423
|
+
gatewayPort: 7830,
|
|
2424
|
+
listenPort: 7840,
|
|
2425
|
+
remoteWebIngress: {
|
|
2426
|
+
webDistDir: "/tmp/web",
|
|
2427
|
+
config: { appAllowedOrigins: ["https://parent.example/path"] },
|
|
2428
|
+
},
|
|
2429
|
+
}),
|
|
2430
|
+
).toThrow("App origins");
|
|
2431
|
+
});
|
|
2432
|
+
});
|
|
2433
|
+
|
|
2434
|
+
const APP_INDEX_SUFFIX = join("standalone", "index.html");
|
|
2435
|
+
const FAKE_APP_INDEX_HTML =
|
|
2436
|
+
"<!doctype html><html><head><title>app</title></head><body>app-shell</body></html>";
|
|
2437
|
+
|
|
2438
|
+
/**
|
|
2439
|
+
* Force findAppDistDir() to resolve a standalone bundle. The web-dist mock
|
|
2440
|
+
* below always pairs with it: app mode serves both bundles.
|
|
2441
|
+
*/
|
|
2442
|
+
function mockAppDistPresent(): void {
|
|
2443
|
+
const previousExists = existsSyncMock.getMockImplementation();
|
|
2444
|
+
existsSyncMock.mockImplementation((path) =>
|
|
2445
|
+
String(path).endsWith(APP_INDEX_SUFFIX)
|
|
2446
|
+
? true
|
|
2447
|
+
: previousExists
|
|
2448
|
+
? previousExists(path)
|
|
2449
|
+
: realFs.existsSync(path),
|
|
2450
|
+
);
|
|
2451
|
+
const previousRead = readFileSyncMock.getMockImplementation();
|
|
2452
|
+
readFileSyncMock.mockImplementation(((path, options) =>
|
|
2453
|
+
String(path).endsWith(APP_INDEX_SUFFIX)
|
|
2454
|
+
? FAKE_APP_INDEX_HTML
|
|
2455
|
+
: previousRead
|
|
2456
|
+
? previousRead(path as never, options as never)
|
|
2457
|
+
: realFs.readFileSync(
|
|
2458
|
+
path as never,
|
|
2459
|
+
options as never,
|
|
2460
|
+
)) as typeof readFileSync);
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
/** The fingerprint the edge must record for an app-mode SPA config. */
|
|
2464
|
+
function appConfigHash(
|
|
2465
|
+
opts: {
|
|
2466
|
+
assistantName?: string;
|
|
2467
|
+
assistantId?: string;
|
|
2468
|
+
} = {},
|
|
2469
|
+
): string {
|
|
2470
|
+
return createHash("sha256")
|
|
2471
|
+
.update(
|
|
2472
|
+
JSON.stringify({
|
|
2473
|
+
template: EDGE_TEMPLATE_VERSION,
|
|
2474
|
+
config: {
|
|
2475
|
+
remote: "custom",
|
|
2476
|
+
apiBaseUrl: "/v1",
|
|
2477
|
+
...(opts.assistantName ? { assistantName: opts.assistantName } : {}),
|
|
2478
|
+
...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
|
|
2479
|
+
hubUrl: PRODUCTION_HUB_URL,
|
|
2480
|
+
},
|
|
2481
|
+
webDistDir: findWebDistDir(),
|
|
2482
|
+
version: "1.2.3",
|
|
2483
|
+
shellMode: "app",
|
|
2484
|
+
appDistDir: findAppDistDir(),
|
|
2485
|
+
appIndexHtml: FAKE_APP_INDEX_HTML,
|
|
2486
|
+
indexHtml: FAKE_INDEX_HTML,
|
|
2487
|
+
}),
|
|
2488
|
+
)
|
|
2489
|
+
.digest("hex");
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
describe("app shell mode", () => {
|
|
2493
|
+
pinProductionEnvironment();
|
|
2494
|
+
|
|
2495
|
+
function appConf() {
|
|
2496
|
+
return buildIngressNginxConfig({
|
|
2497
|
+
gatewayPort: 7830,
|
|
2498
|
+
listenPort: 7840,
|
|
2499
|
+
remoteWebIngress: {
|
|
2500
|
+
webDistDir: "/tmp/cadet web/dist",
|
|
2501
|
+
appDistDir: "/tmp/cadet app/dist/standalone",
|
|
2502
|
+
version: "1.2.3",
|
|
2503
|
+
assistantId: "assistant-1",
|
|
2504
|
+
config: {},
|
|
2505
|
+
},
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
test("serves the app bundle as the outer shell from the origin root", () => {
|
|
2510
|
+
const conf = appConf();
|
|
2511
|
+
// The injected app document is internal-only and every shell route
|
|
2512
|
+
// (/, /account/*, /logout, and the /assistant/* host routes) falls back
|
|
2513
|
+
// to it through the named location.
|
|
2514
|
+
expect(conf).toContain("location = /__cadet-app-index.html {");
|
|
2515
|
+
// Without an explicit app index path, the config aliases the bundle's own
|
|
2516
|
+
// index; the edge start path points this at the ingress dir (below).
|
|
2517
|
+
expect(conf).toContain('alias "/tmp/cadet app/dist/standalone/index.html"');
|
|
2518
|
+
expect(conf).toContain("location @cadet_app_shell {");
|
|
2519
|
+
expect(conf).toContain("rewrite ^ /__cadet-app-index.html last;");
|
|
2520
|
+
expect(conf).toContain(
|
|
2521
|
+
"location = / {\n rewrite ^ /__cadet-app-index.html last;\n }",
|
|
2522
|
+
);
|
|
2523
|
+
expect(conf).toContain('alias "/tmp/cadet app/dist/standalone/assets/";');
|
|
2524
|
+
expect(conf).toContain('alias "/tmp/cadet app/dist/standalone/";');
|
|
2525
|
+
// The shell owns the /assistant/* host routes; the exact mounts load the
|
|
2526
|
+
// app document rather than the web SPA.
|
|
2527
|
+
expect(conf).toContain(
|
|
2528
|
+
"location = /assistant {\n rewrite ^ /__cadet-app-index.html last;\n }",
|
|
2529
|
+
);
|
|
2530
|
+
expect(conf).toContain(
|
|
2531
|
+
"location = /assistant/ {\n rewrite ^ /__cadet-app-index.html last;\n }",
|
|
2532
|
+
);
|
|
2533
|
+
expect(conf).toContain("try_files $uri @cadet_app_shell;");
|
|
2534
|
+
// No recursive loading: the web SPA's exact mount is gone from the shell.
|
|
2535
|
+
expect(conf).not.toContain(
|
|
2536
|
+
"location = /assistant/ {\n rewrite ^ /assistant/__remote-index.html last;\n }",
|
|
2537
|
+
);
|
|
2538
|
+
});
|
|
2539
|
+
|
|
2540
|
+
test("iframes the web UI from the distinct /web/assistant namespace", () => {
|
|
2541
|
+
const conf = appConf();
|
|
2542
|
+
expect(conf).toContain('location = "/v1/assistants/assistant-1/"');
|
|
2543
|
+
expect(conf).toContain('\\"url\\":\\"web/assistant/\\"');
|
|
2544
|
+
expect(conf).toContain('\\"version\\":\\"1.2.3\\"');
|
|
2545
|
+
expect(conf).toContain(
|
|
2546
|
+
"location = /web/assistant/ {\n rewrite ^ /web/assistant/__remote-index.html last;\n }",
|
|
2547
|
+
);
|
|
2548
|
+
expect(conf).toContain("location = /web/assistant/__remote-index.html {");
|
|
2549
|
+
expect(conf).toContain('alias "/tmp/cadet web/dist/index.html"');
|
|
2550
|
+
expect(conf).toContain("location = /web/assistant/__config {");
|
|
2551
|
+
expect(conf).toContain("location ^~ /web/assistant/ {");
|
|
2552
|
+
expect(conf).toContain(
|
|
2553
|
+
"try_files $uri $uri/ /web/assistant/__remote-index.html;",
|
|
2554
|
+
);
|
|
2555
|
+
// The web bundle's asset URLs keep their baked /assistant/ base.
|
|
2556
|
+
expect(conf).toContain(
|
|
2557
|
+
'location ^~ /assistant/assets/ {\n gzip on;\n alias "/tmp/cadet web/dist/assets/"',
|
|
2558
|
+
);
|
|
2559
|
+
// Prefixed API paths the web UI builds from its serving prefix.
|
|
2560
|
+
expect(conf).toContain("location ^~ /web/v1/ {");
|
|
2561
|
+
expect(conf).toContain("proxy_pass http://127.0.0.1:7830/v1/;");
|
|
2562
|
+
// Nothing else under /web exists, so the namespace cannot serve the shell
|
|
2563
|
+
// (or anything else) recursively.
|
|
2564
|
+
expect(conf).toContain("location ^~ /web/ {\n return 404;\n }");
|
|
2565
|
+
});
|
|
2566
|
+
|
|
2567
|
+
test("blocks the sensitive gateway routes under both prefixes", () => {
|
|
2568
|
+
const conf = appConf();
|
|
2569
|
+
for (const route of ["/v1/pair", "/v1/devices", "/v1/guardian/init"]) {
|
|
2570
|
+
expect(conf).toContain(`location = ${route} { return 404; }`);
|
|
2571
|
+
expect(conf).toContain(`location = /web${route} { return 404; }`);
|
|
2572
|
+
}
|
|
2573
|
+
expect(conf).toContain(
|
|
2574
|
+
"location ^~ /web/assistant/__local/ { return 404; }",
|
|
2575
|
+
);
|
|
2576
|
+
});
|
|
2577
|
+
|
|
2578
|
+
test("proxies runtime API paths verbatim for both surfaces", () => {
|
|
2579
|
+
const conf = appConf();
|
|
2580
|
+
expect(conf).toContain(
|
|
2581
|
+
"location ^~ /v1/ {\n proxy_pass http://127.0.0.1:7830;",
|
|
2582
|
+
);
|
|
2583
|
+
expect(conf).toContain(
|
|
2584
|
+
"location ^~ /webhooks/ {\n proxy_pass http://127.0.0.1:7830;",
|
|
2585
|
+
);
|
|
2586
|
+
expect(conf).toContain(
|
|
2587
|
+
"location = /healthz {\n proxy_pass http://127.0.0.1:7830;",
|
|
2588
|
+
);
|
|
2589
|
+
});
|
|
2590
|
+
|
|
2591
|
+
test("app mode starts, injects both documents, and records the app fingerprint", async () => {
|
|
2592
|
+
const ws = makeWorkspace();
|
|
2593
|
+
mockNginxInstalled();
|
|
2594
|
+
mockNginxSpawn();
|
|
2595
|
+
mockWebDistPresent();
|
|
2596
|
+
mockAppDistPresent();
|
|
2597
|
+
fetchCurrentVersionMock.mockResolvedValue("1.2.3");
|
|
2598
|
+
|
|
2599
|
+
const result = await startRemoteWebIngress({
|
|
2600
|
+
workspaceDir: ws,
|
|
2601
|
+
gatewayPort: 7830,
|
|
2602
|
+
listenPort: 7845,
|
|
2603
|
+
shellMode: "app",
|
|
2604
|
+
assistantId: "assistant-1",
|
|
2605
|
+
});
|
|
2606
|
+
|
|
2607
|
+
expect(result.status).toBe("started");
|
|
2608
|
+
const ingressDir = join(ws, "data", "ingress");
|
|
2609
|
+
const appIndex = realFs.readFileSync(
|
|
2610
|
+
join(ingressDir, "app-index.html"),
|
|
2611
|
+
"utf-8",
|
|
2612
|
+
);
|
|
2613
|
+
expect(appIndex).toContain("__CADET_CONFIG__");
|
|
2614
|
+
expect(appIndex).toContain(`"assistantId":"assistant-1"`);
|
|
2615
|
+
const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
|
|
2616
|
+
expect(conf).toContain("location = /__cadet-app-index.html {");
|
|
2617
|
+
expect(conf).toContain('\\"url\\":\\"web/assistant/\\"');
|
|
2618
|
+
expect(readConfig(ws).ingress?.nginx).toMatchObject({
|
|
2619
|
+
listenPort: 7845,
|
|
2620
|
+
includeWebApp: true,
|
|
2621
|
+
gatewayPort: 7830,
|
|
2622
|
+
remoteWebConfigHash: appConfigHash({ assistantId: "assistant-1" }),
|
|
2623
|
+
});
|
|
2624
|
+
});
|
|
2625
|
+
|
|
2626
|
+
test("fails fast when the app bundle is not built", async () => {
|
|
2627
|
+
const ws = makeWorkspace();
|
|
2628
|
+
mockNginxInstalled();
|
|
2629
|
+
mockNginxSpawn();
|
|
2630
|
+
mockWebDistPresent();
|
|
2631
|
+
// A real build in the checkout would satisfy the lookup; the failure
|
|
2632
|
+
// path needs the lookup to resolve nothing, regardless of checkout state.
|
|
2633
|
+
// Web dist stays present so the app-dist check is the one that fires.
|
|
2634
|
+
const previousExists = existsSyncMock.getMockImplementation();
|
|
2635
|
+
existsSyncMock.mockImplementation((path) =>
|
|
2636
|
+
String(path).endsWith(APP_INDEX_SUFFIX)
|
|
2637
|
+
? false
|
|
2638
|
+
: previousExists
|
|
2639
|
+
? previousExists(path)
|
|
2640
|
+
: realFs.existsSync(path),
|
|
2641
|
+
);
|
|
2642
|
+
|
|
2643
|
+
const result = await startRemoteWebIngress({
|
|
2644
|
+
workspaceDir: ws,
|
|
2645
|
+
gatewayPort: 7830,
|
|
2646
|
+
listenPort: 7845,
|
|
2647
|
+
shellMode: "app",
|
|
2648
|
+
});
|
|
2649
|
+
|
|
2650
|
+
expect(result).toEqual({ status: "app-dist-missing" });
|
|
2651
|
+
expect(realFs.existsSync(ingressConfPath(ws))).toBe(false);
|
|
2652
|
+
});
|
|
2653
|
+
|
|
2654
|
+
test("restarting an edge between shell modes rotates onto the requested mode", async () => {
|
|
2655
|
+
const ws = makeWorkspace();
|
|
2656
|
+
mockNginxInstalled();
|
|
2657
|
+
mockNginxSpawn();
|
|
2658
|
+
mockWebDistPresent();
|
|
2659
|
+
mockAppDistPresent();
|
|
2660
|
+
// A running web-mode edge whose fingerprint predates app mode.
|
|
2661
|
+
mockRunningEdge(ws, {
|
|
2662
|
+
listenPort: 7845,
|
|
2663
|
+
includeWebApp: true,
|
|
2664
|
+
gatewayPort: 7830,
|
|
2665
|
+
remoteWebConfigHash: spaConfigHash({ assistantId: "assistant-1" }),
|
|
2666
|
+
});
|
|
2667
|
+
const edge = mockKillableNginx(123_460);
|
|
2668
|
+
|
|
2669
|
+
const result = await startRemoteWebIngress({
|
|
2670
|
+
workspaceDir: ws,
|
|
2671
|
+
gatewayPort: 7830,
|
|
2672
|
+
listenPort: 7845,
|
|
2673
|
+
shellMode: "app",
|
|
2674
|
+
assistantId: "assistant-1",
|
|
2675
|
+
});
|
|
2676
|
+
|
|
2677
|
+
expect(result.status).toBe("started");
|
|
2678
|
+
expect(edge.killed()).toBe(true);
|
|
2679
|
+
expect(readConfig(ws).ingress?.nginx).toMatchObject({
|
|
2680
|
+
remoteWebConfigHash: appConfigHash({ assistantId: "assistant-1" }),
|
|
2681
|
+
});
|
|
2682
|
+
});
|
|
2683
|
+
|
|
2684
|
+
test("reuses an already-running app-mode edge", async () => {
|
|
2685
|
+
const ws = makeWorkspace();
|
|
2686
|
+
mockNginxInstalled();
|
|
2687
|
+
mockNginxSpawn();
|
|
2688
|
+
mockWebDistPresent();
|
|
2689
|
+
mockAppDistPresent();
|
|
2690
|
+
mockRunningEdge(ws, {
|
|
2691
|
+
listenPort: 7845,
|
|
2692
|
+
includeWebApp: true,
|
|
2693
|
+
gatewayPort: 7830,
|
|
2694
|
+
remoteWebConfigHash: appConfigHash({ assistantId: "assistant-1" }),
|
|
2695
|
+
});
|
|
2696
|
+
const edge = mockKillableNginx(123_460);
|
|
2697
|
+
|
|
2698
|
+
const result = await startRemoteWebIngress({
|
|
2699
|
+
workspaceDir: ws,
|
|
2700
|
+
gatewayPort: 7830,
|
|
2701
|
+
listenPort: 7845,
|
|
2702
|
+
shellMode: "app",
|
|
2703
|
+
assistantId: "assistant-1",
|
|
2704
|
+
});
|
|
2705
|
+
|
|
2706
|
+
expect(result).toMatchObject({
|
|
2707
|
+
status: "already-running",
|
|
2708
|
+
listenPort: 7845,
|
|
2709
|
+
});
|
|
2710
|
+
expect(edge.killed()).toBe(false);
|
|
2711
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
2712
|
+
});
|
|
2713
|
+
|
|
2714
|
+
test("ensureTunnelEdge reads the persisted shell mode from the lockfile", async () => {
|
|
2715
|
+
const ws = makeWorkspace();
|
|
2716
|
+
mockNginxInstalled();
|
|
2717
|
+
mockNginxSpawn();
|
|
2718
|
+
mockWebDistPresent();
|
|
2719
|
+
mockAppDistPresent();
|
|
2720
|
+
const originalLockfileDir = process.env.CADET_LOCKFILE_DIR;
|
|
2721
|
+
const lockfileDir = join(ws, "lockfile");
|
|
2722
|
+
mkdirSync(lockfileDir, { recursive: true });
|
|
2723
|
+
process.env.CADET_LOCKFILE_DIR = lockfileDir;
|
|
2724
|
+
try {
|
|
2725
|
+
saveShellMode("app");
|
|
2726
|
+
|
|
2727
|
+
const result = await ensureTunnelEdge({
|
|
2728
|
+
assistantId: "assistant-1",
|
|
2729
|
+
workspaceDir: ws,
|
|
2730
|
+
gatewayPort: 7830,
|
|
2731
|
+
});
|
|
2732
|
+
|
|
2733
|
+
expect(result.shellMode).toBe("app");
|
|
2734
|
+
} finally {
|
|
2735
|
+
if (originalLockfileDir === undefined) {
|
|
2736
|
+
delete process.env.CADET_LOCKFILE_DIR;
|
|
2737
|
+
} else {
|
|
2738
|
+
process.env.CADET_LOCKFILE_DIR = originalLockfileDir;
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
|
|
2742
|
+
expect(conf).toContain("location = /__cadet-app-index.html {");
|
|
2743
|
+
expect(conf).toContain('\\"url\\":\\"web/assistant/\\"');
|
|
2744
|
+
});
|
|
2745
|
+
|
|
2746
|
+
test("ensureTunnelEdge defaults to the web shell", async () => {
|
|
2747
|
+
const ws = makeWorkspace();
|
|
2748
|
+
mockNginxInstalled();
|
|
2749
|
+
mockNginxSpawn();
|
|
2750
|
+
mockWebDistPresent();
|
|
2751
|
+
// Hermetic lockfile dir: no edgeShell key means the web default, even if
|
|
2752
|
+
// the machine running the tests has its own shell selection persisted.
|
|
2753
|
+
const originalLockfileDir = process.env.CADET_LOCKFILE_DIR;
|
|
2754
|
+
const lockfileDir = join(ws, "lockfile");
|
|
2755
|
+
mkdirSync(lockfileDir, { recursive: true });
|
|
2756
|
+
process.env.CADET_LOCKFILE_DIR = lockfileDir;
|
|
2757
|
+
try {
|
|
2758
|
+
const result = await ensureTunnelEdge({
|
|
2759
|
+
assistantId: "assistant-1",
|
|
2760
|
+
workspaceDir: ws,
|
|
2761
|
+
gatewayPort: 7830,
|
|
2762
|
+
});
|
|
2763
|
+
|
|
2764
|
+
expect(result.shellMode).toBe("web");
|
|
2765
|
+
} finally {
|
|
2766
|
+
if (originalLockfileDir === undefined) {
|
|
2767
|
+
delete process.env.CADET_LOCKFILE_DIR;
|
|
2768
|
+
} else {
|
|
2769
|
+
process.env.CADET_LOCKFILE_DIR = originalLockfileDir;
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
|
|
2773
|
+
expect(conf).not.toContain("__cadet-app-index");
|
|
2774
|
+
expect(conf).toContain('\\"url\\":\\"assistant/\\"');
|
|
2775
|
+
});
|
|
2776
|
+
});
|
|
@@ -107,7 +107,7 @@ describe("getKnownPidsFromAssistants", () => {
|
|
|
107
107
|
const entry: AssistantEntry = {
|
|
108
108
|
assistantId: "managed-1",
|
|
109
109
|
runtimeUrl: "https://cloud.mycadet.ai/foo",
|
|
110
|
-
cloud: "
|
|
110
|
+
cloud: "cloud",
|
|
111
111
|
};
|
|
112
112
|
const pids = getKnownPidsFromAssistants([entry]);
|
|
113
113
|
expect(pids.size).toBe(0);
|