@mycadet/cli 0.12.11 → 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 +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
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
isStagingVersion,
|
|
14
14
|
} from "../lib/version-compat.js";
|
|
15
15
|
|
|
16
|
+
import { CADET_GITHUB_REPOSITORY } from "../lib/github-repository.js";
|
|
17
|
+
|
|
16
18
|
const originalFetch = globalThis.fetch;
|
|
17
19
|
|
|
18
20
|
function mockFetchJson(body: unknown, status = 200) {
|
|
@@ -207,3 +209,83 @@ describe("fetchLatestVersion", () => {
|
|
|
207
209
|
expect(await fetchLatestVersion("preview")).toBeNull();
|
|
208
210
|
});
|
|
209
211
|
});
|
|
212
|
+
|
|
213
|
+
describe("stable release fallback", () => {
|
|
214
|
+
function withGitHubFallback(cloudBody: unknown, cloudStatus = 200) {
|
|
215
|
+
const fetchMock = mock(async (url: RequestInfo | URL) => {
|
|
216
|
+
if (String(url).startsWith("https://api.github.com/")) {
|
|
217
|
+
return Response.json({
|
|
218
|
+
tag_name: "v0.12.11",
|
|
219
|
+
draft: false,
|
|
220
|
+
prerelease: false,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return Response.json(cloudBody, { status: cloudStatus });
|
|
224
|
+
});
|
|
225
|
+
globalThis.fetch = Object.assign(fetchMock, {
|
|
226
|
+
preconnect: originalFetch.preconnect,
|
|
227
|
+
});
|
|
228
|
+
return fetchMock;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
test.each([
|
|
232
|
+
[404, { detail: "not found" }],
|
|
233
|
+
[503, { detail: "unavailable" }],
|
|
234
|
+
[200, []],
|
|
235
|
+
[200, { results: [] }],
|
|
236
|
+
[200, [{ version: "not-a-version" }]],
|
|
237
|
+
[200, [{ version: "0.13.0-preview.1" }]],
|
|
238
|
+
[200, [{ version: "0.13.0", is_stable: false }]],
|
|
239
|
+
])("falls back for cloud status %s and body %j", async (status, body) => {
|
|
240
|
+
const fetchMock = withGitHubFallback(body, status);
|
|
241
|
+
expect(await fetchLatestVersion()).toBe("0.12.11");
|
|
242
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
243
|
+
expect(String(fetchMock.mock.calls[1][0])).toBe(
|
|
244
|
+
`https://api.github.com/repos/${CADET_GITHUB_REPOSITORY}/releases/latest`,
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("falls back when the cloud request throws", async () => {
|
|
249
|
+
const fetchMock = withGitHubFallback([]);
|
|
250
|
+
globalThis.fetch = Object.assign(
|
|
251
|
+
mock(async (url: RequestInfo | URL) => {
|
|
252
|
+
if (!String(url).startsWith("https://api.github.com/")) {
|
|
253
|
+
throw new TypeError("fetch failed");
|
|
254
|
+
}
|
|
255
|
+
return fetchMock(url);
|
|
256
|
+
}),
|
|
257
|
+
{ preconnect: originalFetch.preconnect },
|
|
258
|
+
);
|
|
259
|
+
expect(await fetchLatestVersion()).toBe("0.12.11");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("does not contact GitHub when cloud discovery succeeds", async () => {
|
|
263
|
+
const fetchMock = withGitHubFallback([RELEASE]);
|
|
264
|
+
expect(await fetchLatestVersion()).toBe(RELEASE.version);
|
|
265
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("does not substitute stable for an unavailable preview channel", async () => {
|
|
269
|
+
const fetchMock = withGitHubFallback([], 503);
|
|
270
|
+
expect(await fetchLatestVersion("preview")).toBeNull();
|
|
271
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test.each([
|
|
275
|
+
{ tag_name: "v0.13.0", draft: true, prerelease: false },
|
|
276
|
+
{ tag_name: "v0.13.0", draft: false, prerelease: true },
|
|
277
|
+
{ tag_name: "v0.13.0-preview.1", draft: false, prerelease: false },
|
|
278
|
+
{ tag_name: "not-a-version", draft: false, prerelease: false },
|
|
279
|
+
{ tag_name: "v0.13.0" },
|
|
280
|
+
])("rejects invalid GitHub stable release %j", async (body) => {
|
|
281
|
+
mockFetchJson(body);
|
|
282
|
+
expect(await fetchLatestVersion()).toBeNull();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("returns null when both services fail", async () => {
|
|
286
|
+
mockFetchError();
|
|
287
|
+
expect(await fetchLatestVersion()).toBeNull();
|
|
288
|
+
mockFetchJson({ message: "rate limited" }, 403);
|
|
289
|
+
expect(await fetchLatestVersion()).toBeNull();
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import * as childProcess from "node:child_process";
|
|
4
|
+
import { PassThrough } from "node:stream";
|
|
5
|
+
import { spyOn } from "bun:test";
|
|
6
|
+
|
|
7
|
+
export interface CommandRequest {
|
|
8
|
+
command: string;
|
|
9
|
+
args: string[];
|
|
10
|
+
input: Buffer;
|
|
11
|
+
}
|
|
12
|
+
export interface CommandResponse {
|
|
13
|
+
stdout?: string;
|
|
14
|
+
stderr?: string;
|
|
15
|
+
code?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** requests-mock pattern at the subprocess boundary: explicit registrations, history and no fallthrough to real commands. */
|
|
19
|
+
export function mockCommands() {
|
|
20
|
+
const calls: CommandRequest[] = [];
|
|
21
|
+
const handlers: Array<{
|
|
22
|
+
command: string;
|
|
23
|
+
match: (args: string[]) => boolean;
|
|
24
|
+
respond: (
|
|
25
|
+
request: CommandRequest,
|
|
26
|
+
) => CommandResponse | Promise<CommandResponse>;
|
|
27
|
+
}> = [];
|
|
28
|
+
const implementation = (command: string, argsOrOptions: unknown = []) => {
|
|
29
|
+
const args: string[] = Array.isArray(argsOrOptions) ? argsOrOptions : [];
|
|
30
|
+
const request: CommandRequest = {
|
|
31
|
+
command,
|
|
32
|
+
args: [...args],
|
|
33
|
+
input: Buffer.alloc(0),
|
|
34
|
+
};
|
|
35
|
+
calls.push(request);
|
|
36
|
+
const handler = [...handlers]
|
|
37
|
+
.reverse()
|
|
38
|
+
.find((entry) => entry.command === command && entry.match(request.args));
|
|
39
|
+
if (!handler) throw new Error(`Unregistered command: ${command}`);
|
|
40
|
+
const stdin = new PassThrough(),
|
|
41
|
+
stdout = new PassThrough(),
|
|
42
|
+
stderr = new PassThrough();
|
|
43
|
+
const child = Object.assign(new EventEmitter(), {
|
|
44
|
+
stdin,
|
|
45
|
+
stdout,
|
|
46
|
+
stderr,
|
|
47
|
+
stdio: [stdin, stdout, stderr],
|
|
48
|
+
kill: () => true,
|
|
49
|
+
});
|
|
50
|
+
const chunks: Buffer[] = [];
|
|
51
|
+
stdin.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
52
|
+
const respond = async () => {
|
|
53
|
+
request.input = Buffer.concat(chunks);
|
|
54
|
+
try {
|
|
55
|
+
const result = await handler.respond(request);
|
|
56
|
+
stdout.end(result.stdout ?? "");
|
|
57
|
+
stderr.end(result.stderr ?? "");
|
|
58
|
+
child.emit("close", result.code ?? 0);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
child.emit("error", error);
|
|
61
|
+
child.emit("close", 1);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
if (request.args.includes("-i"))
|
|
65
|
+
stdin.once("finish", () => {
|
|
66
|
+
void respond();
|
|
67
|
+
});
|
|
68
|
+
else
|
|
69
|
+
queueMicrotask(() => {
|
|
70
|
+
void respond();
|
|
71
|
+
});
|
|
72
|
+
return child as unknown as ChildProcessWithoutNullStreams;
|
|
73
|
+
};
|
|
74
|
+
const spawn = spyOn(childProcess, "spawn").mockImplementation(
|
|
75
|
+
implementation as typeof childProcess.spawn,
|
|
76
|
+
);
|
|
77
|
+
return {
|
|
78
|
+
calls,
|
|
79
|
+
on(
|
|
80
|
+
command: string,
|
|
81
|
+
args: string[] | ((args: string[]) => boolean),
|
|
82
|
+
response:
|
|
83
|
+
| CommandResponse
|
|
84
|
+
| ((
|
|
85
|
+
request: CommandRequest,
|
|
86
|
+
) => CommandResponse | Promise<CommandResponse>),
|
|
87
|
+
) {
|
|
88
|
+
handlers.push({
|
|
89
|
+
command,
|
|
90
|
+
match:
|
|
91
|
+
typeof args === "function"
|
|
92
|
+
? args
|
|
93
|
+
: (actual) => JSON.stringify(actual) === JSON.stringify(args),
|
|
94
|
+
respond: typeof response === "function" ? response : () => response,
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
restore() {
|
|
98
|
+
spawn.mockRestore();
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -18,7 +18,6 @@ process.env.CADET_LOCKFILE_DIR = testDir;
|
|
|
18
18
|
|
|
19
19
|
import * as assistantConfig from "../lib/assistant-config.js";
|
|
20
20
|
import * as guardianToken from "../lib/guardian-token.js";
|
|
21
|
-
import * as localRuntimeClient from "../lib/local-runtime-client.js";
|
|
22
21
|
import * as cloudClient from "../lib/cloud-client.js";
|
|
23
22
|
import { withPreconnect } from "./helpers/http.js";
|
|
24
23
|
import { makeGuardianTokenData } from "./helpers/guardian-token.js";
|
|
@@ -29,26 +28,9 @@ const resolveTargetMock = spyOn(
|
|
|
29
28
|
).mockImplementation(() => {
|
|
30
29
|
throw new Error("process.exit:1");
|
|
31
30
|
});
|
|
32
|
-
const readCloudTokenMock = spyOn(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
).mockReturnValue("platform-token");
|
|
36
|
-
const exportMock = spyOn(
|
|
37
|
-
localRuntimeClient,
|
|
38
|
-
"localRuntimeExportToGcs",
|
|
39
|
-
).mockResolvedValue({ jobId: "job-1" });
|
|
40
|
-
const identityMock = spyOn(
|
|
41
|
-
localRuntimeClient,
|
|
42
|
-
"localRuntimeIdentity",
|
|
43
|
-
).mockResolvedValue({ version: "0.12.3" });
|
|
44
|
-
const pollMock = spyOn(
|
|
45
|
-
localRuntimeClient,
|
|
46
|
-
"localRuntimePollJobStatus",
|
|
47
|
-
).mockResolvedValue({
|
|
48
|
-
jobId: "job-1",
|
|
49
|
-
type: "export",
|
|
50
|
-
status: "complete",
|
|
51
|
-
});
|
|
31
|
+
const readCloudTokenMock = spyOn(cloudClient, "readCloudToken").mockReturnValue(
|
|
32
|
+
"platform-token",
|
|
33
|
+
);
|
|
52
34
|
const loadGuardianTokenSpy = spyOn(
|
|
53
35
|
guardianToken,
|
|
54
36
|
"loadGuardianToken",
|
|
@@ -109,13 +91,9 @@ beforeEach(() => {
|
|
|
109
91
|
);
|
|
110
92
|
resolveTargetMock.mockReset();
|
|
111
93
|
resolveTargetMock.mockReturnValue(LOCAL_ENTRY);
|
|
112
|
-
identityMock.mockReset();
|
|
113
|
-
identityMock.mockResolvedValue({ version: "0.12.3" });
|
|
114
94
|
leaseGuardianTokenSpy.mockClear();
|
|
115
95
|
readCloudTokenMock.mockReset();
|
|
116
96
|
readCloudTokenMock.mockReturnValue("platform-token");
|
|
117
|
-
exportMock.mockClear();
|
|
118
|
-
pollMock.mockClear();
|
|
119
97
|
});
|
|
120
98
|
|
|
121
99
|
afterEach(() => {
|
|
@@ -125,40 +103,29 @@ afterEach(() => {
|
|
|
125
103
|
|
|
126
104
|
afterAll(() => {
|
|
127
105
|
resolveTargetMock.mockRestore();
|
|
128
|
-
identityMock.mockRestore();
|
|
129
106
|
leaseGuardianTokenSpy.mockRestore();
|
|
130
107
|
readCloudTokenMock.mockRestore();
|
|
131
|
-
exportMock.mockRestore();
|
|
132
|
-
pollMock.mockRestore();
|
|
133
108
|
loadGuardianTokenSpy.mockRestore();
|
|
134
109
|
rmSync(testDir, { recursive: true, force: true });
|
|
135
110
|
});
|
|
136
111
|
|
|
137
112
|
describe("cadet debug", () => {
|
|
138
|
-
test("
|
|
113
|
+
test("requests sanitized logs and uploads them without runtime export jobs", async () => {
|
|
139
114
|
await debug();
|
|
140
|
-
|
|
141
|
-
expect(fetchCalls).toHaveLength(1);
|
|
142
|
-
expect(fetchCalls[0].url).toBe(
|
|
115
|
+
expect(fetchCalls.map((call) => call.url)).toEqual([
|
|
143
116
|
"https://cloud.mycadet.ai/v1/assistants/11111111-2222-3333-4444-555555555555/debug-bundle-upload-url/",
|
|
117
|
+
"http://127.0.0.1:7821/v1/logs/export",
|
|
118
|
+
"https://storage.googleapis.com/b/signed",
|
|
119
|
+
]);
|
|
120
|
+
expect(fetchCalls[1].init?.method).toBe("POST");
|
|
121
|
+
expect(new Headers(fetchCalls[1].init?.headers).get("Authorization")).toBe(
|
|
122
|
+
"Bearer local-token",
|
|
144
123
|
);
|
|
145
|
-
expect(fetchCalls[
|
|
146
|
-
expect(
|
|
147
|
-
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
expect(exportMock).toHaveBeenCalledTimes(1);
|
|
151
|
-
const [entry, token, params] = exportMock.mock.calls[0];
|
|
152
|
-
expect(entry).toBe(LOCAL_ENTRY);
|
|
153
|
-
expect(token).toBe("local-token");
|
|
154
|
-
expect(params).toEqual({
|
|
155
|
-
uploadUrl: "https://storage.googleapis.com/b/signed",
|
|
156
|
-
description: "debug bundle for Cadet staff",
|
|
157
|
-
profile: "debug",
|
|
158
|
-
});
|
|
159
|
-
expect(pollMock).toHaveBeenCalled();
|
|
160
|
-
// The daemon was checked for the debug profile before the cloud call.
|
|
161
|
-
expect(identityMock).toHaveBeenCalledTimes(1);
|
|
124
|
+
expect(fetchCalls[2].init?.method).toBe("PUT");
|
|
125
|
+
expect(new Headers(fetchCalls[2].init?.headers).has("Authorization")).toBe(
|
|
126
|
+
false,
|
|
127
|
+
);
|
|
128
|
+
expect(fetchCalls[2].init?.body).toBeInstanceOf(Blob);
|
|
162
129
|
});
|
|
163
130
|
|
|
164
131
|
test("passes an unquoted multi-word display name through to the shared resolver", async () => {
|
|
@@ -167,46 +134,6 @@ describe("cadet debug", () => {
|
|
|
167
134
|
expect(resolveTargetMock).toHaveBeenCalledWith("Support Bot");
|
|
168
135
|
});
|
|
169
136
|
|
|
170
|
-
test("refuses a daemon older than the debug profile before any URL is minted", async () => {
|
|
171
|
-
// Such a daemon strips `profile` and would upload the owner's
|
|
172
|
-
// credentials to the staff bucket.
|
|
173
|
-
identityMock.mockResolvedValue({ version: "0.12.2" });
|
|
174
|
-
const errors: string[] = [];
|
|
175
|
-
const errorSpy = spyOn(console, "error").mockImplementation((msg) => {
|
|
176
|
-
errors.push(String(msg));
|
|
177
|
-
});
|
|
178
|
-
try {
|
|
179
|
-
await expect(debug()).rejects.toThrow("process.exit:1");
|
|
180
|
-
} finally {
|
|
181
|
-
errorSpy.mockRestore();
|
|
182
|
-
}
|
|
183
|
-
expect(errors.join("\n")).toContain("0.12.3");
|
|
184
|
-
expect(fetchCalls).toHaveLength(0);
|
|
185
|
-
expect(exportMock).not.toHaveBeenCalled();
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
test("re-leases the guardian token when the daemon answers 401 mid-poll", async () => {
|
|
189
|
-
pollMock.mockReset();
|
|
190
|
-
let polls = 0;
|
|
191
|
-
pollMock.mockImplementation(async (_entry, token) => {
|
|
192
|
-
polls += 1;
|
|
193
|
-
if (polls === 1) {
|
|
194
|
-
throw new Error("Local job status check failed: 401 Unauthorized");
|
|
195
|
-
}
|
|
196
|
-
expect(token).toBe("leased-token");
|
|
197
|
-
return {
|
|
198
|
-
jobId: "job-1",
|
|
199
|
-
type: "export",
|
|
200
|
-
status: "complete",
|
|
201
|
-
};
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
await debug();
|
|
205
|
-
|
|
206
|
-
expect(leaseGuardianTokenSpy).toHaveBeenCalledTimes(1);
|
|
207
|
-
expect(polls).toBe(2);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
137
|
test("explains what to turn on when the cloud refuses", async () => {
|
|
211
138
|
uploadUrlStatus = 403;
|
|
212
139
|
const errors: string[] = [];
|
|
@@ -219,13 +146,12 @@ describe("cadet debug", () => {
|
|
|
219
146
|
errorSpy.mockRestore();
|
|
220
147
|
}
|
|
221
148
|
expect(errors.join("\n")).toContain("Allow Staff Access");
|
|
222
|
-
expect(exportMock).not.toHaveBeenCalled();
|
|
223
149
|
});
|
|
224
150
|
|
|
225
151
|
test("refuses a Cadet-hosted assistant, which needs no bundle", async () => {
|
|
226
152
|
resolveTargetMock.mockReturnValue({
|
|
227
153
|
...LOCAL_ENTRY,
|
|
228
|
-
cloud: "
|
|
154
|
+
cloud: "cloud",
|
|
229
155
|
});
|
|
230
156
|
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
231
157
|
try {
|
|
@@ -420,7 +420,7 @@ describe("cadet devices", () => {
|
|
|
420
420
|
expect(fetchCalls).toHaveLength(0);
|
|
421
421
|
});
|
|
422
422
|
|
|
423
|
-
test("host-gates a paired connection (points to the host
|
|
423
|
+
test("host-gates a paired connection (points to the host)", async () => {
|
|
424
424
|
saveAssistantEntry({
|
|
425
425
|
assistantId: "paired-box",
|
|
426
426
|
name: "Paired Box",
|
|
@@ -433,7 +433,7 @@ describe("cadet devices", () => {
|
|
|
433
433
|
const { exited, errors } = await runDevices();
|
|
434
434
|
|
|
435
435
|
expect(exited).toBe(true);
|
|
436
|
-
expect(errors).toContain("cadet
|
|
436
|
+
expect(errors).toContain("cadet devices");
|
|
437
437
|
expect(fetchCalls).toHaveLength(0);
|
|
438
438
|
});
|
|
439
439
|
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command-level tests for `cadet gateway token get/refresh`.
|
|
3
|
+
*
|
|
4
|
+
* Resolution: the assistant id is OPTIONAL — resolveTargetAssistant falls
|
|
5
|
+
* back to the active (or sole) assistant, so `cadet gateway token refresh`
|
|
6
|
+
* works with no arguments.
|
|
7
|
+
*
|
|
8
|
+
* Self-heal: a spent credential (401 — expired/revoked refresh token) or a
|
|
9
|
+
* missing token file (404) triggers an in-place re-provision via the
|
|
10
|
+
* gateway's bootstrap reset — the same machinery `cadet wake
|
|
11
|
+
* --repair-guardian` runs. It must NEVER suggest `cadet hatch`: hatching
|
|
12
|
+
* creates a different assistant.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
afterAll,
|
|
16
|
+
afterEach,
|
|
17
|
+
beforeEach,
|
|
18
|
+
describe,
|
|
19
|
+
expect,
|
|
20
|
+
spyOn,
|
|
21
|
+
test,
|
|
22
|
+
} from "bun:test";
|
|
23
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
|
|
27
|
+
import { gatewayToken } from "../commands/gateway/token.js";
|
|
28
|
+
import {
|
|
29
|
+
saveAssistantEntry,
|
|
30
|
+
setActiveAssistant,
|
|
31
|
+
} from "../lib/assistant-config.js";
|
|
32
|
+
import {
|
|
33
|
+
loadGuardianToken,
|
|
34
|
+
saveGuardianToken,
|
|
35
|
+
} from "../lib/guardian-token.js";
|
|
36
|
+
import { makeGuardianTokenData } from "./helpers/guardian-token.js";
|
|
37
|
+
import { withPreconnect } from "./helpers/http.js";
|
|
38
|
+
|
|
39
|
+
const ORIGINAL_LOCKFILE_DIR = process.env.CADET_LOCKFILE_DIR;
|
|
40
|
+
const ORIGINAL_CONFIG_HOME = process.env.XDG_CONFIG_HOME;
|
|
41
|
+
const ORIGINAL_ENVIRONMENT = process.env.CADET_ENVIRONMENT;
|
|
42
|
+
const ORIGINAL_ARGV = [...process.argv];
|
|
43
|
+
const REAL_FETCH = globalThis.fetch;
|
|
44
|
+
|
|
45
|
+
let tempHome: string;
|
|
46
|
+
|
|
47
|
+
function seedLocalEntry(overrides: Record<string, unknown> = {}): void {
|
|
48
|
+
saveAssistantEntry({
|
|
49
|
+
assistantId: "px",
|
|
50
|
+
name: "Prime Quail",
|
|
51
|
+
runtimeUrl: "http://127.0.0.1:7830",
|
|
52
|
+
localUrl: "http://127.0.0.1:7830",
|
|
53
|
+
cloud: "local",
|
|
54
|
+
paired: false,
|
|
55
|
+
...overrides,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Run `gatewayToken()` with console.log + console.error + process.exit
|
|
61
|
+
* spied; return the captured streams and whether the run exited 1.
|
|
62
|
+
*/
|
|
63
|
+
async function runGatewayToken(): Promise<{
|
|
64
|
+
exited: boolean;
|
|
65
|
+
logs: string;
|
|
66
|
+
errors: string;
|
|
67
|
+
}> {
|
|
68
|
+
const logs: string[] = [];
|
|
69
|
+
const errors: string[] = [];
|
|
70
|
+
const logSpy = spyOn(console, "log").mockImplementation(
|
|
71
|
+
(...a: unknown[]) => {
|
|
72
|
+
logs.push(a.join(" "));
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
const errSpy = spyOn(console, "error").mockImplementation(
|
|
76
|
+
(...a: unknown[]) => {
|
|
77
|
+
errors.push(a.join(" "));
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(
|
|
81
|
+
(code?: number | string | undefined): never => {
|
|
82
|
+
throw new Error(`exit:${code ?? 0}`);
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
let exited = false;
|
|
86
|
+
try {
|
|
87
|
+
await gatewayToken();
|
|
88
|
+
} catch (e) {
|
|
89
|
+
exited = e instanceof Error && e.message === "exit:1";
|
|
90
|
+
} finally {
|
|
91
|
+
logSpy.mockRestore();
|
|
92
|
+
errSpy.mockRestore();
|
|
93
|
+
exitSpy.mockRestore();
|
|
94
|
+
}
|
|
95
|
+
return { exited, logs: logs.join("\n"), errors: errors.join("\n") };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Fetch double covering the repair choreography (readyz/reset/init). */
|
|
99
|
+
function mockRepairFetch(initBody: object): void {
|
|
100
|
+
globalThis.fetch = withPreconnect(async (input: RequestInfo | URL) => {
|
|
101
|
+
const url = String(input);
|
|
102
|
+
if (url.endsWith("/readyz")) {
|
|
103
|
+
return new Response("", { status: 404 }); // no starting gate — proceed
|
|
104
|
+
}
|
|
105
|
+
if (url.endsWith("/v1/guardian/reset-bootstrap")) {
|
|
106
|
+
return new Response("{}", { status: 200 });
|
|
107
|
+
}
|
|
108
|
+
if (url.endsWith("/v1/guardian/init")) {
|
|
109
|
+
return new Response(JSON.stringify(initBody), {
|
|
110
|
+
status: 200,
|
|
111
|
+
headers: { "content-type": "application/json" },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
throw new Error(`unexpected fetch: ${url}`);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const FRESH_LEASE = {
|
|
119
|
+
guardianPrincipalId: "principal-new",
|
|
120
|
+
accessToken: "fresh-acc",
|
|
121
|
+
accessTokenExpiresAt: Date.now() + 60 * 60 * 1000,
|
|
122
|
+
refreshToken: "fresh-ref",
|
|
123
|
+
refreshTokenExpiresAt: Date.now() + 365 * 24 * 60 * 60 * 1000,
|
|
124
|
+
refreshAfter: Date.now() + 48 * 60 * 60 * 1000,
|
|
125
|
+
isNew: false,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
describe("gateway token command", () => {
|
|
129
|
+
beforeEach(() => {
|
|
130
|
+
tempHome = mkdtempSync(join(tmpdir(), "gateway-token-test-"));
|
|
131
|
+
process.env.CADET_LOCKFILE_DIR = tempHome;
|
|
132
|
+
process.env.XDG_CONFIG_HOME = tempHome;
|
|
133
|
+
delete process.env.CADET_ENVIRONMENT;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
process.argv = [...ORIGINAL_ARGV];
|
|
138
|
+
globalThis.fetch = REAL_FETCH;
|
|
139
|
+
if (ORIGINAL_LOCKFILE_DIR === undefined)
|
|
140
|
+
delete process.env.CADET_LOCKFILE_DIR;
|
|
141
|
+
else process.env.CADET_LOCKFILE_DIR = ORIGINAL_LOCKFILE_DIR;
|
|
142
|
+
if (ORIGINAL_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME;
|
|
143
|
+
else process.env.XDG_CONFIG_HOME = ORIGINAL_CONFIG_HOME;
|
|
144
|
+
if (ORIGINAL_ENVIRONMENT === undefined) delete process.env.CADET_ENVIRONMENT;
|
|
145
|
+
else process.env.CADET_ENVIRONMENT = ORIGINAL_ENVIRONMENT;
|
|
146
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
afterAll(() => {
|
|
150
|
+
globalThis.fetch = REAL_FETCH;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("refresh without a name targets the sole assistant and prints the refreshed token", async () => {
|
|
154
|
+
seedLocalEntry();
|
|
155
|
+
saveGuardianToken("px", makeGuardianTokenData());
|
|
156
|
+
let sawRefresh = false;
|
|
157
|
+
globalThis.fetch = withPreconnect(async (input: RequestInfo | URL) => {
|
|
158
|
+
const url = String(input);
|
|
159
|
+
if (url.endsWith("/v1/guardian/refresh")) {
|
|
160
|
+
sawRefresh = true;
|
|
161
|
+
return new Response(JSON.stringify({ accessToken: "new-acc" }), {
|
|
162
|
+
status: 200,
|
|
163
|
+
headers: { "content-type": "application/json" },
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
throw new Error(`unexpected fetch: ${url}`);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
process.argv = ["bun", "cadet", "gateway", "token", "refresh"];
|
|
170
|
+
const { exited, logs, errors } = await runGatewayToken();
|
|
171
|
+
|
|
172
|
+
expect(sawRefresh).toBe(true);
|
|
173
|
+
expect(exited).toBe(false);
|
|
174
|
+
expect(logs).toContain("new-acc");
|
|
175
|
+
expect(loadGuardianToken("px")?.accessToken).toBe("new-acc");
|
|
176
|
+
expect(`${logs}\n${errors}`).not.toContain("hatch");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("get without a name targets the active assistant", async () => {
|
|
180
|
+
seedLocalEntry();
|
|
181
|
+
saveAssistantEntry({
|
|
182
|
+
assistantId: "py",
|
|
183
|
+
name: "Other Box",
|
|
184
|
+
runtimeUrl: "http://127.0.0.1:7831",
|
|
185
|
+
localUrl: "http://127.0.0.1:7831",
|
|
186
|
+
cloud: "local",
|
|
187
|
+
paired: false,
|
|
188
|
+
});
|
|
189
|
+
setActiveAssistant("py");
|
|
190
|
+
saveGuardianToken(
|
|
191
|
+
"py",
|
|
192
|
+
makeGuardianTokenData({ accessToken: "active-token" }),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
process.argv = ["bun", "cadet", "gateway", "token", "get"];
|
|
196
|
+
const { exited, logs } = await runGatewayToken();
|
|
197
|
+
|
|
198
|
+
expect(exited).toBe(false);
|
|
199
|
+
expect(logs).toContain("active-token");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("an unknown name is a strict-match error", async () => {
|
|
203
|
+
seedLocalEntry();
|
|
204
|
+
saveGuardianToken("px", makeGuardianTokenData());
|
|
205
|
+
|
|
206
|
+
process.argv = ["bun", "cadet", "gateway", "token", "get", "nope"];
|
|
207
|
+
const { exited, errors } = await runGatewayToken();
|
|
208
|
+
|
|
209
|
+
expect(exited).toBe(true);
|
|
210
|
+
expect(errors).toContain("No assistant found with name or ID 'nope'");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("refresh self-heals an expired credential via the bootstrap reset", async () => {
|
|
214
|
+
seedLocalEntry({ guardianBootstrapSecret: "boot-secret-1" });
|
|
215
|
+
saveGuardianToken(
|
|
216
|
+
"px",
|
|
217
|
+
makeGuardianTokenData({
|
|
218
|
+
refreshTokenExpiresAt: new Date(Date.now() - 1000).toISOString(),
|
|
219
|
+
}),
|
|
220
|
+
);
|
|
221
|
+
mockRepairFetch(FRESH_LEASE);
|
|
222
|
+
|
|
223
|
+
process.argv = ["bun", "cadet", "gateway", "token", "refresh"];
|
|
224
|
+
const { exited, logs, errors } = await runGatewayToken();
|
|
225
|
+
|
|
226
|
+
expect(exited).toBe(false);
|
|
227
|
+
expect(errors).toContain(
|
|
228
|
+
"Re-provisioned guardian token (this revoked other devices' paired tokens).",
|
|
229
|
+
);
|
|
230
|
+
expect(logs).toContain("fresh-acc");
|
|
231
|
+
expect(loadGuardianToken("px")?.accessToken).toBe("fresh-acc");
|
|
232
|
+
expect(`${logs}\n${errors}`).not.toContain("hatch");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("refresh reports repair failure and never suggests hatching", async () => {
|
|
236
|
+
seedLocalEntry({ guardianBootstrapSecret: "boot-secret-1" });
|
|
237
|
+
saveGuardianToken(
|
|
238
|
+
"px",
|
|
239
|
+
makeGuardianTokenData({
|
|
240
|
+
refreshTokenExpiresAt: new Date(Date.now() - 1000).toISOString(),
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
// Malformed 200 lease → GuardianLeasePermanentError → no retries.
|
|
244
|
+
mockRepairFetch({});
|
|
245
|
+
|
|
246
|
+
process.argv = ["bun", "cadet", "gateway", "token", "refresh"];
|
|
247
|
+
const { exited, logs, errors } = await runGatewayToken();
|
|
248
|
+
|
|
249
|
+
expect(exited).toBe(true);
|
|
250
|
+
expect(errors).toContain("Guardian token could not be re-provisioned.");
|
|
251
|
+
expect(errors).toContain("--repair-guardian");
|
|
252
|
+
expect(errors).toContain("CADET_REFRESH_ERROR=");
|
|
253
|
+
expect(`${logs}\n${errors}`).not.toContain("hatch");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("refresh re-provisions when the token file is missing entirely", async () => {
|
|
257
|
+
seedLocalEntry({ guardianBootstrapSecret: "boot-secret-1" });
|
|
258
|
+
mockRepairFetch(FRESH_LEASE);
|
|
259
|
+
|
|
260
|
+
process.argv = ["bun", "cadet", "gateway", "token", "refresh"];
|
|
261
|
+
const { exited, logs } = await runGatewayToken();
|
|
262
|
+
|
|
263
|
+
expect(exited).toBe(false);
|
|
264
|
+
expect(logs).toContain("fresh-acc");
|
|
265
|
+
expect(loadGuardianToken("px")?.accessToken).toBe("fresh-acc");
|
|
266
|
+
});
|
|
267
|
+
});
|