@mycadet/cli 0.12.10 → 0.13.0

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.
Files changed (105) hide show
  1. package/README.md +13 -0
  2. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  3. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  4. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  5. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  6. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  7. package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  8. package/package.json +3 -2
  9. package/src/__tests__/assistant-client-cloud-auth.test.ts +2 -2
  10. package/src/__tests__/backup-ops-matching.test.ts +15 -13
  11. package/src/__tests__/backup-ops.test.ts +105 -0
  12. package/src/__tests__/backup.test.ts +114 -673
  13. package/src/__tests__/cloud-login-poll.test.ts +69 -0
  14. package/src/__tests__/cloud-releases.test.ts +82 -0
  15. package/src/__tests__/commands-mock.ts +101 -0
  16. package/src/__tests__/debug.test.ts +17 -91
  17. package/src/__tests__/devices.test.ts +64 -2
  18. package/src/__tests__/ensure-binary.test.ts +52 -0
  19. package/src/__tests__/gateway-token.test.ts +267 -0
  20. package/src/__tests__/hatch-next-steps.test.ts +38 -0
  21. package/src/__tests__/host-teleport-docker.test.ts +371 -0
  22. package/src/__tests__/host-teleport.test.ts +405 -0
  23. package/src/__tests__/multi-local.test.ts +3 -3
  24. package/src/__tests__/nginx-ingress.test.ts +233 -36
  25. package/src/__tests__/orphan-detection.test.ts +1 -1
  26. package/src/__tests__/paired-lifecycle.test.ts +2 -2
  27. package/src/__tests__/ps-cloud-status.test.ts +8 -7
  28. package/src/__tests__/root-backup-docker.test.ts +294 -0
  29. package/src/__tests__/root-backup.test.ts +527 -0
  30. package/src/__tests__/root-restore-command.test.ts +147 -0
  31. package/src/__tests__/root-signing-key.test.ts +50 -0
  32. package/src/__tests__/setup.test.ts +17 -7
  33. package/src/__tests__/sleep.test.ts +11 -0
  34. package/src/__tests__/teleport.test.ts +68 -2996
  35. package/src/__tests__/tunnel-startup-summary.test.ts +110 -0
  36. package/src/__tests__/tunnel-stop.test.ts +226 -0
  37. package/src/__tests__/tunnel.test.ts +13 -3
  38. package/src/__tests__/upgrade-local.test.ts +21 -11
  39. package/src/__tests__/upgrade-replay-env.test.ts +2 -2
  40. package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
  41. package/src/__tests__/windows-lifecycle.test.ts +25 -0
  42. package/src/commands/backup.ts +44 -399
  43. package/src/commands/client.ts +3 -3
  44. package/src/commands/completions.ts +0 -2
  45. package/src/commands/debug.ts +75 -131
  46. package/src/commands/devices.ts +117 -47
  47. package/src/commands/gateway/token.ts +77 -23
  48. package/src/commands/hatch.ts +10 -52
  49. package/src/commands/pair.ts +9 -3
  50. package/src/commands/ps.ts +4 -2
  51. package/src/commands/restore.ts +77 -444
  52. package/src/commands/retire.ts +2 -3
  53. package/src/commands/sleep.ts +15 -2
  54. package/src/commands/teleport.ts +47 -1559
  55. package/src/commands/tunnel.ts +43 -4
  56. package/src/commands/upgrade.ts +84 -157
  57. package/src/commands/wake.ts +1 -1
  58. package/src/index.ts +0 -4
  59. package/src/lib/__tests__/http-client-version.test.ts +29 -0
  60. package/src/lib/__tests__/runtime-url.test.ts +3 -75
  61. package/src/lib/__tests__/web-dist.test.ts +27 -1
  62. package/src/lib/assistant-client.ts +1 -1
  63. package/src/lib/assistant-config.ts +20 -23
  64. package/src/lib/backup-ops.ts +37 -251
  65. package/src/lib/cloud-client.ts +10 -13
  66. package/src/lib/cloud-login-poll.ts +75 -0
  67. package/src/lib/cloud-login.ts +5 -2
  68. package/src/lib/cloud-releases.ts +54 -10
  69. package/src/lib/cloud-values.ts +1 -1
  70. package/src/lib/confirm-action.ts +2 -2
  71. package/src/lib/docker.ts +65 -34
  72. package/src/lib/ensure-binary.ts +65 -6
  73. package/src/lib/github-repository.ts +2 -0
  74. package/src/lib/guardian-token.ts +1 -23
  75. package/src/lib/hatch-local.ts +18 -52
  76. package/src/lib/hatch-next-steps.ts +15 -2
  77. package/src/lib/host-teleport.ts +506 -0
  78. package/src/lib/http-client.ts +25 -0
  79. package/src/lib/live-voice/tunnel.test.ts +1 -1
  80. package/src/lib/live-voice/tunnel.ts +3 -3
  81. package/src/lib/local.ts +10 -8
  82. package/src/lib/nginx-ingress.ts +254 -36
  83. package/src/lib/process.ts +13 -0
  84. package/src/lib/root-backup.ts +917 -0
  85. package/src/lib/root-signing-key.ts +31 -0
  86. package/src/lib/runtime-url.ts +0 -29
  87. package/src/lib/teleport-backup.ts +3 -16
  88. package/src/lib/tunnel.ts +86 -34
  89. package/src/lib/tunnels/cadet.ts +12 -1
  90. package/src/lib/tunnels/cloudflare.ts +36 -1
  91. package/src/lib/tunnels/ngrok.ts +16 -2
  92. package/src/lib/tunnels/process-lifecycle.ts +26 -0
  93. package/src/lib/tunnels/tailscale.ts +79 -1
  94. package/src/lib/upgrade-lifecycle.ts +63 -141
  95. package/src/lib/web-dist.ts +23 -1
  96. package/src/lib/xdg-log.ts +11 -0
  97. package/src/__tests__/bundle-staging.test.ts +0 -268
  98. package/src/__tests__/unpair.test.ts +0 -160
  99. package/src/commands/rollback.ts +0 -479
  100. package/src/commands/unpair.ts +0 -122
  101. package/src/lib/__tests__/job-polling.test.ts +0 -278
  102. package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
  103. package/src/lib/bundle-staging.ts +0 -220
  104. package/src/lib/job-polling.ts +0 -195
  105. package/src/lib/local-runtime-client.ts +0 -374
@@ -1,16 +1,4 @@
1
- /**
2
- * `cadet debug [name]`
3
- *
4
- * Send Cadet staff a debug bundle of a self-hosted assistant: the
5
- * workspace, its logs, and the gateway's own database, with no credentials.
6
- * Staff open it on a throwaway debug clone. The cloud only hands out the
7
- * upload URL while "Allow Staff Access" is on, and the bundle is deleted
8
- * after seven days.
9
- *
10
- * Steps: the daemon is checked for the debug profile, the cloud mints
11
- * the upload URL, the daemon exports with the debug profile, and this
12
- * command waits for the job so the result is real.
13
- */
1
+ import { z } from "zod";
14
2
  import { isCloudHome } from "../lib/cloud-values.js";
15
3
  import {
16
4
  formatAssistantReference,
@@ -18,13 +6,6 @@ import {
18
6
  } from "../lib/assistant-config.js";
19
7
  import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
20
8
  import { loadGuardianToken, leaseGuardianToken } from "../lib/guardian-token";
21
- import { pollJobUntilDone } from "../lib/job-polling.js";
22
- import {
23
- MigrationInProgressError,
24
- localRuntimeExportToGcs,
25
- localRuntimeIdentity,
26
- localRuntimePollJobStatus,
27
- } from "../lib/local-runtime-client.js";
28
9
  import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
29
10
  import {
30
11
  authHeaders,
@@ -32,16 +13,7 @@ import {
32
13
  getCloudUrl,
33
14
  readCloudToken,
34
15
  } from "../lib/cloud-client.js";
35
- import { compareVersions } from "../lib/version-compat.js";
36
16
 
37
- // Matches the daemon's own upload deadline (EXPORT_TO_GCS_PUT_TIMEOUT_MS in
38
- // assistant/src/runtime/routes/migration-routes.ts), so a slow but healthy
39
- // export is never reported as failed while it is still uploading.
40
- const EXPORT_TIMEOUT_MS = 60 * 60 * 1000;
41
- // The daemon release that added `profile: "debug"`. An older daemon strips
42
- // the field and exports a normal teleport bundle, which carries the owner's
43
- // credentials. That must never reach the staff bucket.
44
- export const DEBUG_PROFILE_MIN_VERSION = "0.12.3";
45
17
  // Renew a cached guardian token that is about to lapse instead of starting
46
18
  // a long export on it.
47
19
  const TOKEN_EXPIRY_MARGIN_MS = 60 * 1000;
@@ -50,8 +22,8 @@ function printHelp(): void {
50
22
  console.log(`Usage: cadet debug [name]
51
23
 
52
24
  Send Cadet staff a debug bundle of a self-hosted assistant so they can
53
- inspect a copy of it. The bundle holds the assistant's workspace, its
54
- logs, and the gateway's own database. It never holds credentials.
25
+ inspect its diagnostic logs and sanitized configuration. The archive
26
+ never holds credentials.
55
27
 
56
28
  Arguments:
57
29
  name Assistant ID or unique display name. Defaults to the active
@@ -61,7 +33,6 @@ Requirements:
61
33
  - 'Allow Staff Access' is on for the assistant (Settings > Privacy).
62
34
  - You are logged in ('cadet login'), which also registers the
63
35
  assistant with the cloud.
64
- - The assistant runs version ${DEBUG_PROFILE_MIN_VERSION} or newer.
65
36
  - The assistant is self-hosted. Cadet-hosted assistants need no bundle;
66
37
  staff clone them directly.
67
38
 
@@ -69,7 +40,7 @@ Side effects:
69
40
  - Uploads the bundle to Cadet's debug-bundle storage, where it is
70
41
  deleted after 7 days. Nothing on this machine changes.
71
42
  - Records a 'debug bundle exported' entry in your account's audit history.
72
- - Waits for the upload to finish (up to 60 minutes for a large workspace).
43
+ - Waits for the diagnostic upload to finish.
73
44
 
74
45
  Examples:
75
46
  cadet debug
@@ -80,114 +51,87 @@ Examples:
80
51
 
81
52
  export const debug = Object.assign(
82
53
  async function debug(): Promise<void> {
83
- const args = process.argv.slice(3);
84
- if (args.includes("--help") || args.includes("-h")) {
85
- printHelp();
86
- return;
87
- }
88
-
89
- const entry = resolveTargetAssistant(parseAssistantTargetArg(args));
90
- const reference = formatAssistantReference(entry);
91
- if (isCloudHome(entry.cloud)) {
92
- console.error(
93
- `${reference} is hosted by Cadet, so staff can clone it directly. No bundle is needed.`,
94
- );
95
- process.exit(1);
96
- }
97
-
98
- const cloudToken = readCloudToken();
99
- if (!cloudToken) {
100
- console.error("Not logged in. Run 'cadet login' first.");
101
- process.exit(1);
102
- }
103
- const cloudAssistantId = entry.cloudAssistantId?.trim();
104
- if (!cloudAssistantId) {
105
- console.error(
106
- `${reference} is not registered with the cloud yet. Run 'cadet login' and retry.`,
107
- );
108
- process.exit(1);
109
- }
110
- const cloudUrl = entry.cloudBaseUrl?.trim() || getCloudUrl();
111
- const organizationId = entry.cloudOrganizationId?.trim();
54
+ const args = process.argv.slice(3);
55
+ if (args.includes("--help") || args.includes("-h")) {
56
+ printHelp();
57
+ return;
58
+ }
112
59
 
113
- // Step 1: a guardian token for the daemon, and a check that the daemon
114
- // knows the debug profile. This comes before the cloud call so a
115
- // daemon that would export credentials is refused before any URL exists.
116
- let accessToken = await daemonAccessToken(entry, reference, false);
117
- const { version } = await localRuntimeIdentity(entry, accessToken);
118
- const comparison = compareVersions(version, DEBUG_PROFILE_MIN_VERSION);
119
- if (comparison === null || comparison < 0) {
120
- console.error(
121
- `${reference} runs version ${version || "unknown"}, which cannot build a debug bundle. Update it to ${DEBUG_PROFILE_MIN_VERSION} or newer ('cadet upgrade') and retry.`,
122
- );
123
- process.exit(1);
124
- }
60
+ const entry = resolveTargetAssistant(parseAssistantTargetArg(args));
61
+ const reference = formatAssistantReference(entry);
62
+ if (isCloudHome(entry.cloud)) {
63
+ console.error(
64
+ `${reference} is hosted by Cadet, so staff can clone it directly. No bundle is needed.`,
65
+ );
66
+ process.exit(1);
67
+ }
125
68
 
126
- // Step 2: the cloud mints the upload URL, and refuses unless the
127
- // owner's staff access grant is active.
128
- const headers = organizationId
129
- ? authHeadersForKnownOrganization(cloudToken, organizationId)
130
- : await authHeaders(cloudToken, cloudUrl);
131
- const urlResponse = await loopbackSafeFetch(
132
- `${cloudUrl}/v1/assistants/${encodeURIComponent(cloudAssistantId)}/debug-bundle-upload-url/`,
133
- { method: "POST", headers },
134
- );
135
- if (urlResponse.status === 403) {
136
- console.error(
137
- "Staff access is off for this assistant. Turn on 'Allow Staff Access' in Settings > Privacy, then retry.",
138
- );
139
- process.exit(1);
140
- }
141
- if (!urlResponse.ok) {
142
- const detail = await urlResponse.text().catch(() => "");
143
- console.error(
144
- `Error: Could not get an upload URL (${urlResponse.status}): ${detail || urlResponse.statusText}`,
69
+ const cloudToken = readCloudToken();
70
+ if (!cloudToken) {
71
+ console.error("Not logged in. Run 'cadet login' first.");
72
+ process.exit(1);
73
+ }
74
+ const cloudAssistantId = entry.cloudAssistantId?.trim();
75
+ if (!cloudAssistantId) {
76
+ console.error(
77
+ `${reference} is not registered with the cloud yet. Run 'cadet login' and retry.`,
78
+ );
79
+ process.exit(1);
80
+ }
81
+ const cloudUrl = entry.cloudBaseUrl?.trim() || getCloudUrl();
82
+ const organizationId = entry.cloudOrganizationId?.trim();
83
+
84
+ const accessToken = await daemonAccessToken(entry, reference, false);
85
+ const headers = organizationId
86
+ ? authHeadersForKnownOrganization(cloudToken, organizationId)
87
+ : await authHeaders(cloudToken, cloudUrl);
88
+ const urlResponse = await loopbackSafeFetch(
89
+ `${cloudUrl}/v1/assistants/${encodeURIComponent(cloudAssistantId)}/debug-bundle-upload-url/`,
90
+ { method: "POST", headers },
145
91
  );
146
- process.exit(1);
147
- }
148
- const { url: uploadUrl } = (await urlResponse.json()) as { url: string };
149
-
150
- // Step 3: the daemon builds and uploads the bundle.
151
- let jobId: string;
152
- try {
153
- ({ jobId } = await localRuntimeExportToGcs(entry, accessToken, {
154
- uploadUrl,
155
- description: "debug bundle for Cadet staff",
156
- profile: "debug",
157
- }));
158
- } catch (err) {
159
- if (err instanceof MigrationInProgressError) {
92
+ if (urlResponse.status === 403) {
160
93
  console.error(
161
- `Error: Another export is already in progress (job ${err.existingJobId}). Wait for it to finish, then retry.`,
94
+ "Staff access is off for this assistant. Turn on 'Allow Staff Access' in Settings > Privacy, then retry.",
162
95
  );
163
96
  process.exit(1);
164
97
  }
165
- throw err;
166
- }
167
- console.log(`Export started (job ${jobId})...`);
168
-
169
- // Step 4: wait for the upload so the owner sees a real result. A large
170
- // export can outlive the guardian token, so a 401 mid-poll re-leases one.
171
- const terminal = await pollJobUntilDone({
172
- label: "debug bundle export",
173
- poll: () => localRuntimePollJobStatus(entry, accessToken, jobId),
174
- timeoutMs: EXPORT_TIMEOUT_MS,
175
- refreshOn401: async () => {
176
- accessToken = await daemonAccessToken(entry, reference, true);
177
- },
178
- });
179
- if (terminal.status === "failed") {
180
- console.error(`Error: Export failed: ${terminal.error}`);
181
- process.exit(1);
182
- }
183
- console.log(
184
- "Debug bundle sent to Cadet. Staff can open it for the next 7 days.",
185
- );
186
- },
98
+ if (!urlResponse.ok) {
99
+ const detail = await urlResponse.text().catch(() => "");
100
+ console.error(
101
+ `Error: Could not get an upload URL (${urlResponse.status}): ${detail || urlResponse.statusText}`,
102
+ );
103
+ process.exit(1);
104
+ }
105
+ const { url: uploadUrl } = z
106
+ .object({ url: z.string().url() })
107
+ .parse(await urlResponse.json());
108
+ const logs = await loopbackSafeFetch(
109
+ `${entry.runtimeUrl.replace(/\/+$/, "")}/v1/logs/export`,
110
+ {
111
+ method: "POST",
112
+ signal: AbortSignal.timeout(180_000),
113
+ headers: { Authorization: `Bearer ${accessToken}` },
114
+ },
115
+ );
116
+ if (!logs.ok) {
117
+ throw new Error(`Diagnostic export failed (HTTP ${logs.status}).`);
118
+ }
119
+ const upload = await fetch(uploadUrl, {
120
+ method: "PUT",
121
+ signal: AbortSignal.timeout(300_000),
122
+ headers: { "Content-Type": "application/octet-stream" },
123
+ body: await logs.blob(),
124
+ });
125
+ if (!upload.ok) {
126
+ throw new Error(`Diagnostic upload failed (HTTP ${upload.status}).`);
127
+ }
128
+ console.log(
129
+ "Debug bundle sent to Cadet. Staff can open it for the next 7 days.",
130
+ );
131
+ },
187
132
  { description: "Send Cadet staff a debug bundle of a self-hosted assistant" },
188
133
  );
189
134
 
190
-
191
135
  async function daemonAccessToken(
192
136
  entry: {
193
137
  assistantId: string;
@@ -7,13 +7,11 @@
7
7
  * which self-guard loopback + reject any browser/WebView Origin. The gateway
8
8
  * only ever stores the HASHED device id, so list returns and revoke accepts the
9
9
  * same `hashedDeviceId` (the raw device id is never persisted anywhere).
10
- *
11
- * This is the counterpart to `cadet unpair`: `unpair` forgets a connection on
12
- * the *paired* machine (client side); `devices` revokes a device from the *host*
13
- * that runs the assistant (server side).
14
10
  */
15
11
 
12
+ import { z } from "zod";
16
13
  import { createHash } from "node:crypto";
14
+ import { hostname } from "node:os";
17
15
 
18
16
  import type { DeviceRecord } from "@mycadet/local-mode";
19
17
 
@@ -23,7 +21,11 @@ import {
23
21
  getAssistantDisplayName,
24
22
  resolveTargetAssistant,
25
23
  } from "../lib/assistant-config";
26
- import { computeDeviceId } from "../lib/guardian-token.js";
24
+ import {
25
+ type GuardianTokenData,
26
+ computeDeviceId,
27
+ loadGuardianToken,
28
+ } from "../lib/guardian-token.js";
27
29
  import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
28
30
  import {
29
31
  CLI_INTERFACE_ID,
@@ -33,10 +35,18 @@ import {
33
35
  canPromptForConfirmation,
34
36
  confirmAction,
35
37
  } from "../lib/confirm-action.js";
38
+ import { GATEWAY_INTERNAL_PORT } from "../lib/environments/paths.js";
39
+ import { execOutput } from "../lib/step-runner.js";
40
+ import { dockerResourceNames } from "../lib/docker.js";
36
41
  import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
37
42
 
43
+ const dockerGatewayResponseSchema = z.object({
44
+ status: z.number().int().min(200).max(599),
45
+ body: z.string(),
46
+ });
47
+
38
48
  function printUsage(): void {
39
- console.log(`cadet devices [beta] - List and revoke devices paired to a local assistant
49
+ console.log(`cadet devices - List and revoke devices paired to a local assistant
40
50
 
41
51
  USAGE:
42
52
  cadet devices [name]
@@ -51,8 +61,7 @@ OPTIONS:
51
61
  --json Print machine-readable JSON on stdout (revoke requires --yes)
52
62
 
53
63
  Lists the devices paired to a local (host-side) assistant, or revokes one by its
54
- hashed id. Runs on the machine that hosts the assistant — paired connections
55
- imported from another machine are managed with 'cadet unpair' instead.
64
+ hashed id. Runs on the machine that hosts the assistant.
56
65
 
57
66
  EXAMPLES:
58
67
  cadet devices
@@ -77,10 +86,12 @@ function resolveLoopbackBase(entry: AssistantEntry): string {
77
86
  console.error(
78
87
  "Run `cadet devices` on the host that runs the assistant to manage its devices.",
79
88
  );
80
- console.error("To forget this connection here, use `cadet unpair`.");
81
89
  process.exit(1);
82
90
  }
83
91
 
92
+ if (entry.cloud === "docker")
93
+ return `http://127.0.0.1:${GATEWAY_INTERNAL_PORT}`;
94
+
84
95
  const base =
85
96
  entry.localUrl ||
86
97
  (entry.resources?.gatewayPort
@@ -96,15 +107,66 @@ function resolveLoopbackBase(entry: AssistantEntry): string {
96
107
  return base.replace(/\/+$/, "");
97
108
  }
98
109
 
110
+ /** Docker host management enters the gateway network namespace before calling loopback-only routes. */
111
+ async function deviceRequest(
112
+ entry: AssistantEntry,
113
+ url: string,
114
+ init: RequestInit,
115
+ ): Promise<Response> {
116
+ if (entry.cloud !== "docker") return loopbackSafeFetch(url, init);
117
+ const script = `const r = await fetch(process.argv[1], {...JSON.parse(process.argv[2]), signal: AbortSignal.timeout(10000)}); console.log(JSON.stringify({status:r.status, body:await r.text()}));`;
118
+ const output = await execOutput(
119
+ "docker",
120
+ [
121
+ "exec",
122
+ dockerResourceNames(entry.assistantId).gatewayContainer,
123
+ "bun",
124
+ "-e",
125
+ script,
126
+ url,
127
+ JSON.stringify(init),
128
+ ],
129
+ { timeoutMs: 15000 },
130
+ );
131
+ const result = dockerGatewayResponseSchema.parse(JSON.parse(output));
132
+ return new Response(result.body, { status: result.status });
133
+ }
134
+
99
135
  /**
100
136
  * The hashed device id the gateway stores for THIS machine's own guardian
101
137
  * credential (`leaseGuardianToken` registers it device-bound as
102
- * sha256(deviceId)). It shows up in `/v1/devices` next to remotely paired
103
- * devices; revoking it locks this host out of its own assistant until
104
- * guardian repair.
138
+ * sha256(deviceId)). The gateway's row hashes the deviceId the token was
139
+ * leased with, so prefer the stored token's deviceId and fall back to the
140
+ * current machine id for tokens persisted before the field was stored. It
141
+ * shows up in `/v1/devices` next to remotely paired devices; revoking it
142
+ * locks this host out of its own assistant until guardian repair.
105
143
  */
106
- function currentHostHashedDeviceId(): string {
107
- return createHash("sha256").update(computeDeviceId()).digest("hex");
144
+ function currentHostHashedDeviceId(token?: GuardianTokenData | null): string {
145
+ const deviceId = token?.deviceId || computeDeviceId();
146
+ return createHash("sha256").update(deviceId).digest("hex");
147
+ }
148
+
149
+ /**
150
+ * The local guardian token.json IS a paired device even when the gateway has
151
+ * no record of it (fresh gateway DB, a lease that predates device tracking).
152
+ * Rebuild the row it would have — same identity the gateway would have
153
+ * stored — so it always shows up in `cadet devices` and stays revocable.
154
+ */
155
+ function localHostDeviceRow(
156
+ token: GuardianTokenData,
157
+ hashedDeviceId: string,
158
+ ): DeviceRecord {
159
+ const leasedAtMs = Date.parse(token.leasedAt);
160
+ return {
161
+ hashedDeviceId,
162
+ platform: "cli",
163
+ issuedAt: Number.isFinite(leasedAtMs) ? leasedAtMs : null,
164
+ expiresAt: null,
165
+ lastUsedAt: null,
166
+ pairingUserAgent: null,
167
+ clientReportedName: hostname(),
168
+ isCurrentHost: true,
169
+ };
108
170
  }
109
171
 
110
172
  /** Format an epoch-ms timestamp as ISO, or a placeholder when absent. */
@@ -122,7 +184,7 @@ async function listDevices(
122
184
 
123
185
  let response: Response;
124
186
  try {
125
- response = await loopbackSafeFetch(`${base}/v1/devices`, {
187
+ response = await deviceRequest(entry, `${base}/v1/devices`, {
126
188
  method: "GET",
127
189
  headers: getClientRegistrationHeaders(CLI_INTERFACE_ID),
128
190
  });
@@ -143,13 +205,21 @@ async function listDevices(
143
205
  }
144
206
 
145
207
  const body = (await response.json()) as { devices?: DeviceRecord[] };
146
- const hostHash = currentHostHashedDeviceId();
208
+ const token = loadGuardianToken(entry.assistantId);
209
+ const hostHash = currentHostHashedDeviceId(token);
147
210
  const devices = (body.devices ?? []).map((device) =>
148
211
  device.hashedDeviceId === hostHash
149
212
  ? { ...device, isCurrentHost: true as const }
150
213
  : device,
151
214
  );
152
215
 
216
+ // The local guardian token.json IS a paired device even when the gateway
217
+ // has no record of it (fresh gateway DB, lease predating device tracking):
218
+ // synthesize the row it would have so it stays visible and revocable here.
219
+ if (token && !devices.some((device) => device.hashedDeviceId === hostHash)) {
220
+ devices.push(localHostDeviceRow(token, hostHash));
221
+ }
222
+
153
223
  if (jsonOutput) {
154
224
  console.log(JSON.stringify({ devices }));
155
225
  return;
@@ -213,7 +283,8 @@ async function revokeDevice(
213
283
  // Warn (don't block; recoverable via guardian repair) when the target is
214
284
  // this machine's own host credential. --json is app-driven; the UI disables
215
285
  // the host row, so the warning stays out of automation transcripts.
216
- if (!jsonOutput && hashedDeviceId === currentHostHashedDeviceId()) {
286
+ const localToken = loadGuardianToken(entry.assistantId);
287
+ if (!jsonOutput && hashedDeviceId === currentHostHashedDeviceId(localToken)) {
217
288
  console.error(
218
289
  "Warning: this is this machine's own host credential. Revoking it locks this host out of the assistant until guardian repair.",
219
290
  );
@@ -239,7 +310,7 @@ async function revokeDevice(
239
310
 
240
311
  let response: Response;
241
312
  try {
242
- response = await loopbackSafeFetch(`${base}/v1/devices/revoke`, {
313
+ response = await deviceRequest(entry, `${base}/v1/devices/revoke`, {
243
314
  method: "POST",
244
315
  headers: {
245
316
  "Content-Type": "application/json",
@@ -281,38 +352,37 @@ async function revokeDevice(
281
352
 
282
353
  export const devices = Object.assign(
283
354
  async function devices(): Promise<void> {
284
- const rawArgs = process.argv.slice(3);
285
-
286
- if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
287
- printUsage();
288
- return;
289
- }
355
+ const rawArgs = process.argv.slice(3);
290
356
 
291
- const jsonOutput = rawArgs.includes("--json");
292
- const args = rawArgs.filter((a) => a !== "--json");
293
-
294
- if (args[0] === "revoke") {
295
- const rest = args.slice(1);
296
- const yes = rest.includes("--yes");
297
- const positionals = rest.filter((a) => !a.startsWith("-"));
298
- const hashedDeviceId = positionals[0];
299
- if (!hashedDeviceId) {
300
- console.error("Error: a hashedDeviceId is required to revoke.");
357
+ if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
301
358
  printUsage();
302
- process.exit(1);
359
+ return;
303
360
  }
304
- const nameArg = positionals.slice(1).join(" ") || undefined;
361
+
362
+ const jsonOutput = rawArgs.includes("--json");
363
+ const args = rawArgs.filter((a) => a !== "--json");
364
+
365
+ if (args[0] === "revoke") {
366
+ const rest = args.slice(1);
367
+ const yes = rest.includes("--yes");
368
+ const positionals = rest.filter((a) => !a.startsWith("-"));
369
+ const hashedDeviceId = positionals[0];
370
+ if (!hashedDeviceId) {
371
+ console.error("Error: a hashedDeviceId is required to revoke.");
372
+ printUsage();
373
+ process.exit(1);
374
+ }
375
+ const nameArg = positionals.slice(1).join(" ") || undefined;
376
+ const entry = resolveTargetAssistant(nameArg);
377
+ const base = resolveLoopbackBase(entry);
378
+ await revokeDevice(entry, base, hashedDeviceId, yes, jsonOutput);
379
+ return;
380
+ }
381
+
382
+ const nameArg = parseAssistantTargetArg(args, []);
305
383
  const entry = resolveTargetAssistant(nameArg);
306
384
  const base = resolveLoopbackBase(entry);
307
- await revokeDevice(entry, base, hashedDeviceId, yes, jsonOutput);
308
- return;
309
- }
310
-
311
- const nameArg = parseAssistantTargetArg(args, []);
312
- const entry = resolveTargetAssistant(nameArg);
313
- const base = resolveLoopbackBase(entry);
314
- await listDevices(entry, base, jsonOutput);
315
- },
316
- { description: "List or revoke devices paired to a local assistant [beta]" },
385
+ await listDevices(entry, base, jsonOutput);
386
+ },
387
+ { description: "List or revoke devices paired to a local assistant" },
317
388
  );
318
-
@@ -1,22 +1,67 @@
1
1
  import { formatGuardianRefreshCliFailure } from "@mycadet/local-mode";
2
2
 
3
3
  import {
4
- lookupAssistantByIdentifier,
5
- formatAssistantLookupError,
4
+ type AssistantEntry,
5
+ resolveTargetAssistant,
6
+ saveAssistantEntry,
6
7
  } from "../../lib/assistant-config.js";
7
8
  import {
9
+ GuardianLeasePermanentError,
10
+ leaseGuardianToken,
8
11
  loadGuardianToken,
9
12
  refreshGuardianTokenResult,
13
+ resetGuardianBootstrap,
10
14
  } from "../../lib/guardian-token.js";
15
+ import { generateLocalSigningKey } from "../../lib/local.js";
11
16
 
12
17
  function printUsage(): void {
13
- console.log("Usage: cadet gateway token <subcommand> <assistantId>");
18
+ console.log("Usage: cadet gateway token <subcommand> [assistantId]");
14
19
  console.log("");
15
20
  console.log("Manage gateway authentication tokens.");
16
21
  console.log("");
22
+ console.log(
23
+ "Omit the assistant id to target the active (or sole) assistant.",
24
+ );
25
+ console.log("");
17
26
  console.log("Subcommands:");
18
27
  console.log(" get Print the current guardian access token");
19
- console.log(" refresh Refresh an expired access token and print it");
28
+ console.log(
29
+ " refresh Refresh an expired access token and print it (self-repairs a spent credential)",
30
+ );
31
+ }
32
+
33
+ /**
34
+ * Re-provision the guardian credential in place via the gateway's bootstrap
35
+ * reset — the same machinery `cadet wake --repair-guardian` runs, minus the
36
+ * wake. NEVER points at `cadet hatch`: hatching creates a different assistant.
37
+ * A fresh lease revokes other devices' paired tokens; callers say so on
38
+ * stderr. Returns true on success (the newly leased token is persisted).
39
+ */
40
+ async function repairGuardianCredential(
41
+ entry: AssistantEntry,
42
+ gatewayUrl: string,
43
+ ): Promise<boolean> {
44
+ let bootstrapSecret = entry.guardianBootstrapSecret;
45
+ if (!bootstrapSecret) {
46
+ bootstrapSecret = generateLocalSigningKey();
47
+ entry.guardianBootstrapSecret = bootstrapSecret;
48
+ saveAssistantEntry(entry);
49
+ }
50
+
51
+ const maxAttempts = 3;
52
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
53
+ try {
54
+ await resetGuardianBootstrap(gatewayUrl, bootstrapSecret);
55
+ await leaseGuardianToken(gatewayUrl, entry.assistantId, bootstrapSecret);
56
+ return true;
57
+ } catch (err) {
58
+ if (err instanceof GuardianLeasePermanentError) return false;
59
+ if (attempt < maxAttempts) {
60
+ await new Promise((r) => setTimeout(r, 2000 * 2 ** (attempt - 1)));
61
+ }
62
+ }
63
+ }
64
+ return false;
20
65
  }
21
66
 
22
67
  export async function gatewayToken(): Promise<void> {
@@ -34,27 +79,15 @@ export async function gatewayToken(): Promise<void> {
34
79
  process.exit(1);
35
80
  }
36
81
 
37
- const assistantId = args[1];
38
- if (!assistantId) {
39
- console.error("Missing required argument: <assistantId>");
40
- printUsage();
41
- process.exit(1);
42
- }
43
-
44
- const result = lookupAssistantByIdentifier(assistantId);
45
- if (result.status !== "found") {
46
- console.error(formatAssistantLookupError(assistantId, result));
47
- process.exit(1);
48
- }
49
- const entry = result.entry;
50
-
51
- const tokenData = loadGuardianToken(entry.assistantId);
52
- if (!tokenData) {
53
- console.error("No guardian token found for this assistant.");
54
- process.exit(1);
55
- }
82
+ const nameArg = args[1];
83
+ const entry = resolveTargetAssistant(nameArg);
56
84
 
57
85
  if (subcommand === "get") {
86
+ const tokenData = loadGuardianToken(entry.assistantId);
87
+ if (!tokenData) {
88
+ console.error("No guardian token found for this assistant.");
89
+ process.exit(1);
90
+ }
58
91
  console.log(tokenData.accessToken);
59
92
  return;
60
93
  }
@@ -70,6 +103,27 @@ export async function gatewayToken(): Promise<void> {
70
103
  entry.assistantId,
71
104
  );
72
105
  if (!refreshed.ok) {
106
+ // A spent credential (401 — expired/revoked refresh token) or a missing
107
+ // token file (404) is recoverable in place: re-lease via the gateway's
108
+ // bootstrap reset. The user explicitly invoked a refresh — this is the
109
+ // sanctioned recovery, never `cadet hatch` (a different assistant).
110
+ if (refreshed.status === 401 || refreshed.status === 404) {
111
+ const repaired = await repairGuardianCredential(entry, gatewayUrl);
112
+ if (repaired) {
113
+ const freshToken = loadGuardianToken(entry.assistantId);
114
+ if (freshToken) {
115
+ console.error(
116
+ "Re-provisioned guardian token (this revoked other devices' paired tokens).",
117
+ );
118
+ console.log(freshToken.accessToken);
119
+ return;
120
+ }
121
+ }
122
+ console.error("Guardian token could not be re-provisioned.");
123
+ console.error(
124
+ `Run \`cadet wake ${entry.assistantId} --repair-guardian\` to recover access.`,
125
+ );
126
+ }
73
127
  console.error(refreshed.error);
74
128
  console.error(
75
129
  formatGuardianRefreshCliFailure(refreshed.status, refreshed.error),