@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.
Files changed (94) 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 +2 -2
  3. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
  4. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  5. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  6. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
  7. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  8. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  9. package/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
  10. package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  11. package/package.json +8 -3
  12. package/src/__tests__/assistant-client-cloud-auth.test.ts +2 -2
  13. package/src/__tests__/backup-ops-matching.test.ts +15 -13
  14. package/src/__tests__/backup-ops.test.ts +105 -0
  15. package/src/__tests__/backup.test.ts +114 -673
  16. package/src/__tests__/cloud-releases.test.ts +82 -0
  17. package/src/__tests__/commands-mock.ts +101 -0
  18. package/src/__tests__/debug.test.ts +17 -91
  19. package/src/__tests__/devices.test.ts +2 -2
  20. package/src/__tests__/gateway-token.test.ts +267 -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__/nginx-ingress.test.ts +536 -23
  24. package/src/__tests__/orphan-detection.test.ts +1 -1
  25. package/src/__tests__/paired-lifecycle.test.ts +2 -2
  26. package/src/__tests__/ps-cloud-status.test.ts +6 -6
  27. package/src/__tests__/root-backup-docker.test.ts +294 -0
  28. package/src/__tests__/root-backup.test.ts +527 -0
  29. package/src/__tests__/root-restore-command.test.ts +147 -0
  30. package/src/__tests__/root-signing-key.test.ts +50 -0
  31. package/src/__tests__/setup.test.ts +3 -3
  32. package/src/__tests__/shell-mode.test.ts +83 -0
  33. package/src/__tests__/sleep.test.ts +11 -0
  34. package/src/__tests__/teleport.test.ts +68 -2996
  35. package/src/__tests__/tunnel.test.ts +10 -2
  36. package/src/__tests__/upgrade-local.test.ts +21 -11
  37. package/src/__tests__/upgrade-replay-env.test.ts +2 -2
  38. package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
  39. package/src/__tests__/wake.test.ts +4 -1
  40. package/src/__tests__/windows-lifecycle.test.ts +25 -0
  41. package/src/commands/backup.ts +44 -399
  42. package/src/commands/client.ts +3 -3
  43. package/src/commands/completions.ts +0 -2
  44. package/src/commands/debug.ts +75 -131
  45. package/src/commands/devices.ts +51 -17
  46. package/src/commands/gateway/token.ts +77 -23
  47. package/src/commands/pair.ts +2 -2
  48. package/src/commands/restore.ts +77 -444
  49. package/src/commands/retire.ts +2 -3
  50. package/src/commands/sleep.ts +15 -2
  51. package/src/commands/teleport.ts +47 -1559
  52. package/src/commands/tunnel.ts +48 -7
  53. package/src/commands/upgrade.ts +78 -156
  54. package/src/commands/wake.ts +1 -1
  55. package/src/index.ts +0 -4
  56. package/src/lib/__tests__/http-client-version.test.ts +29 -0
  57. package/src/lib/__tests__/runtime-url.test.ts +3 -75
  58. package/src/lib/__tests__/web-dist.test.ts +27 -1
  59. package/src/lib/assistant-client.ts +1 -1
  60. package/src/lib/assistant-config.ts +23 -18
  61. package/src/lib/backup-ops.ts +37 -251
  62. package/src/lib/cloud-client.ts +10 -13
  63. package/src/lib/cloud-login.ts +2 -1
  64. package/src/lib/cloud-releases.ts +54 -10
  65. package/src/lib/cloud-values.ts +1 -1
  66. package/src/lib/confirm-action.ts +2 -2
  67. package/src/lib/docker.ts +59 -22
  68. package/src/lib/github-repository.ts +2 -0
  69. package/src/lib/guardian-token.ts +1 -23
  70. package/src/lib/hatch-local.ts +17 -1
  71. package/src/lib/host-teleport.ts +506 -0
  72. package/src/lib/http-client.ts +25 -0
  73. package/src/lib/ingress-config.ts +31 -0
  74. package/src/lib/live-voice/tunnel.test.ts +1 -1
  75. package/src/lib/live-voice/tunnel.ts +1 -1
  76. package/src/lib/local.ts +4 -2
  77. package/src/lib/nginx-ingress.ts +454 -79
  78. package/src/lib/process.ts +13 -0
  79. package/src/lib/root-backup.ts +917 -0
  80. package/src/lib/root-signing-key.ts +31 -0
  81. package/src/lib/runtime-url.ts +0 -29
  82. package/src/lib/teleport-backup.ts +3 -16
  83. package/src/lib/tunnel.ts +1 -0
  84. package/src/lib/upgrade-lifecycle.ts +63 -141
  85. package/src/lib/web-dist.ts +79 -1
  86. package/src/__tests__/bundle-staging.test.ts +0 -268
  87. package/src/__tests__/unpair.test.ts +0 -160
  88. package/src/commands/rollback.ts +0 -479
  89. package/src/commands/unpair.ts +0 -122
  90. package/src/lib/__tests__/job-polling.test.ts +0 -278
  91. package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
  92. package/src/lib/bundle-staging.ts +0 -220
  93. package/src/lib/job-polling.ts +0 -195
  94. 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,14 +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
 
16
12
  import { z } from "zod";
17
13
  import { createHash } from "node:crypto";
14
+ import { hostname } from "node:os";
18
15
 
19
16
  import type { DeviceRecord } from "@mycadet/local-mode";
20
17
 
@@ -24,7 +21,11 @@ import {
24
21
  getAssistantDisplayName,
25
22
  resolveTargetAssistant,
26
23
  } from "../lib/assistant-config";
27
- import { computeDeviceId } from "../lib/guardian-token.js";
24
+ import {
25
+ type GuardianTokenData,
26
+ computeDeviceId,
27
+ loadGuardianToken,
28
+ } from "../lib/guardian-token.js";
28
29
  import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
29
30
  import {
30
31
  CLI_INTERFACE_ID,
@@ -45,7 +46,7 @@ const dockerGatewayResponseSchema = z.object({
45
46
  });
46
47
 
47
48
  function printUsage(): void {
48
- 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
49
50
 
50
51
  USAGE:
51
52
  cadet devices [name]
@@ -60,8 +61,7 @@ OPTIONS:
60
61
  --json Print machine-readable JSON on stdout (revoke requires --yes)
61
62
 
62
63
  Lists the devices paired to a local (host-side) assistant, or revokes one by its
63
- hashed id. Runs on the machine that hosts the assistant — paired connections
64
- imported from another machine are managed with 'cadet unpair' instead.
64
+ hashed id. Runs on the machine that hosts the assistant.
65
65
 
66
66
  EXAMPLES:
67
67
  cadet devices
@@ -86,7 +86,6 @@ function resolveLoopbackBase(entry: AssistantEntry): string {
86
86
  console.error(
87
87
  "Run `cadet devices` on the host that runs the assistant to manage its devices.",
88
88
  );
89
- console.error("To forget this connection here, use `cadet unpair`.");
90
89
  process.exit(1);
91
90
  }
92
91
 
@@ -136,12 +135,38 @@ async function deviceRequest(
136
135
  /**
137
136
  * The hashed device id the gateway stores for THIS machine's own guardian
138
137
  * credential (`leaseGuardianToken` registers it device-bound as
139
- * sha256(deviceId)). It shows up in `/v1/devices` next to remotely paired
140
- * devices; revoking it locks this host out of its own assistant until
141
- * 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.
142
143
  */
143
- function currentHostHashedDeviceId(): string {
144
- 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
+ };
145
170
  }
146
171
 
147
172
  /** Format an epoch-ms timestamp as ISO, or a placeholder when absent. */
@@ -180,13 +205,21 @@ async function listDevices(
180
205
  }
181
206
 
182
207
  const body = (await response.json()) as { devices?: DeviceRecord[] };
183
- const hostHash = currentHostHashedDeviceId();
208
+ const token = loadGuardianToken(entry.assistantId);
209
+ const hostHash = currentHostHashedDeviceId(token);
184
210
  const devices = (body.devices ?? []).map((device) =>
185
211
  device.hashedDeviceId === hostHash
186
212
  ? { ...device, isCurrentHost: true as const }
187
213
  : device,
188
214
  );
189
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
+
190
223
  if (jsonOutput) {
191
224
  console.log(JSON.stringify({ devices }));
192
225
  return;
@@ -250,7 +283,8 @@ async function revokeDevice(
250
283
  // Warn (don't block; recoverable via guardian repair) when the target is
251
284
  // this machine's own host credential. --json is app-driven; the UI disables
252
285
  // the host row, so the warning stays out of automation transcripts.
253
- if (!jsonOutput && hashedDeviceId === currentHostHashedDeviceId()) {
286
+ const localToken = loadGuardianToken(entry.assistantId);
287
+ if (!jsonOutput && hashedDeviceId === currentHostHashedDeviceId(localToken)) {
254
288
  console.error(
255
289
  "Warning: this is this machine's own host credential. Revoking it locks this host out of the assistant until guardian repair.",
256
290
  );
@@ -350,5 +384,5 @@ export const devices = Object.assign(
350
384
  const base = resolveLoopbackBase(entry);
351
385
  await listDevices(entry, base, jsonOutput);
352
386
  },
353
- { description: "List or revoke devices paired to a local assistant [beta]" },
387
+ { description: "List or revoke devices paired to a local assistant" },
354
388
  );
@@ -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),
@@ -90,7 +90,7 @@ const RETIRED_FLAGS = new Map<string, string>([
90
90
  ]);
91
91
 
92
92
  function printUsage(): void {
93
- console.log(`cadet pair [beta] - Print a pairing link and QR code for another device
93
+ console.log(`cadet pair - Print a pairing link and QR code for another device
94
94
 
95
95
  USAGE:
96
96
  cadet pair [assistant] [options]
@@ -608,6 +608,6 @@ export const pair = Object.assign(
608
608
  console.log("");
609
609
  console.log(`Expires: ${challenge.expiresAt}`);
610
610
  },
611
- { description: "Print a pairing link and QR code for another device [beta]" },
611
+ { description: "Print a pairing link and QR code for another device" },
612
612
  );
613
613