@mycadet/cli 0.12.6-dev.202610082233.54244ee → 0.12.7
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/eslint.config.mjs +7 -0
- package/node_modules/@mycadet/local-mode/src/__tests__/environment.test.ts +1 -4
- package/node_modules/@mycadet/local-mode/src/config.ts +14 -19
- package/node_modules/@mycadet/local-mode/src/index.ts +5 -0
- package/node_modules/@mycadet/local-mode/src/local-signing-key.ts +73 -0
- package/node_modules/@mycadet/local-mode/src/lockfile.ts +18 -3
- package/package.json +4 -3
- package/src/__tests__/assistant-config.test.ts +30 -28
- package/src/__tests__/debug.test.ts +8 -8
- package/src/__tests__/default-main-screen-formatting.test.ts +5 -4
- package/src/__tests__/flags.test.ts +2 -2
- package/src/__tests__/hatch-provider-secrets.test.ts +3 -3
- package/src/__tests__/ingress-config.test.ts +2 -2
- package/src/__tests__/login-loopback.test.ts +1 -1
- package/src/__tests__/nginx-ingress.test.ts +68 -76
- package/src/__tests__/pair.test.ts +135 -121
- package/src/__tests__/platform-client.test.ts +2 -2
- package/src/__tests__/recover.test.ts +96 -3
- package/src/__tests__/retire.test.ts +5 -3
- package/src/__tests__/signing-key-continuity.test.ts +261 -0
- package/src/__tests__/sleep.test.ts +189 -6
- package/src/__tests__/tailscale-tunnel.test.ts +1 -1
- package/src/__tests__/uninstall.test.ts +171 -0
- package/src/__tests__/upgrade-local.test.ts +16 -3
- package/src/__tests__/use.test.ts +2 -2
- package/src/__tests__/wake-error.test.ts +76 -0
- package/src/__tests__/wake.test.ts +309 -36
- package/src/commands/backup.ts +6 -2
- package/src/commands/clean.ts +6 -2
- package/src/commands/client.ts +91 -41
- package/src/commands/confirm.ts +6 -2
- package/src/commands/connect.ts +6 -2
- package/src/commands/debug.ts +6 -2
- package/src/commands/devices.ts +6 -2
- package/src/commands/events.ts +6 -2
- package/src/commands/exec.ts +50 -12
- package/src/commands/flags.ts +6 -2
- package/src/commands/gateway.ts +6 -2
- package/src/commands/hatch.ts +6 -2
- package/src/commands/login.ts +26 -356
- package/src/commands/logs.ts +6 -2
- package/src/commands/message.ts +6 -2
- package/src/commands/pair.ts +6 -2
- package/src/commands/ps.ts +7 -2
- package/src/commands/recover.ts +21 -16
- package/src/commands/restore.ts +6 -2
- package/src/commands/retire.ts +6 -2
- package/src/commands/roadmap.ts +6 -2
- package/src/commands/rollback.ts +6 -2
- package/src/commands/setup.ts +76 -9
- package/src/commands/sleep.ts +131 -31
- package/src/commands/ssh.ts +6 -2
- package/src/commands/teleport.ts +6 -2
- package/src/commands/terminal.ts +6 -2
- package/src/commands/tunnel.ts +6 -2
- package/src/commands/uninstall.ts +228 -0
- package/src/commands/unpair.ts +6 -2
- package/src/commands/upgrade.ts +13 -8
- package/src/commands/use.ts +6 -2
- package/src/commands/voice.ts +6 -2
- package/src/commands/wake.ts +69 -37
- package/src/commands/workflows.ts +6 -2
- package/src/components/DefaultMainScreen.tsx +250 -36
- package/src/index.ts +23 -55
- package/src/lib/assistant-config.ts +27 -13
- package/src/lib/backup-ops.ts +3 -4
- package/src/lib/cli-error.ts +5 -0
- package/src/lib/client-identity.ts +8 -10
- package/src/lib/cloud-login.ts +422 -0
- package/src/lib/color.ts +16 -0
- package/src/lib/default-main-screen-formatting.ts +32 -8
- package/src/lib/environments/__tests__/paths.test.ts +9 -11
- package/src/lib/environments/paths.ts +2 -20
- package/src/lib/guardian-token.ts +72 -18
- package/src/lib/hatch-local.ts +52 -1
- package/src/lib/health-check.ts +24 -15
- package/src/lib/lifecycle-reporter.ts +9 -1
- package/src/lib/local-hatch-lock.ts +19 -27
- package/src/lib/local-runtime-client.ts +25 -14
- package/src/lib/local.ts +17 -43
- package/src/lib/ngrok.ts +48 -16
- package/src/lib/platform-client.ts +141 -72
- package/src/lib/retire-archive.ts +4 -4
- package/src/lib/retire-local.ts +2 -25
- package/src/lib/teleport-backup.ts +23 -9
- package/src/lib/terminal-client.ts +21 -9
- package/src/lib/tunnel-edge.ts +11 -13
- package/src/lib/upgrade-lifecycle.ts +16 -4
- package/src/lib/workos-pkce.ts +52 -21
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveLockfilePaths } from "@mycadet/local-mode";
|
|
1
2
|
import {
|
|
2
3
|
afterAll,
|
|
3
4
|
afterEach,
|
|
@@ -14,9 +15,11 @@ import {
|
|
|
14
15
|
mkdirSync,
|
|
15
16
|
mkdtempSync,
|
|
16
17
|
rmSync,
|
|
18
|
+
readFileSync,
|
|
17
19
|
writeFileSync,
|
|
18
20
|
} from "node:fs";
|
|
19
|
-
import { tmpdir } from "node:os";
|
|
21
|
+
import { homedir, tmpdir } from "node:os";
|
|
22
|
+
import * as osModule from "node:os";
|
|
20
23
|
import { basename, join } from "node:path";
|
|
21
24
|
|
|
22
25
|
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
@@ -35,7 +38,9 @@ const realExec = stepRunnerModule.exec;
|
|
|
35
38
|
|
|
36
39
|
// Prevent real daemon / gateway / CES from starting
|
|
37
40
|
const startLocalDaemonMock = mock(async () => {});
|
|
38
|
-
const startGatewayMock = mock
|
|
41
|
+
const startGatewayMock = mock<typeof localModule.startGateway>(
|
|
42
|
+
async () => "http://127.0.0.1:7831",
|
|
43
|
+
);
|
|
39
44
|
const startCesMock = mock(async () => {});
|
|
40
45
|
|
|
41
46
|
// Capture exec calls without running real tar
|
|
@@ -73,12 +78,14 @@ function makeEntry(assistantId: string, instanceDir: string): AssistantEntry {
|
|
|
73
78
|
assistantId,
|
|
74
79
|
runtimeUrl: "http://127.0.0.1:7831",
|
|
75
80
|
cloud: "local",
|
|
81
|
+
guardianBootstrapSecret: "existing-bootstrap",
|
|
76
82
|
resources: {
|
|
77
83
|
instanceDir,
|
|
78
84
|
daemonPort: 7801,
|
|
79
85
|
gatewayPort: 7831,
|
|
80
86
|
qdrantPort: 6334,
|
|
81
87
|
cesPort: 7790,
|
|
88
|
+
signingKey: "ab".repeat(32),
|
|
82
89
|
},
|
|
83
90
|
};
|
|
84
91
|
}
|
|
@@ -106,18 +113,21 @@ function writeArchiveFixtures(
|
|
|
106
113
|
return { archivePath, metadataPath, extractedPath };
|
|
107
114
|
}
|
|
108
115
|
|
|
116
|
+
let homeSpy: ReturnType<typeof spyOn>;
|
|
109
117
|
let consoleLogSpy: ReturnType<typeof spyOn>;
|
|
110
118
|
let consoleErrorSpy: ReturnType<typeof spyOn>;
|
|
111
119
|
let exitSpy: ReturnType<typeof spyOn>;
|
|
112
120
|
|
|
113
121
|
beforeEach(() => {
|
|
122
|
+
homeSpy = spyOn(osModule, "homedir").mockReturnValue(join(testDir, "home"));
|
|
123
|
+
mkdirSync(homedir(), { recursive: true });
|
|
114
124
|
// Route lockfile and retired archives to the temp directory
|
|
115
125
|
process.env.CADET_LOCKFILE_DIR = testDir;
|
|
116
126
|
process.env.XDG_DATA_HOME = testDir;
|
|
117
127
|
// Write an empty lockfile so saveAssistantEntry has a dir to write to
|
|
118
128
|
mkdirSync(testDir, { recursive: true });
|
|
119
129
|
writeFileSync(
|
|
120
|
-
|
|
130
|
+
resolveLockfilePaths(process.env)[0]!,
|
|
121
131
|
JSON.stringify({ assistants: [] }) + "\n",
|
|
122
132
|
);
|
|
123
133
|
|
|
@@ -134,6 +144,7 @@ beforeEach(() => {
|
|
|
134
144
|
});
|
|
135
145
|
|
|
136
146
|
afterEach(() => {
|
|
147
|
+
homeSpy.mockRestore();
|
|
137
148
|
process.argv = [...originalArgv];
|
|
138
149
|
process.env.CADET_LOCKFILE_DIR = originalLockfileDir;
|
|
139
150
|
process.env.XDG_DATA_HOME = originalXdgData;
|
|
@@ -176,6 +187,83 @@ describe("recover --help", () => {
|
|
|
176
187
|
});
|
|
177
188
|
|
|
178
189
|
describe("recover error cases", () => {
|
|
190
|
+
for (const failure of ["missing", "conflict"] as const) {
|
|
191
|
+
test(`recovery ${failure} leaves destination and registry untouched and permits retry`, async () => {
|
|
192
|
+
const name = `retry-${failure}`;
|
|
193
|
+
const instanceDir = join(testDir, "cadet", "assistants", name);
|
|
194
|
+
const entry = makeEntry(name, instanceDir);
|
|
195
|
+
delete entry.resources!.signingKey;
|
|
196
|
+
const { archivePath, metadataPath, extractedPath } = writeArchiveFixtures(
|
|
197
|
+
name,
|
|
198
|
+
entry,
|
|
199
|
+
);
|
|
200
|
+
if (failure === "conflict") {
|
|
201
|
+
for (const [dir, hex] of [
|
|
202
|
+
["gateway-security", "ab"],
|
|
203
|
+
["workspace/deprecated", "cd"],
|
|
204
|
+
]) {
|
|
205
|
+
mkdirSync(join(extractedPath, dir!), { recursive: true });
|
|
206
|
+
writeFileSync(
|
|
207
|
+
join(extractedPath, dir!, "actor-token-signing-key"),
|
|
208
|
+
Buffer.from(hex!.repeat(32), "hex"),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const registry = resolveLockfilePaths(process.env)[0]!;
|
|
213
|
+
const before = readFileSync(registry, "utf8");
|
|
214
|
+
process.argv = ["bun", "cadet", "recover", name];
|
|
215
|
+
await expect(recover()).rejects.toThrow(`signing key ${failure}`);
|
|
216
|
+
expect(existsSync(instanceDir)).toBe(false);
|
|
217
|
+
expect(readFileSync(registry, "utf8")).toBe(before);
|
|
218
|
+
expect(existsSync(archivePath)).toBe(true);
|
|
219
|
+
expect(existsSync(metadataPath)).toBe(true);
|
|
220
|
+
expect(startLocalDaemonMock).not.toHaveBeenCalled();
|
|
221
|
+
expect(startGatewayMock).not.toHaveBeenCalled();
|
|
222
|
+
// Restore the authoritative identity in the archive metadata and retry.
|
|
223
|
+
entry.resources!.signingKey = "ab".repeat(32);
|
|
224
|
+
writeFileSync(metadataPath, JSON.stringify(entry));
|
|
225
|
+
await recover();
|
|
226
|
+
expect(existsSync(instanceDir)).toBe(true);
|
|
227
|
+
expect(existsSync(archivePath)).toBe(false);
|
|
228
|
+
expect(startGatewayMock).toHaveBeenCalledTimes(1);
|
|
229
|
+
rmSync(instanceDir, { recursive: true, force: true });
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
test("recovers an archived legacy identity from staging", async () => {
|
|
234
|
+
const name = "legacy-staged";
|
|
235
|
+
const entry = makeEntry(name, join(testDir, "cadet", "assistants", name));
|
|
236
|
+
delete entry.resources!.signingKey;
|
|
237
|
+
const { extractedPath } = writeArchiveFixtures(name, entry);
|
|
238
|
+
const dir = join(extractedPath, "workspace", "deprecated");
|
|
239
|
+
mkdirSync(dir, { recursive: true });
|
|
240
|
+
writeFileSync(
|
|
241
|
+
join(dir, "actor-token-signing-key"),
|
|
242
|
+
Buffer.from("cd".repeat(32), "hex"),
|
|
243
|
+
);
|
|
244
|
+
process.argv = ["bun", "cadet", "recover", name];
|
|
245
|
+
await recover();
|
|
246
|
+
expect(startGatewayMock).toHaveBeenCalledWith(
|
|
247
|
+
false,
|
|
248
|
+
expect.objectContaining({ signingKey: "cd".repeat(32) }),
|
|
249
|
+
{ signingKey: "cd".repeat(32), bootstrapSecret: "existing-bootstrap" },
|
|
250
|
+
);
|
|
251
|
+
rmSync(entry.resources!.instanceDir, { recursive: true, force: true });
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("missing identity preserves the archive and starts no processes", async () => {
|
|
255
|
+
const name = "missing-key";
|
|
256
|
+
const entry = makeEntry(name, join(testDir, "cadet", "assistants", name));
|
|
257
|
+
delete entry.resources!.signingKey;
|
|
258
|
+
const { archivePath, metadataPath } = writeArchiveFixtures(name, entry);
|
|
259
|
+
process.argv = ["bun", "cadet", "recover", name];
|
|
260
|
+
await expect(recover()).rejects.toThrow("signing key missing");
|
|
261
|
+
expect(existsSync(archivePath)).toBe(true);
|
|
262
|
+
expect(existsSync(metadataPath)).toBe(true);
|
|
263
|
+
expect(startLocalDaemonMock).not.toHaveBeenCalled();
|
|
264
|
+
expect(startGatewayMock).not.toHaveBeenCalled();
|
|
265
|
+
});
|
|
266
|
+
|
|
179
267
|
test("exits 1 when no name is given", async () => {
|
|
180
268
|
process.argv = ["bun", "cadet", "recover"];
|
|
181
269
|
await expect(recover()).rejects.toThrow("process.exit(1)");
|
|
@@ -253,6 +341,11 @@ describe("recover extraction path — named instance (instanceDir !== homedir())
|
|
|
253
341
|
// Daemon and gateway were started
|
|
254
342
|
expect(startLocalDaemonMock).toHaveBeenCalledTimes(1);
|
|
255
343
|
expect(startGatewayMock).toHaveBeenCalledTimes(1);
|
|
344
|
+
expect(startGatewayMock.mock.calls[0]).toEqual([
|
|
345
|
+
false,
|
|
346
|
+
expect.objectContaining({ signingKey: "ab".repeat(32) }),
|
|
347
|
+
{ signingKey: "ab".repeat(32), bootstrapSecret: "existing-bootstrap" },
|
|
348
|
+
]);
|
|
256
349
|
});
|
|
257
350
|
|
|
258
351
|
test("creates parent directories of instanceDir when they do not exist", async () => {
|
|
@@ -111,13 +111,13 @@ function makeEntry(
|
|
|
111
111
|
function writeLockfile(entries: AssistantEntry[]): void {
|
|
112
112
|
mkdirSync(testDir, { recursive: true });
|
|
113
113
|
writeFileSync(
|
|
114
|
-
join(testDir, ".
|
|
114
|
+
join(testDir, "lockfile.json"),
|
|
115
115
|
JSON.stringify({ assistants: entries }, null, 2) + "\n",
|
|
116
116
|
);
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
function readLockfile(): string {
|
|
120
|
-
return readFileSync(join(testDir, ".
|
|
120
|
+
return readFileSync(join(testDir, "lockfile.json"), "utf-8");
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
function setTerminalMode(isTTY: boolean): void {
|
|
@@ -168,7 +168,9 @@ describe("cadet retire", () => {
|
|
|
168
168
|
beforeEach(() => {
|
|
169
169
|
process.env.CADET_LOCKFILE_DIR = testDir;
|
|
170
170
|
delete process.env.CADET_PLATFORM_TOKEN;
|
|
171
|
-
|
|
171
|
+
for (const name of ["lockfile.json", ".cadet.lock.json", ".cadet.lockfile.json"]) {
|
|
172
|
+
rmSync(join(testDir, name), { force: true });
|
|
173
|
+
}
|
|
172
174
|
process.argv = ["bun", "cadet", "retire"];
|
|
173
175
|
process.exit = ((code?: number) => {
|
|
174
176
|
throw new Error(`process.exit:${code}`);
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createHmac } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
getLockfileData,
|
|
16
|
+
resolveExistingLocalSigningKey,
|
|
17
|
+
upsertRendererLockfileAssistant,
|
|
18
|
+
} from "@mycadet/local-mode";
|
|
19
|
+
|
|
20
|
+
// wake, upgrade and recover resolve the existing signing identity through
|
|
21
|
+
// @mycadet/local-mode before touching any process. These tests exercise that
|
|
22
|
+
// contract the way the CLI consumes it: host lockfile entries, legacy on-disk
|
|
23
|
+
// key files, and a renderer save in between.
|
|
24
|
+
describe("local signing identity continuity", () => {
|
|
25
|
+
let instanceDir: string;
|
|
26
|
+
const key = "ab".repeat(32);
|
|
27
|
+
const other = "cd".repeat(32);
|
|
28
|
+
const legacyPaths = () => [
|
|
29
|
+
join(instanceDir, "gateway-security", "actor-token-signing-key"),
|
|
30
|
+
join(instanceDir, "workspace", "deprecated", "actor-token-signing-key"),
|
|
31
|
+
];
|
|
32
|
+
function put(file: string, hex: string) {
|
|
33
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
34
|
+
writeFileSync(file, Buffer.from(hex, "hex"), { mode: 0o600 });
|
|
35
|
+
}
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
instanceDir = mkdtempSync(join(tmpdir(), "signing-continuity-"));
|
|
38
|
+
});
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
rmSync(instanceDir, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("resolveExistingLocalSigningKey", () => {
|
|
44
|
+
test("loads the persisted key without creating disk copies", () => {
|
|
45
|
+
expect(
|
|
46
|
+
resolveExistingLocalSigningKey({
|
|
47
|
+
instanceDir,
|
|
48
|
+
signingKey: key.toUpperCase(),
|
|
49
|
+
}),
|
|
50
|
+
).toBe(key);
|
|
51
|
+
for (const file of legacyPaths()) {
|
|
52
|
+
expect(existsSync(file)).toBe(false);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
for (const index of [0, 1]) {
|
|
56
|
+
test(`recovers legacy source ${index} without rewriting it`, () => {
|
|
57
|
+
put(legacyPaths()[index]!, key);
|
|
58
|
+
expect(resolveExistingLocalSigningKey({ instanceDir })).toBe(key);
|
|
59
|
+
expect(readFileSync(legacyPaths()[index]!)).toEqual(
|
|
60
|
+
Buffer.from(key, "hex"),
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
test("matching legacy sources converge", () => {
|
|
65
|
+
for (const file of legacyPaths()) {
|
|
66
|
+
put(file, key);
|
|
67
|
+
}
|
|
68
|
+
expect(
|
|
69
|
+
resolveExistingLocalSigningKey({ instanceDir, signingKey: key }),
|
|
70
|
+
).toBe(key);
|
|
71
|
+
});
|
|
72
|
+
test("missing state refuses to create an identity", () => {
|
|
73
|
+
expect(() => resolveExistingLocalSigningKey({ instanceDir })).toThrow(
|
|
74
|
+
"signing key missing",
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
test("malformed persisted state cannot fall back to a different source", () => {
|
|
78
|
+
put(legacyPaths()[1]!, key);
|
|
79
|
+
for (const invalid of ["", "short", null, 123, "z".repeat(64)]) {
|
|
80
|
+
expect(() =>
|
|
81
|
+
resolveExistingLocalSigningKey({ instanceDir, signingKey: invalid }),
|
|
82
|
+
).toThrow("signing key invalid");
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
test("the host key remains authoritative over stale legacy copies after repair", () => {
|
|
86
|
+
put(legacyPaths()[1]!, other);
|
|
87
|
+
expect(
|
|
88
|
+
resolveExistingLocalSigningKey({ instanceDir, signingKey: key }),
|
|
89
|
+
).toBe(key);
|
|
90
|
+
expect(readFileSync(legacyPaths()[1]!)).toEqual(
|
|
91
|
+
Buffer.from(other, "hex"),
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
test("conflicting legacy copies refuse even without a persisted key", () => {
|
|
95
|
+
put(legacyPaths()[0]!, key);
|
|
96
|
+
put(legacyPaths()[1]!, other);
|
|
97
|
+
expect(() => resolveExistingLocalSigningKey({ instanceDir })).toThrow(
|
|
98
|
+
"signing key conflict",
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
test("malformed or unreadable legacy storage is not absence", () => {
|
|
102
|
+
put(legacyPaths()[0]!, "ab");
|
|
103
|
+
expect(() => resolveExistingLocalSigningKey({ instanceDir })).toThrow(
|
|
104
|
+
"signing key invalid",
|
|
105
|
+
);
|
|
106
|
+
rmSync(legacyPaths()[0]!);
|
|
107
|
+
mkdirSync(legacyPaths()[0]!);
|
|
108
|
+
expect(() => resolveExistingLocalSigningKey({ instanceDir })).toThrow(
|
|
109
|
+
/signing key (invalid|unreadable)/,
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
test("a foreign instance cannot supply a key", () => {
|
|
113
|
+
put(legacyPaths()[1]!, key);
|
|
114
|
+
expect(() =>
|
|
115
|
+
resolveExistingLocalSigningKey({
|
|
116
|
+
instanceDir: join(instanceDir, "other"),
|
|
117
|
+
}),
|
|
118
|
+
).toThrow("signing key missing");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("renderer saves preserve host identity", () => {
|
|
123
|
+
const lockfilePath = () => join(instanceDir, "lockfile.json");
|
|
124
|
+
function writeOnDisk(value: unknown): void {
|
|
125
|
+
writeFileSync(lockfilePath(), JSON.stringify(value, null, 2));
|
|
126
|
+
}
|
|
127
|
+
function readOnDisk(): Record<string, unknown> {
|
|
128
|
+
return JSON.parse(readFileSync(lockfilePath(), "utf-8")) as Record<
|
|
129
|
+
string,
|
|
130
|
+
unknown
|
|
131
|
+
>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
test("a renderer read/select/save preserves host resources and credentials", () => {
|
|
135
|
+
const entry = {
|
|
136
|
+
assistantId: "local-1",
|
|
137
|
+
cloud: "local",
|
|
138
|
+
name: "Example Assistant",
|
|
139
|
+
runtimeUrl: "http://127.0.0.1:7830",
|
|
140
|
+
guardianBootstrapSecret: "bootstrap-secret",
|
|
141
|
+
resources: {
|
|
142
|
+
instanceDir: "/example/instance",
|
|
143
|
+
gatewayPort: 7830,
|
|
144
|
+
daemonPort: 7831,
|
|
145
|
+
signingKey: key,
|
|
146
|
+
cesPort: 7832,
|
|
147
|
+
futureHostField: "keep",
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
writeOnDisk({ assistants: [entry], activeAssistant: null });
|
|
151
|
+
const read = getLockfileData([lockfilePath()]);
|
|
152
|
+
expect(read.ok).toBe(true);
|
|
153
|
+
if (!read.ok) {
|
|
154
|
+
throw new Error("fixture read failed");
|
|
155
|
+
}
|
|
156
|
+
const view = read.data.assistants[0]!;
|
|
157
|
+
expect(view.resources).not.toHaveProperty("signingKey");
|
|
158
|
+
const result = upsertRendererLockfileAssistant(
|
|
159
|
+
[lockfilePath()],
|
|
160
|
+
{ ...view },
|
|
161
|
+
"local-1",
|
|
162
|
+
);
|
|
163
|
+
expect(result.ok).toBe(true);
|
|
164
|
+
expect(readOnDisk()).toEqual({
|
|
165
|
+
assistants: [entry],
|
|
166
|
+
activeAssistant: "local-1",
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("stale or forged renderer resources cannot replace host state", () => {
|
|
171
|
+
const entry = {
|
|
172
|
+
assistantId: "local-1",
|
|
173
|
+
cloud: "local",
|
|
174
|
+
guardianBootstrapSecret: "keep",
|
|
175
|
+
resources: {
|
|
176
|
+
instanceDir: "/example/current",
|
|
177
|
+
signingKey: key,
|
|
178
|
+
gatewayPort: 7830,
|
|
179
|
+
daemonPort: 7831,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
writeOnDisk({ assistants: [entry], activeAssistant: null });
|
|
183
|
+
const result = upsertRendererLockfileAssistant(
|
|
184
|
+
[lockfilePath()],
|
|
185
|
+
{
|
|
186
|
+
assistantId: "local-1",
|
|
187
|
+
name: "Updated",
|
|
188
|
+
guardianBootstrapSecret: "replace",
|
|
189
|
+
resources: {
|
|
190
|
+
instanceDir: "/example/stale",
|
|
191
|
+
signingKey: other,
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
undefined,
|
|
195
|
+
);
|
|
196
|
+
expect(result.ok).toBe(true);
|
|
197
|
+
expect(readOnDisk().assistants).toEqual([{ ...entry, name: "Updated" }]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("a renderer save refuses an unreadable registry without replacing it", () => {
|
|
201
|
+
writeFileSync(lockfilePath(), "{ invalid");
|
|
202
|
+
expect(
|
|
203
|
+
upsertRendererLockfileAssistant(
|
|
204
|
+
[lockfilePath()],
|
|
205
|
+
{ assistantId: "local-1" },
|
|
206
|
+
"local-1",
|
|
207
|
+
),
|
|
208
|
+
).toMatchObject({ ok: false, status: 409 });
|
|
209
|
+
expect(readFileSync(lockfilePath(), "utf-8")).toBe("{ invalid");
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("across desktop saves and wake cycles", () => {
|
|
214
|
+
test("read/select/save and repeated key loads preserve a retained credential", () => {
|
|
215
|
+
const registry = join(instanceDir, "registry.json");
|
|
216
|
+
const payload = "synthetic-retained-credential";
|
|
217
|
+
const signature = createHmac("sha256", Buffer.from(key, "hex"))
|
|
218
|
+
.update(payload)
|
|
219
|
+
.digest("hex");
|
|
220
|
+
writeFileSync(
|
|
221
|
+
registry,
|
|
222
|
+
JSON.stringify({
|
|
223
|
+
assistants: [
|
|
224
|
+
{
|
|
225
|
+
assistantId: "example",
|
|
226
|
+
cloud: "local",
|
|
227
|
+
resources: {
|
|
228
|
+
instanceDir,
|
|
229
|
+
gatewayPort: 7830,
|
|
230
|
+
daemonPort: 7831,
|
|
231
|
+
signingKey: key,
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
for (let cycle = 0; cycle < 3; cycle++) {
|
|
238
|
+
const view = getLockfileData([registry]);
|
|
239
|
+
if (!view.ok) {
|
|
240
|
+
throw new Error("fixture read failed");
|
|
241
|
+
}
|
|
242
|
+
expect(
|
|
243
|
+
upsertRendererLockfileAssistant(
|
|
244
|
+
[registry],
|
|
245
|
+
{ ...view.data.assistants[0]! },
|
|
246
|
+
"example",
|
|
247
|
+
).ok,
|
|
248
|
+
).toBe(true);
|
|
249
|
+
const stored = JSON.parse(readFileSync(registry, "utf8"));
|
|
250
|
+
const restored = resolveExistingLocalSigningKey(
|
|
251
|
+
stored.assistants[0].resources,
|
|
252
|
+
);
|
|
253
|
+
expect(
|
|
254
|
+
createHmac("sha256", Buffer.from(restored, "hex"))
|
|
255
|
+
.update(payload)
|
|
256
|
+
.digest("hex"),
|
|
257
|
+
).toBe(signature);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -18,10 +18,12 @@ const assistantRootDir = testDir;
|
|
|
18
18
|
process.env.CADET_LOCKFILE_DIR = testDir;
|
|
19
19
|
|
|
20
20
|
const stopProcessByPidFileMock = mock(async () => true);
|
|
21
|
-
const isProcessAliveMock = mock(
|
|
22
|
-
alive:
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
const isProcessAliveMock = mock(
|
|
22
|
+
(_pidFile?: string): { alive: boolean; pid: number | null } => ({
|
|
23
|
+
alive: false,
|
|
24
|
+
pid: null,
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
25
27
|
|
|
26
28
|
const realProcess = { ...(await import("../lib/process.js")) };
|
|
27
29
|
|
|
@@ -95,7 +97,7 @@ import {
|
|
|
95
97
|
// assistant without needing to mock the entire assistant-config module.
|
|
96
98
|
function writeLockfile(extraEntryFields: Record<string, unknown> = {}): void {
|
|
97
99
|
writeFileSync(
|
|
98
|
-
join(testDir, ".
|
|
100
|
+
join(testDir, "lockfile.json"),
|
|
99
101
|
JSON.stringify(
|
|
100
102
|
{
|
|
101
103
|
assistants: [
|
|
@@ -161,7 +163,7 @@ describe("sleep command", () => {
|
|
|
161
163
|
// Reset per-test state: drop the lockfile and any lease file left behind.
|
|
162
164
|
// assistantRootDir IS testDir now (state is flattened into the instance
|
|
163
165
|
// dir), so wipe files rather than the directory itself.
|
|
164
|
-
rmSync(join(testDir, ".
|
|
166
|
+
rmSync(join(testDir, "lockfile.json"), { force: true });
|
|
165
167
|
rmSync(join(assistantRootDir, "active-call-leases.json"), { force: true });
|
|
166
168
|
writeLockfile();
|
|
167
169
|
});
|
|
@@ -366,4 +368,185 @@ describe("sleep command", () => {
|
|
|
366
368
|
expect(stopProcessByPidFileMock).not.toHaveBeenCalled();
|
|
367
369
|
});
|
|
368
370
|
});
|
|
371
|
+
|
|
372
|
+
describe("--all", () => {
|
|
373
|
+
function writeMultiAssistantLockfile(): void {
|
|
374
|
+
writeFileSync(
|
|
375
|
+
join(testDir, "lockfile.json"),
|
|
376
|
+
JSON.stringify(
|
|
377
|
+
{
|
|
378
|
+
assistants: [
|
|
379
|
+
{
|
|
380
|
+
assistantId: "sleep-all-1",
|
|
381
|
+
runtimeUrl: `http://127.0.0.1:${DEFAULT_DAEMON_PORT}`,
|
|
382
|
+
cloud: "local",
|
|
383
|
+
resources: {
|
|
384
|
+
instanceDir: join(testDir, "instance-1"),
|
|
385
|
+
daemonPort: DEFAULT_DAEMON_PORT,
|
|
386
|
+
gatewayPort: DEFAULT_GATEWAY_PORT,
|
|
387
|
+
qdrantPort: DEFAULT_QDRANT_PORT,
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
assistantId: "sleep-all-2",
|
|
392
|
+
runtimeUrl: `http://127.0.0.1:${DEFAULT_DAEMON_PORT}`,
|
|
393
|
+
cloud: "local",
|
|
394
|
+
resources: {
|
|
395
|
+
instanceDir: join(testDir, "instance-2"),
|
|
396
|
+
daemonPort: DEFAULT_DAEMON_PORT,
|
|
397
|
+
gatewayPort: DEFAULT_GATEWAY_PORT,
|
|
398
|
+
qdrantPort: DEFAULT_QDRANT_PORT,
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
],
|
|
402
|
+
activeAssistant: "sleep-all-1",
|
|
403
|
+
},
|
|
404
|
+
null,
|
|
405
|
+
2,
|
|
406
|
+
),
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
test("stops every assistant in the lockfile", async () => {
|
|
411
|
+
writeMultiAssistantLockfile();
|
|
412
|
+
process.argv = ["bun", "cadet", "sleep", "--all"];
|
|
413
|
+
|
|
414
|
+
const consoleLog = spyOn(console, "log").mockImplementation(() => {});
|
|
415
|
+
try {
|
|
416
|
+
await sleep();
|
|
417
|
+
} finally {
|
|
418
|
+
consoleLog.mockRestore();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(6); // assistant+gateway+CES × 2
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("skips assistants whose lifecycle is managed elsewhere", async () => {
|
|
425
|
+
writeMultiAssistantLockfile();
|
|
426
|
+
// Rewrite the lockfile with the second entry paired.
|
|
427
|
+
writeFileSync(
|
|
428
|
+
join(testDir, "lockfile.json"),
|
|
429
|
+
JSON.stringify({
|
|
430
|
+
assistants: [
|
|
431
|
+
{
|
|
432
|
+
assistantId: "sleep-all-1",
|
|
433
|
+
runtimeUrl: `http://127.0.0.1:${DEFAULT_DAEMON_PORT}`,
|
|
434
|
+
cloud: "local",
|
|
435
|
+
resources: {
|
|
436
|
+
instanceDir: join(testDir, "instance-1"),
|
|
437
|
+
daemonPort: DEFAULT_DAEMON_PORT,
|
|
438
|
+
gatewayPort: DEFAULT_GATEWAY_PORT,
|
|
439
|
+
qdrantPort: DEFAULT_QDRANT_PORT,
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
assistantId: "paired-one",
|
|
444
|
+
runtimeUrl: "http://127.0.0.1:1",
|
|
445
|
+
cloud: "paired",
|
|
446
|
+
},
|
|
447
|
+
],
|
|
448
|
+
activeAssistant: "sleep-all-1",
|
|
449
|
+
}),
|
|
450
|
+
);
|
|
451
|
+
process.argv = ["bun", "cadet", "sleep", "--all"];
|
|
452
|
+
|
|
453
|
+
const consoleLog = spyOn(console, "log").mockImplementation(() => {});
|
|
454
|
+
try {
|
|
455
|
+
await sleep();
|
|
456
|
+
} finally {
|
|
457
|
+
consoleLog.mockRestore();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(3); // assistant+gateway+CES, one local entry
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("continues past a failing assistant and exits 1", async () => {
|
|
464
|
+
writeMultiAssistantLockfile();
|
|
465
|
+
// instance-1 hits an active-call lease refusal; instance-2 is fine.
|
|
466
|
+
isProcessAliveMock.mockImplementation((pidFile?: string) => ({
|
|
467
|
+
alive: pidFile?.includes("instance-1") ?? false,
|
|
468
|
+
pid: pidFile?.includes("instance-1") ? 12345 : null,
|
|
469
|
+
}));
|
|
470
|
+
mkdirSync(join(testDir, "instance-1"), { recursive: true });
|
|
471
|
+
writeFileSync(
|
|
472
|
+
join(testDir, "instance-1", "active-call-leases.json"),
|
|
473
|
+
JSON.stringify({
|
|
474
|
+
version: 1,
|
|
475
|
+
leases: [
|
|
476
|
+
{
|
|
477
|
+
callSessionId: "call-all-1",
|
|
478
|
+
providerCallSid: null,
|
|
479
|
+
updatedAt: Date.now(),
|
|
480
|
+
},
|
|
481
|
+
],
|
|
482
|
+
}),
|
|
483
|
+
);
|
|
484
|
+
process.argv = ["bun", "cadet", "sleep", "--all"];
|
|
485
|
+
|
|
486
|
+
const consoleError = spyOn(console, "error").mockImplementation(() => {});
|
|
487
|
+
const exitMock = mock((code?: number) => {
|
|
488
|
+
throw new Error(`process.exit:${code}`);
|
|
489
|
+
});
|
|
490
|
+
const originalExit = process.exit;
|
|
491
|
+
process.exit = exitMock as unknown as typeof process.exit;
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
await expect(sleep()).rejects.toThrow("process.exit:1");
|
|
495
|
+
expect(
|
|
496
|
+
consoleError.mock.calls.some(([line]) =>
|
|
497
|
+
String(line).includes("failed to stop 'sleep-all-1'"),
|
|
498
|
+
),
|
|
499
|
+
).toBe(true);
|
|
500
|
+
} finally {
|
|
501
|
+
process.exit = originalExit;
|
|
502
|
+
consoleError.mockRestore();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// The healthy assistant was still stopped.
|
|
506
|
+
expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(3); // assistant+gateway+CES of instance-2
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("exits 1 when there is nothing to stop", async () => {
|
|
510
|
+
rmSync(join(testDir, "lockfile.json"), { force: true });
|
|
511
|
+
process.argv = ["bun", "cadet", "sleep", "--all"];
|
|
512
|
+
|
|
513
|
+
const consoleError = spyOn(console, "error").mockImplementation(() => {});
|
|
514
|
+
const exitMock = mock((code?: number) => {
|
|
515
|
+
throw new Error(`process.exit:${code}`);
|
|
516
|
+
});
|
|
517
|
+
const originalExit = process.exit;
|
|
518
|
+
process.exit = exitMock as unknown as typeof process.exit;
|
|
519
|
+
|
|
520
|
+
try {
|
|
521
|
+
await expect(sleep()).rejects.toThrow("process.exit:1");
|
|
522
|
+
expect(consoleError).toHaveBeenCalledWith(
|
|
523
|
+
expect.stringContaining("no assistants found"),
|
|
524
|
+
);
|
|
525
|
+
} finally {
|
|
526
|
+
process.exit = originalExit;
|
|
527
|
+
consoleError.mockRestore();
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("refuses --all combined with a name", async () => {
|
|
532
|
+
process.argv = ["bun", "cadet", "sleep", "sleep-test", "--all"];
|
|
533
|
+
|
|
534
|
+
const consoleError = spyOn(console, "error").mockImplementation(() => {});
|
|
535
|
+
const exitMock = mock((code?: number) => {
|
|
536
|
+
throw new Error(`process.exit:${code}`);
|
|
537
|
+
});
|
|
538
|
+
const originalExit = process.exit;
|
|
539
|
+
process.exit = exitMock as unknown as typeof process.exit;
|
|
540
|
+
|
|
541
|
+
try {
|
|
542
|
+
await expect(sleep()).rejects.toThrow("process.exit:1");
|
|
543
|
+
expect(consoleError).toHaveBeenCalledWith(
|
|
544
|
+
expect.stringContaining("--all cannot be combined"),
|
|
545
|
+
);
|
|
546
|
+
} finally {
|
|
547
|
+
process.exit = originalExit;
|
|
548
|
+
consoleError.mockRestore();
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
});
|
|
369
552
|
});
|
|
@@ -92,7 +92,7 @@ function makeDeps(opts: {
|
|
|
92
92
|
function useTempLockfile(assistantId: string, ingressUrl?: string): string {
|
|
93
93
|
const lockfileDir = makeWorkspace();
|
|
94
94
|
process.env.CADET_LOCKFILE_DIR = lockfileDir;
|
|
95
|
-
const lockfilePath = join(lockfileDir, ".
|
|
95
|
+
const lockfilePath = join(lockfileDir, "lockfile.json");
|
|
96
96
|
writeFileSync(
|
|
97
97
|
lockfilePath,
|
|
98
98
|
JSON.stringify({
|