botmux 3.3.0 → 3.4.1

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 (159) hide show
  1. package/dist/adapters/backend/sandbox.d.ts +47 -0
  2. package/dist/adapters/backend/sandbox.d.ts.map +1 -1
  3. package/dist/adapters/backend/sandbox.js +137 -0
  4. package/dist/adapters/backend/sandbox.js.map +1 -1
  5. package/dist/adapters/cli/read-isolation.d.ts +72 -3
  6. package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
  7. package/dist/adapters/cli/read-isolation.js +143 -6
  8. package/dist/adapters/cli/read-isolation.js.map +1 -1
  9. package/dist/adapters/cli/registry.d.ts.map +1 -1
  10. package/dist/adapters/cli/registry.js +8 -2
  11. package/dist/adapters/cli/registry.js.map +1 -1
  12. package/dist/cli/create-group-resolver.d.ts +19 -0
  13. package/dist/cli/create-group-resolver.d.ts.map +1 -1
  14. package/dist/cli/create-group-resolver.js +30 -0
  15. package/dist/cli/create-group-resolver.js.map +1 -1
  16. package/dist/cli/pm2-command.d.ts.map +1 -1
  17. package/dist/cli/pm2-command.js +6 -0
  18. package/dist/cli/pm2-command.js.map +1 -1
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +36 -1
  21. package/dist/cli.js.map +1 -1
  22. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  23. package/dist/core/dashboard-ipc-server.js +21 -0
  24. package/dist/core/dashboard-ipc-server.js.map +1 -1
  25. package/dist/core/device-isolation-activation.d.ts +56 -0
  26. package/dist/core/device-isolation-activation.d.ts.map +1 -0
  27. package/dist/core/device-isolation-activation.js +110 -0
  28. package/dist/core/device-isolation-activation.js.map +1 -0
  29. package/dist/core/device-isolation-daemon.d.ts +84 -0
  30. package/dist/core/device-isolation-daemon.d.ts.map +1 -0
  31. package/dist/core/device-isolation-daemon.js +591 -0
  32. package/dist/core/device-isolation-daemon.js.map +1 -0
  33. package/dist/core/types.d.ts +9 -0
  34. package/dist/core/types.d.ts.map +1 -1
  35. package/dist/core/types.js.map +1 -1
  36. package/dist/core/worker-pool.d.ts.map +1 -1
  37. package/dist/core/worker-pool.js +35 -0
  38. package/dist/core/worker-pool.js.map +1 -1
  39. package/dist/daemon.d.ts.map +1 -1
  40. package/dist/daemon.js +5 -0
  41. package/dist/daemon.js.map +1 -1
  42. package/dist/desktop/main/auto-start.d.ts +2 -0
  43. package/dist/desktop/main/auto-start.d.ts.map +1 -1
  44. package/dist/desktop/main/auto-start.js +10 -6
  45. package/dist/desktop/main/auto-start.js.map +1 -1
  46. package/dist/desktop/main/bundled-runtime.d.ts +12 -0
  47. package/dist/desktop/main/bundled-runtime.d.ts.map +1 -0
  48. package/dist/desktop/main/bundled-runtime.js +30 -0
  49. package/dist/desktop/main/bundled-runtime.js.map +1 -0
  50. package/dist/desktop/main/device-status.d.ts +15 -0
  51. package/dist/desktop/main/device-status.d.ts.map +1 -0
  52. package/dist/desktop/main/device-status.js +94 -0
  53. package/dist/desktop/main/device-status.js.map +1 -0
  54. package/dist/desktop/main/external-runtime.d.ts +9 -0
  55. package/dist/desktop/main/external-runtime.d.ts.map +1 -1
  56. package/dist/desktop/main/external-runtime.js +88 -18
  57. package/dist/desktop/main/external-runtime.js.map +1 -1
  58. package/dist/desktop/main/ipc.d.ts.map +1 -1
  59. package/dist/desktop/main/ipc.js +1 -0
  60. package/dist/desktop/main/ipc.js.map +1 -1
  61. package/dist/desktop/main/node-command.d.ts +21 -0
  62. package/dist/desktop/main/node-command.d.ts.map +1 -1
  63. package/dist/desktop/main/node-command.js +41 -0
  64. package/dist/desktop/main/node-command.js.map +1 -1
  65. package/dist/desktop/main/pm2-apps.d.ts +4 -2
  66. package/dist/desktop/main/pm2-apps.d.ts.map +1 -1
  67. package/dist/desktop/main/pm2-apps.js +11 -3
  68. package/dist/desktop/main/pm2-apps.js.map +1 -1
  69. package/dist/desktop/main/runtime-service.d.ts +21 -5
  70. package/dist/desktop/main/runtime-service.d.ts.map +1 -1
  71. package/dist/desktop/main/runtime-service.js +94 -10
  72. package/dist/desktop/main/runtime-service.js.map +1 -1
  73. package/dist/desktop/main/runtime-source.d.ts +2 -0
  74. package/dist/desktop/main/runtime-source.d.ts.map +1 -1
  75. package/dist/desktop/main/runtime-source.js +12 -6
  76. package/dist/desktop/main/runtime-source.js.map +1 -1
  77. package/dist/desktop/main/tray.d.ts +1 -0
  78. package/dist/desktop/main/tray.d.ts.map +1 -1
  79. package/dist/desktop/main/tray.js +3 -3
  80. package/dist/desktop/main/tray.js.map +1 -1
  81. package/dist/desktop/main.js +28 -8
  82. package/dist/desktop/main.js.map +1 -1
  83. package/dist/desktop/preload.js +3 -0
  84. package/dist/desktop/preload.js.map +1 -1
  85. package/dist/desktop/renderer/app.d.ts +2 -1
  86. package/dist/desktop/renderer/app.d.ts.map +1 -1
  87. package/dist/desktop/renderer/app.js +6 -1
  88. package/dist/desktop/renderer/app.js.map +1 -1
  89. package/dist/desktop/renderer/runtime-state.d.ts.map +1 -1
  90. package/dist/desktop/renderer/runtime-state.js +2 -0
  91. package/dist/desktop/renderer/runtime-state.js.map +1 -1
  92. package/dist/desktop/shared/shell-path-probes.d.ts +24 -0
  93. package/dist/desktop/shared/shell-path-probes.d.ts.map +1 -0
  94. package/dist/desktop/shared/shell-path-probes.js +38 -0
  95. package/dist/desktop/shared/shell-path-probes.js.map +1 -0
  96. package/dist/desktop/shared/types.d.ts +21 -1
  97. package/dist/desktop/shared/types.d.ts.map +1 -1
  98. package/dist/desktop/smoke.d.ts +1 -0
  99. package/dist/desktop/smoke.d.ts.map +1 -1
  100. package/dist/desktop/smoke.js +20 -5
  101. package/dist/desktop/smoke.js.map +1 -1
  102. package/dist/platform/bind.d.ts +4 -1
  103. package/dist/platform/bind.d.ts.map +1 -1
  104. package/dist/platform/bind.js +10 -1
  105. package/dist/platform/bind.js.map +1 -1
  106. package/dist/platform/binding.d.ts.map +1 -1
  107. package/dist/platform/binding.js +15 -9
  108. package/dist/platform/binding.js.map +1 -1
  109. package/dist/platform/device-command.d.ts +35 -0
  110. package/dist/platform/device-command.d.ts.map +1 -0
  111. package/dist/platform/device-command.js +187 -0
  112. package/dist/platform/device-command.js.map +1 -0
  113. package/dist/platform/device-enroll.d.ts +156 -0
  114. package/dist/platform/device-enroll.d.ts.map +1 -0
  115. package/dist/platform/device-enroll.js +781 -0
  116. package/dist/platform/device-enroll.js.map +1 -0
  117. package/dist/platform/device-isolation-activation-client.d.ts +28 -0
  118. package/dist/platform/device-isolation-activation-client.d.ts.map +1 -0
  119. package/dist/platform/device-isolation-activation-client.js +279 -0
  120. package/dist/platform/device-isolation-activation-client.js.map +1 -0
  121. package/dist/platform/device-isolation.d.ts +35 -0
  122. package/dist/platform/device-isolation.d.ts.map +1 -0
  123. package/dist/platform/device-isolation.js +90 -0
  124. package/dist/platform/device-isolation.js.map +1 -0
  125. package/dist/platform/device-paths.d.ts +9 -0
  126. package/dist/platform/device-paths.d.ts.map +1 -0
  127. package/dist/platform/device-paths.js +14 -0
  128. package/dist/platform/device-paths.js.map +1 -0
  129. package/dist/platform/device.d.ts +77 -0
  130. package/dist/platform/device.d.ts.map +1 -0
  131. package/dist/platform/device.js +212 -0
  132. package/dist/platform/device.js.map +1 -0
  133. package/dist/platform/host-command-context.d.ts +15 -0
  134. package/dist/platform/host-command-context.d.ts.map +1 -0
  135. package/dist/platform/host-command-context.js +26 -0
  136. package/dist/platform/host-command-context.js.map +1 -0
  137. package/dist/platform/secure-host-file.d.ts +12 -0
  138. package/dist/platform/secure-host-file.d.ts.map +1 -0
  139. package/dist/platform/secure-host-file.js +182 -0
  140. package/dist/platform/secure-host-file.js.map +1 -0
  141. package/dist/services/group-creator.d.ts +8 -0
  142. package/dist/services/group-creator.d.ts.map +1 -1
  143. package/dist/services/group-creator.js +42 -0
  144. package/dist/services/group-creator.js.map +1 -1
  145. package/dist/types.d.ts +9 -0
  146. package/dist/types.d.ts.map +1 -1
  147. package/dist/utils/atomic-write.d.ts +13 -0
  148. package/dist/utils/atomic-write.d.ts.map +1 -1
  149. package/dist/utils/atomic-write.js +89 -9
  150. package/dist/utils/atomic-write.js.map +1 -1
  151. package/dist/utils/input-gate.d.ts +41 -0
  152. package/dist/utils/input-gate.d.ts.map +1 -1
  153. package/dist/utils/input-gate.js +41 -0
  154. package/dist/utils/input-gate.js.map +1 -1
  155. package/dist/worker.js +328 -22
  156. package/dist/worker.js.map +1 -1
  157. package/dist/workflows/events/payloads.d.ts +4 -4
  158. package/dist/workflows/events/schema.d.ts +16 -16
  159. package/package.json +6 -3
@@ -0,0 +1,781 @@
1
+ /**
2
+ * Platform-neutral desktop enrollment protocol client.
3
+ *
4
+ * The platform origin is supplied at runtime by platform.json and is pinned by
5
+ * device.ts. No deployment hostname belongs in the open-source client. Tokens
6
+ * are carried only in an Authorization header or JSON request body; this module
7
+ * never logs, formats, or places them in URLs.
8
+ */
9
+ import { randomBytes } from 'node:crypto';
10
+ import { request as httpRequest } from 'node:http';
11
+ import { request as httpsRequest } from 'node:https';
12
+ import { mkdirSync } from 'node:fs';
13
+ import { dirname, join } from 'node:path';
14
+ import { withFileLock } from '../utils/file-lock.js';
15
+ import { clearDeviceCredentials, DeviceCredentialError, deviceCredentialsPath, normalizeDeviceIssuer, readDeviceCredentials, writeDeviceCredentials, } from './device.js';
16
+ import { readSecureHostFileSync, unlinkSecureHostFileSync, writeSecureHostFileSync, } from './secure-host-file.js';
17
+ export const DEVICE_ENROLL_ENDPOINTS = Object.freeze({
18
+ begin: '/api/devices/enroll',
19
+ poll: '/api/devices/enroll/poll',
20
+ refresh: '/api/devices/refresh',
21
+ });
22
+ const REFRESH_REPLAY_WINDOW_MS = 60_000;
23
+ // Leave transport/scheduler jitter before the server's hard replay boundary.
24
+ const REFRESH_SAFE_REPLAY_WINDOW_MS = 50_000;
25
+ const REFRESH_RETRY_DELAYS_MS = [250, 750];
26
+ const REFRESH_TRANSIENT_HTTP_STATUSES = new Set([408, 425, 429]);
27
+ export class DeviceProtocolError extends Error {
28
+ code;
29
+ status;
30
+ serverCode;
31
+ retryAfterMs;
32
+ constructor(message, code, status, serverCode,
33
+ /** Parsed and bounded Retry-After delay for a transient HTTP response. */
34
+ retryAfterMs) {
35
+ super(message);
36
+ this.code = code;
37
+ this.status = status;
38
+ this.serverCode = serverCode;
39
+ this.retryAfterMs = retryAfterMs;
40
+ this.name = 'DeviceProtocolError';
41
+ }
42
+ }
43
+ const DEVICE_PROTOCOL_SERVER_CODES = new Set([
44
+ 'bad_request',
45
+ 'invalid_refresh',
46
+ 'device_gone',
47
+ 'device_revoked',
48
+ 'device_expired',
49
+ 'recovery_required',
50
+ 'refresh_in_progress',
51
+ 'store_unavailable',
52
+ ]);
53
+ function isRecord(value) {
54
+ return !!value && typeof value === 'object' && !Array.isArray(value);
55
+ }
56
+ function parseBoundedRetryAfterMs(response, nowMs, maxMs = REFRESH_SAFE_REPLAY_WINDOW_MS) {
57
+ const raw = response.headers?.['retry-after']?.trim();
58
+ if (!raw)
59
+ return undefined;
60
+ if (/^\d+$/.test(raw)) {
61
+ const seconds = Number(raw);
62
+ if (!Number.isFinite(seconds))
63
+ return maxMs;
64
+ return Math.min(maxMs, seconds * 1_000);
65
+ }
66
+ const retryAt = Date.parse(raw);
67
+ if (!Number.isFinite(retryAt))
68
+ return undefined;
69
+ return Math.min(maxMs, Math.max(0, retryAt - nowMs));
70
+ }
71
+ function rejected(response, operation, nowMs = Date.now()) {
72
+ // Do not reflect response bodies: a misbehaving server/proxy may echo a
73
+ // machine bearer, poll secret, or rotating refresh token in its details.
74
+ const rawCode = isRecord(response.body) ? response.body.error : undefined;
75
+ const candidate = typeof rawCode === 'string'
76
+ && DEVICE_PROTOCOL_SERVER_CODES.has(rawCode)
77
+ ? rawCode
78
+ : undefined;
79
+ // Status is part of the signed protocol contract. In particular, a proxy's
80
+ // transient 429/5xx body must not trick the client into clearing or
81
+ // permanently blocking a still-valid refresh credential.
82
+ const serverCode = candidate && ((response.status === 400 && candidate === 'bad_request')
83
+ || (response.status === 401 && (candidate === 'invalid_refresh'
84
+ || candidate === 'device_gone'
85
+ || candidate === 'device_revoked'
86
+ || candidate === 'device_expired'
87
+ || candidate === 'recovery_required'))
88
+ || (response.status === 409 && candidate === 'refresh_in_progress')
89
+ || (response.status === 503 && candidate === 'store_unavailable')) ? candidate : undefined;
90
+ return new DeviceProtocolError(`${operation}被平台拒绝:HTTP ${response.status}`, 'request_rejected', response.status, serverCode, response.status === 429 ? parseBoundedRetryAfterMs(response, nowMs) : undefined);
91
+ }
92
+ function requiredString(record, key) {
93
+ const value = record[key];
94
+ return typeof value === 'string' && value.length > 0 ? value : null;
95
+ }
96
+ function optionalFiniteNumber(record, key) {
97
+ const value = record[key];
98
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined;
99
+ }
100
+ function parseGrant(body) {
101
+ if (!isRecord(body)) {
102
+ throw new DeviceProtocolError('平台注册响应结构无效', 'invalid_response');
103
+ }
104
+ const grantId = requiredString(body, 'grantId');
105
+ const pollSecret = requiredString(body, 'pollSecret');
106
+ const expiresAt = optionalFiniteNumber(body, 'expiresAt');
107
+ if (!grantId || !pollSecret || expiresAt === undefined) {
108
+ throw new DeviceProtocolError('平台注册响应缺少 grantId、pollSecret 或 expiresAt', 'invalid_response');
109
+ }
110
+ return {
111
+ grantId,
112
+ pollSecret,
113
+ expiresAt,
114
+ ...(optionalFiniteNumber(body, 'pollIntervalMs') !== undefined
115
+ ? { pollIntervalMs: optionalFiniteNumber(body, 'pollIntervalMs') }
116
+ : {}),
117
+ };
118
+ }
119
+ function parseTokenPair(body) {
120
+ if (!isRecord(body)) {
121
+ throw new DeviceProtocolError('平台凭证响应结构无效', 'invalid_response');
122
+ }
123
+ const accessToken = requiredString(body, 'accessToken');
124
+ const accessExpiresAt = optionalFiniteNumber(body, 'accessExpiresAt');
125
+ const refreshToken = requiredString(body, 'refreshToken');
126
+ const deviceExp = optionalFiniteNumber(body, 'deviceExp');
127
+ if (!accessToken
128
+ || accessExpiresAt === undefined
129
+ || !refreshToken
130
+ || deviceExp === undefined
131
+ || accessExpiresAt > deviceExp) {
132
+ throw new DeviceProtocolError('平台凭证响应缺少必要字段', 'invalid_response');
133
+ }
134
+ return { accessToken, accessExpiresAt, refreshToken, deviceExp };
135
+ }
136
+ function retryAfterMs(response) {
137
+ const raw = response.headers?.['retry-after'];
138
+ if (raw && /^\d+$/.test(raw.trim())) {
139
+ return Math.min(10_000, Math.max(1_000, Number(raw) * 1_000));
140
+ }
141
+ if (isRecord(response.body)) {
142
+ const fromBody = optionalFiniteNumber(response.body, 'retryAfterMs');
143
+ if (fromBody !== undefined)
144
+ return Math.min(10_000, Math.max(1_000, fromBody));
145
+ const pollInterval = optionalFiniteNumber(response.body, 'pollIntervalMs');
146
+ if (pollInterval !== undefined)
147
+ return Math.min(10_000, Math.max(1_000, pollInterval));
148
+ }
149
+ return 2_000;
150
+ }
151
+ function abortError() {
152
+ return new DeviceProtocolError('设备注册已取消', 'aborted');
153
+ }
154
+ function defaultSleep(ms, signal) {
155
+ if (signal?.aborted)
156
+ return Promise.reject(abortError());
157
+ return new Promise((resolve, reject) => {
158
+ const finish = () => {
159
+ if (signal)
160
+ signal.removeEventListener('abort', onAbort);
161
+ resolve();
162
+ };
163
+ const timer = setTimeout(finish, ms);
164
+ const onAbort = () => {
165
+ clearTimeout(timer);
166
+ signal?.removeEventListener('abort', onAbort);
167
+ reject(abortError());
168
+ };
169
+ signal?.addEventListener('abort', onAbort, { once: true });
170
+ });
171
+ }
172
+ /**
173
+ * Small JSON transport with a bounded response and no redirect following.
174
+ * Redirects are surfaced as a rejection so a bearer can never cross origins.
175
+ */
176
+ export const nodeDeviceHttpTransport = async (input) => {
177
+ const url = new URL(input.url);
178
+ const requestFn = url.protocol === 'https:' ? httpsRequest : httpRequest;
179
+ const payload = Buffer.from(JSON.stringify(input.body), 'utf8');
180
+ return await new Promise((resolve, reject) => {
181
+ const req = requestFn({
182
+ protocol: url.protocol,
183
+ hostname: url.hostname,
184
+ port: url.port || undefined,
185
+ path: `${url.pathname}${url.search}`,
186
+ method: input.method,
187
+ timeout: input.timeoutMs,
188
+ signal: input.signal,
189
+ headers: {
190
+ ...input.headers,
191
+ 'content-type': 'application/json',
192
+ 'content-length': String(payload.length),
193
+ accept: 'application/json',
194
+ },
195
+ }, (response) => {
196
+ const chunks = [];
197
+ let size = 0;
198
+ response.on('data', (chunk) => {
199
+ size += chunk.length;
200
+ if (size > 64 * 1024) {
201
+ response.destroy(new Error('device response too large'));
202
+ return;
203
+ }
204
+ chunks.push(chunk);
205
+ });
206
+ response.on('end', () => {
207
+ let body = {};
208
+ const raw = Buffer.concat(chunks).toString('utf8');
209
+ if (raw) {
210
+ try {
211
+ body = JSON.parse(raw);
212
+ }
213
+ catch {
214
+ body = {};
215
+ }
216
+ }
217
+ const headers = {};
218
+ for (const [key, value] of Object.entries(response.headers)) {
219
+ headers[key.toLowerCase()] = Array.isArray(value) ? value.join(', ') : value;
220
+ }
221
+ resolve({ status: response.statusCode ?? 0, body, headers });
222
+ });
223
+ response.on('error', reject);
224
+ });
225
+ req.on('timeout', () => req.destroy(new Error('device request timeout')));
226
+ req.on('error', reject);
227
+ req.end(payload);
228
+ });
229
+ };
230
+ export class DeviceEnrollmentClient {
231
+ issuer;
232
+ transport;
233
+ endpoints;
234
+ timeoutMs;
235
+ now;
236
+ sleep;
237
+ constructor(issuer, options = {}) {
238
+ this.issuer = normalizeDeviceIssuer(issuer);
239
+ this.transport = options.transport ?? nodeDeviceHttpTransport;
240
+ const requestedEndpoints = { ...DEVICE_ENROLL_ENDPOINTS, ...options.endpoints };
241
+ this.endpoints = {
242
+ begin: this.normalizeEndpoint(requestedEndpoints.begin),
243
+ poll: this.normalizeEndpoint(requestedEndpoints.poll),
244
+ refresh: this.normalizeEndpoint(requestedEndpoints.refresh),
245
+ };
246
+ this.timeoutMs = options.timeoutMs ?? 10_000;
247
+ this.now = options.now ?? Date.now;
248
+ this.sleep = options.sleep ?? defaultSleep;
249
+ }
250
+ normalizeEndpoint(endpoint) {
251
+ if (!endpoint.startsWith('/')
252
+ || endpoint.startsWith('//')
253
+ || endpoint.includes('\\')
254
+ || endpoint.includes('?')
255
+ || endpoint.includes('#')) {
256
+ throw new DeviceProtocolError('设备协议 endpoint 必须是同源绝对路径', 'invalid_response');
257
+ }
258
+ let resolved;
259
+ try {
260
+ resolved = new URL(endpoint, `${this.issuer}/`);
261
+ }
262
+ catch {
263
+ throw new DeviceProtocolError('设备协议 endpoint 无效', 'invalid_response');
264
+ }
265
+ if (resolved.origin !== this.issuer
266
+ || resolved.username
267
+ || resolved.password
268
+ || resolved.search
269
+ || resolved.hash) {
270
+ throw new DeviceProtocolError('设备协议 endpoint 必须保持同源', 'invalid_response');
271
+ }
272
+ return resolved.pathname;
273
+ }
274
+ async post(path, body, headers = {}, signal) {
275
+ if (signal?.aborted)
276
+ throw abortError();
277
+ try {
278
+ const resolved = new URL(path, `${this.issuer}/`);
279
+ // Defense in depth: endpoint configuration is validated in the
280
+ // constructor, but a bearer must never leave the pinned issuer even if a
281
+ // future refactor routes another path through this helper.
282
+ if (resolved.origin !== this.issuer || resolved.username || resolved.password || resolved.hash) {
283
+ throw new DeviceProtocolError('设备协议请求试图离开固定平台 origin', 'invalid_response');
284
+ }
285
+ return await this.transport({
286
+ url: resolved.toString(),
287
+ method: 'POST',
288
+ headers,
289
+ body,
290
+ timeoutMs: this.timeoutMs,
291
+ signal,
292
+ });
293
+ }
294
+ catch (err) {
295
+ if (signal?.aborted || (err instanceof DeviceProtocolError && err.code === 'aborted')) {
296
+ throw abortError();
297
+ }
298
+ if (err instanceof DeviceProtocolError)
299
+ throw err;
300
+ // Raw socket errors can contain host details but never need to contain a
301
+ // credential; still replace them with a stable, non-reflective message.
302
+ throw new DeviceProtocolError('无法连接设备平台', 'network_error');
303
+ }
304
+ }
305
+ async beginEnrollment(input) {
306
+ if (!input.machineToken || !input.deviceName.trim()) {
307
+ throw new DeviceProtocolError('缺少机器凭证或设备名', 'invalid_response');
308
+ }
309
+ const response = await this.post(this.endpoints.begin, {
310
+ deviceKind: input.deviceKind ?? 'desktop-ide',
311
+ deviceName: input.deviceName.trim(),
312
+ }, { authorization: `Bearer ${input.machineToken}` }, input.signal);
313
+ if (response.status < 200 || response.status >= 300)
314
+ throw rejected(response, '设备注册');
315
+ return parseGrant(response.body);
316
+ }
317
+ async pollEnrollment(grant, signal) {
318
+ const response = await this.post(this.endpoints.poll, { grantId: grant.grantId, pollSecret: grant.pollSecret }, {}, signal);
319
+ if (response.status === 202 || response.status === 204) {
320
+ return { kind: 'pending', retryAfterMs: retryAfterMs(response) };
321
+ }
322
+ if (response.status < 200 || response.status >= 300)
323
+ throw rejected(response, '设备确认');
324
+ if (isRecord(response.body)) {
325
+ if (response.body.status === 'pending') {
326
+ return { kind: 'pending', retryAfterMs: retryAfterMs(response) };
327
+ }
328
+ if (response.body.status === 'denied')
329
+ return { kind: 'denied' };
330
+ if (response.body.status === 'expired')
331
+ return { kind: 'expired' };
332
+ if (response.body.status === 'issued') {
333
+ return { kind: 'issued', credentials: parseTokenPair(response.body) };
334
+ }
335
+ }
336
+ // Compatibility with a staged server that returned the token pair without
337
+ // an explicit status discriminator.
338
+ return { kind: 'issued', credentials: parseTokenPair(response.body) };
339
+ }
340
+ async waitForEnrollment(grant, options = {}) {
341
+ const localDeadline = this.now() + (options.timeoutMs ?? 5 * 60_000);
342
+ const deadline = Math.min(localDeadline, grant.expiresAt);
343
+ let nextDelay = Math.min(10_000, Math.max(1_000, grant.pollIntervalMs ?? 2_000));
344
+ while (this.now() < deadline) {
345
+ const result = await this.pollEnrollment(grant, options.signal);
346
+ if (result.kind === 'issued')
347
+ return result.credentials;
348
+ if (result.kind === 'denied') {
349
+ throw new DeviceProtocolError('设备注册已被 owner 拒绝', 'enrollment_denied');
350
+ }
351
+ if (result.kind === 'expired') {
352
+ throw new DeviceProtocolError('设备确认已过期,请重新执行注册', 'enrollment_expired');
353
+ }
354
+ options.onPending?.();
355
+ nextDelay = Math.max(nextDelay, result.retryAfterMs);
356
+ const remaining = deadline - this.now();
357
+ if (remaining <= 0)
358
+ break;
359
+ await this.sleep(Math.min(nextDelay, remaining), options.signal);
360
+ }
361
+ throw new DeviceProtocolError('设备确认已超时,请重新执行注册', 'enrollment_timeout');
362
+ }
363
+ /**
364
+ * Send one refresh attempt. The stored-credential coordinator performs
365
+ * bounded same-key retries for ambiguous failures inside the replay window;
366
+ * keeping retry policy outside this wire primitive also keeps direct callers
367
+ * from accidentally retrying with a new key.
368
+ */
369
+ async refresh(refreshToken, options) {
370
+ if (!refreshToken)
371
+ throw new DeviceProtocolError('缺少 refresh token', 'invalid_response');
372
+ if (!/^[a-zA-Z0-9._:-]{16,128}$/.test(options.requestId)) {
373
+ throw new DeviceProtocolError('设备续期 request ID 无效', 'invalid_response');
374
+ }
375
+ const response = await this.post(this.endpoints.refresh, { refreshToken }, { 'idempotency-key': options.requestId }, options.signal);
376
+ if (response.status === 409) {
377
+ throw new DeviceProtocolError('设备续期请求无法安全重放;请重新注册,或先在平台确认设备是否已被吊销', 'request_rejected', response.status, 'refresh_in_progress');
378
+ }
379
+ if (response.status < 200 || response.status >= 300) {
380
+ throw rejected(response, '设备续期', this.now());
381
+ }
382
+ return parseTokenPair(response.body);
383
+ }
384
+ }
385
+ export { DEVICE_ENROLLMENT_JOURNAL_FILE } from './device-paths.js';
386
+ import { DEVICE_ENROLLMENT_JOURNAL_FILE } from './device-paths.js';
387
+ const DEVICE_ENROLLMENT_JOURNAL_SCHEMA_VERSION = 1;
388
+ export function deviceEnrollmentJournalPath(options = {}) {
389
+ return join(dirname(deviceCredentialsPath(options)), DEVICE_ENROLLMENT_JOURNAL_FILE);
390
+ }
391
+ function parseDeviceEnrollmentJournal(raw) {
392
+ let value;
393
+ try {
394
+ value = JSON.parse(raw);
395
+ }
396
+ catch {
397
+ throw new DeviceCredentialError('设备注册恢复日志已损坏', 'invalid_file');
398
+ }
399
+ if (!isRecord(value)) {
400
+ throw new DeviceCredentialError('设备注册恢复日志结构无效', 'invalid_file');
401
+ }
402
+ const issuer = typeof value.issuer === 'string' ? normalizeDeviceIssuer(value.issuer) : '';
403
+ if (value.schemaVersion !== DEVICE_ENROLLMENT_JOURNAL_SCHEMA_VERSION
404
+ || issuer !== value.issuer
405
+ || typeof value.grantId !== 'string'
406
+ || !/^[a-zA-Z0-9._:-]{1,128}$/.test(value.grantId)
407
+ || typeof value.pollSecret !== 'string'
408
+ || value.pollSecret.length < 1
409
+ || value.pollSecret.length > 512
410
+ || typeof value.expiresAt !== 'number'
411
+ || !Number.isSafeInteger(value.expiresAt)
412
+ || value.expiresAt <= 0
413
+ || (value.pollIntervalMs !== undefined && (typeof value.pollIntervalMs !== 'number'
414
+ || !Number.isSafeInteger(value.pollIntervalMs)
415
+ || value.pollIntervalMs <= 0))
416
+ || typeof value.deviceName !== 'string'
417
+ || !value.deviceName
418
+ || value.deviceName.length > 80
419
+ || typeof value.createdAt !== 'number'
420
+ || !Number.isSafeInteger(value.createdAt)
421
+ || value.createdAt <= 0
422
+ || value.createdAt > value.expiresAt) {
423
+ throw new DeviceCredentialError('设备注册恢复日志字段无效', 'invalid_file');
424
+ }
425
+ return {
426
+ schemaVersion: DEVICE_ENROLLMENT_JOURNAL_SCHEMA_VERSION,
427
+ issuer,
428
+ grantId: value.grantId,
429
+ pollSecret: value.pollSecret,
430
+ expiresAt: value.expiresAt,
431
+ ...(typeof value.pollIntervalMs === 'number' ? { pollIntervalMs: value.pollIntervalMs } : {}),
432
+ deviceName: value.deviceName,
433
+ createdAt: value.createdAt,
434
+ };
435
+ }
436
+ function readDeviceEnrollmentJournal(options) {
437
+ const raw = readSecureHostFileSync(deviceEnrollmentJournalPath(options));
438
+ return raw === null ? null : parseDeviceEnrollmentJournal(raw);
439
+ }
440
+ function writeDeviceEnrollmentJournal(journal, options) {
441
+ writeSecureHostFileSync(deviceEnrollmentJournalPath(options), `${JSON.stringify(journal, null, 2)}\n`);
442
+ }
443
+ function clearDeviceEnrollmentJournal(options) {
444
+ return unlinkSecureHostFileSync(deviceEnrollmentJournalPath(options));
445
+ }
446
+ function sameGrant(left, right) {
447
+ return left.grantId === right.grantId && left.pollSecret === right.pollSecret;
448
+ }
449
+ /**
450
+ * Crash-safe F1 enrollment coordinator.
451
+ *
452
+ * The grant/poll secret is durably journaled before polling. A later host
453
+ * process resumes the same grant, and an issued response is installed together
454
+ * with journal cleanup under the same device lock.
455
+ */
456
+ export async function enrollStoredDeviceCredentials(options) {
457
+ const pathOptions = {
458
+ ...(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
459
+ ...(options.filePath !== undefined ? { filePath: options.filePath } : {}),
460
+ };
461
+ const filePath = deviceCredentialsPath(pathOptions);
462
+ mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
463
+ const now = () => (options.now ?? (() => new Date()))().getTime();
464
+ let journal = await withFileLock(filePath, async () => {
465
+ if (readDeviceCredentials(pathOptions)) {
466
+ throw new DeviceCredentialError('本机已有 desktop device 凭证', 'invalid_file');
467
+ }
468
+ const existing = readDeviceEnrollmentJournal(pathOptions);
469
+ if (!existing)
470
+ return null;
471
+ if (existing.expiresAt <= now()) {
472
+ clearDeviceEnrollmentJournal(pathOptions);
473
+ return null;
474
+ }
475
+ if (existing.issuer !== normalizeDeviceIssuer(options.client.issuer)) {
476
+ throw new DeviceCredentialError('未完成的设备注册已固定到另一个平台;请等待其过期或执行 logout 清理', 'issuer_mismatch');
477
+ }
478
+ return existing;
479
+ }, { maxWaitMs: 15_000 });
480
+ if (!journal) {
481
+ const grant = await options.client.beginEnrollment({
482
+ machineToken: options.machineToken,
483
+ deviceName: options.deviceName,
484
+ deviceKind: 'desktop-ide',
485
+ signal: options.signal,
486
+ });
487
+ if (grant.expiresAt <= now()) {
488
+ throw new DeviceProtocolError('平台返回的设备 grant 已过期', 'invalid_response');
489
+ }
490
+ const proposed = {
491
+ schemaVersion: DEVICE_ENROLLMENT_JOURNAL_SCHEMA_VERSION,
492
+ issuer: normalizeDeviceIssuer(options.client.issuer),
493
+ grantId: grant.grantId,
494
+ pollSecret: grant.pollSecret,
495
+ expiresAt: grant.expiresAt,
496
+ ...(grant.pollIntervalMs !== undefined ? { pollIntervalMs: grant.pollIntervalMs } : {}),
497
+ deviceName: options.deviceName,
498
+ createdAt: now(),
499
+ };
500
+ journal = await withFileLock(filePath, async () => {
501
+ if (readDeviceCredentials(pathOptions)) {
502
+ throw new DeviceCredentialError('并发设备注册已完成;未覆盖现有凭证', 'invalid_file');
503
+ }
504
+ const existing = readDeviceEnrollmentJournal(pathOptions);
505
+ if (existing && existing.expiresAt > now()) {
506
+ if (existing.issuer !== proposed.issuer) {
507
+ throw new DeviceCredentialError('并发设备注册的平台不一致', 'issuer_mismatch');
508
+ }
509
+ return existing;
510
+ }
511
+ if (existing)
512
+ clearDeviceEnrollmentJournal(pathOptions);
513
+ writeDeviceEnrollmentJournal(proposed, pathOptions);
514
+ return proposed;
515
+ }, { maxWaitMs: 15_000 });
516
+ }
517
+ let credentials;
518
+ options.onGrantReady?.();
519
+ try {
520
+ credentials = await options.client.waitForEnrollment({
521
+ grantId: journal.grantId,
522
+ pollSecret: journal.pollSecret,
523
+ expiresAt: journal.expiresAt,
524
+ ...(journal.pollIntervalMs !== undefined ? { pollIntervalMs: journal.pollIntervalMs } : {}),
525
+ }, {
526
+ signal: options.signal,
527
+ onPending: options.onPending,
528
+ timeoutMs: Math.max(1, journal.expiresAt - now()),
529
+ });
530
+ }
531
+ catch (error) {
532
+ if (error instanceof DeviceProtocolError
533
+ && (error.code === 'enrollment_denied'
534
+ || error.code === 'enrollment_expired'
535
+ || error.code === 'enrollment_timeout')) {
536
+ await withFileLock(filePath, async () => {
537
+ const latest = readDeviceEnrollmentJournal(pathOptions);
538
+ if (latest && sameGrant(latest, journal))
539
+ clearDeviceEnrollmentJournal(pathOptions);
540
+ }, { maxWaitMs: 15_000 });
541
+ }
542
+ throw error;
543
+ }
544
+ return await withFileLock(filePath, async () => {
545
+ const existingDevice = readDeviceCredentials(pathOptions);
546
+ if (existingDevice) {
547
+ // A concurrent poll of this same durable grant already installed the
548
+ // server-identical token bytes. Preserve its local savedAt and finish.
549
+ const latestJournal = readDeviceEnrollmentJournal(pathOptions);
550
+ if (latestJournal && sameGrant(latestJournal, journal)) {
551
+ clearDeviceEnrollmentJournal(pathOptions);
552
+ }
553
+ return existingDevice;
554
+ }
555
+ const latestJournal = readDeviceEnrollmentJournal(pathOptions);
556
+ if (!latestJournal || !sameGrant(latestJournal, journal)) {
557
+ throw new DeviceCredentialError('设备注册恢复日志已被另一个流程替换', 'invalid_file');
558
+ }
559
+ const stored = writeDeviceCredentials({ issuer: journal.issuer, ...credentials }, { ...pathOptions, ...(options.now ? { now: options.now } : {}) });
560
+ clearDeviceEnrollmentJournal(pathOptions);
561
+ return stored;
562
+ }, { maxWaitMs: 15_000 });
563
+ }
564
+ function refreshNowMs(options) {
565
+ return (options.now ?? (() => new Date()))().getTime();
566
+ }
567
+ function generateRefreshRequestId() {
568
+ // 128 random bits, encoded without padding for the HTTP header.
569
+ return randomBytes(16).toString('base64url');
570
+ }
571
+ function refreshErrorIsAmbiguousRetryable(error) {
572
+ return error instanceof DeviceProtocolError && (error.code === 'network_error'
573
+ || error.code === 'invalid_response'
574
+ || (error.code === 'request_rejected' && (error.status ?? 0) >= 500)
575
+ || (error.code === 'request_rejected'
576
+ && error.status !== undefined
577
+ && REFRESH_TRANSIENT_HTTP_STATUSES.has(error.status)));
578
+ }
579
+ function refreshErrorRequiresLocalClear(error) {
580
+ if (!(error instanceof DeviceProtocolError))
581
+ return false;
582
+ // A status-bearing transport response takes precedence over an inconsistent
583
+ // body/code supplied by a proxy or custom transport.
584
+ if (error.status !== undefined && error.status !== 401)
585
+ return false;
586
+ return (error.serverCode === 'device_gone'
587
+ || error.serverCode === 'device_revoked'
588
+ || error.serverCode === 'device_expired'
589
+ || error.serverCode === 'recovery_required');
590
+ }
591
+ function refreshErrorRequiresRecoveryBlock(error) {
592
+ if (!(error instanceof DeviceProtocolError))
593
+ return false;
594
+ if (error.status !== undefined && (error.status >= 500
595
+ || error.status === 409
596
+ || REFRESH_TRANSIENT_HTTP_STATUSES.has(error.status)))
597
+ return false;
598
+ return (error.serverCode === 'invalid_refresh'
599
+ || error.serverCode === 'bad_request'
600
+ || (error.code === 'request_rejected'
601
+ && error.status !== undefined
602
+ && error.status >= 400
603
+ && error.status < 500));
604
+ }
605
+ function writeRefreshRecoveryRequired(current, pathOptions, now) {
606
+ return writeDeviceCredentials({
607
+ issuer: current.issuer,
608
+ accessToken: current.accessToken,
609
+ accessExpiresAt: current.accessExpiresAt,
610
+ refreshToken: current.refreshToken,
611
+ deviceExp: current.deviceExp,
612
+ refreshRecoveryRequired: true,
613
+ }, { ...pathOptions, ...(now ? { now } : {}) });
614
+ }
615
+ async function refreshWithBoundedReplay(client, current, requestId, startedAt, options) {
616
+ const sleep = options.sleep ?? defaultSleep;
617
+ let attempt = 0;
618
+ while (true) {
619
+ try {
620
+ return await client.refresh(current.refreshToken, {
621
+ requestId,
622
+ signal: options.signal,
623
+ });
624
+ }
625
+ catch (error) {
626
+ const baseDelay = REFRESH_RETRY_DELAYS_MS[attempt];
627
+ const delay = baseDelay === undefined
628
+ ? undefined
629
+ : Math.max(baseDelay, error instanceof DeviceProtocolError && error.status === 429
630
+ ? (error.retryAfterMs ?? 0)
631
+ : 0);
632
+ const retryDeadline = startedAt + REFRESH_SAFE_REPLAY_WINDOW_MS;
633
+ if (delay === undefined
634
+ || !refreshErrorIsAmbiguousRetryable(error)
635
+ || options.signal?.aborted
636
+ || refreshNowMs(options) + delay >= retryDeadline) {
637
+ throw error;
638
+ }
639
+ attempt += 1;
640
+ await sleep(delay, options.signal);
641
+ }
642
+ }
643
+ }
644
+ /** Serialize logout with refresh/install so a completed refresh cannot revive it. */
645
+ export async function clearStoredDeviceCredentials(options = {}) {
646
+ const filePath = deviceCredentialsPath(options);
647
+ mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
648
+ return await withFileLock(filePath, async () => {
649
+ const removed = clearDeviceCredentials(options);
650
+ clearDeviceEnrollmentJournal(options);
651
+ return removed;
652
+ }, { maxWaitMs: 45_000 });
653
+ }
654
+ /**
655
+ * Safely rotate the stored refresh token across host processes.
656
+ *
657
+ * The file lock covers the complete prewrite → HTTP → replacement sequence.
658
+ * The idempotency key is persisted before any network I/O and is retained on
659
+ * every failure, including process death, so the next call replays the exact
660
+ * same server operation. A waiter that observed the old token returns the
661
+ * winner's new credential instead of performing a second rotation.
662
+ */
663
+ /** Max foreign-journal follow hops. Multi-process journal handoff should
664
+ * converge in one hop; a small bound blocks theoretical refresh ping-pong. */
665
+ const MAX_FOREIGN_JOURNAL_DEPTH = 2;
666
+ export async function refreshStoredDeviceCredentials(options = {}, foreignJournalDepth = 0) {
667
+ const pathOptions = {
668
+ ...(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
669
+ ...(options.filePath !== undefined ? { filePath: options.filePath } : {}),
670
+ };
671
+ const observed = readDeviceCredentials(pathOptions);
672
+ if (!observed) {
673
+ throw new DeviceCredentialError('本机没有 desktop device 凭证,请先注册', 'invalid_file');
674
+ }
675
+ const filePath = deviceCredentialsPath(pathOptions);
676
+ const outcome = await withFileLock(filePath, async () => {
677
+ const current = readDeviceCredentials(pathOptions);
678
+ if (!current) {
679
+ throw new DeviceCredentialError('本机设备凭证已被移除', 'invalid_file');
680
+ }
681
+ // Another caller completed a rotation while this process waited. Returning
682
+ // its result is the cross-process single-flight outcome.
683
+ if (current.refreshToken !== observed.refreshToken || current.issuer !== observed.issuer) {
684
+ return { kind: 'credentials', value: current };
685
+ }
686
+ if (current.refreshRecoveryRequired) {
687
+ throw new DeviceCredentialError('设备续期状态需要人工恢复;请先确认平台设备状态,再 logout/re-enroll', 'invalid_file');
688
+ }
689
+ const requestId = current.pendingRefreshRequestId
690
+ ?? (options.requestIdFactory ?? generateRefreshRequestId)();
691
+ if (!/^[a-zA-Z0-9._:-]{16,128}$/.test(requestId)) {
692
+ throw new DeviceProtocolError('设备续期 request ID 无效', 'invalid_response');
693
+ }
694
+ const startedAt = current.pendingRefreshStartedAt ?? refreshNowMs(options);
695
+ if (current.pendingRefreshRequestId && (refreshNowMs(options) - startedAt >= REFRESH_SAFE_REPLAY_WINDOW_MS
696
+ || refreshNowMs(options) - startedAt < 0)) {
697
+ writeRefreshRecoveryRequired(current, pathOptions, options.now);
698
+ throw new DeviceCredentialError(`设备续期丢响应已超过安全恢复窗口(服务端窗口 ${REFRESH_REPLAY_WINDOW_MS}ms);已停止重发,请重新注册`, 'invalid_file');
699
+ }
700
+ if (!current.pendingRefreshRequestId) {
701
+ writeDeviceCredentials({
702
+ issuer: current.issuer,
703
+ accessToken: current.accessToken,
704
+ accessExpiresAt: current.accessExpiresAt,
705
+ refreshToken: current.refreshToken,
706
+ deviceExp: current.deviceExp,
707
+ pendingRefreshRequestId: requestId,
708
+ pendingRefreshStartedAt: startedAt,
709
+ }, {
710
+ ...pathOptions,
711
+ ...(options.now ? { now: options.now } : {}),
712
+ });
713
+ }
714
+ const client = (options.createClient ?? ((issuer) => new DeviceEnrollmentClient(issuer)))(current.issuer);
715
+ if (normalizeDeviceIssuer(client.issuer) !== current.issuer) {
716
+ throw new DeviceCredentialError('设备续期客户端 issuer 与已固定平台不一致', 'issuer_mismatch');
717
+ }
718
+ try {
719
+ const rotated = await refreshWithBoundedReplay(client, current, requestId, startedAt, options);
720
+ return {
721
+ kind: 'credentials',
722
+ value: writeDeviceCredentials({ issuer: current.issuer, ...rotated }, {
723
+ ...pathOptions,
724
+ ...(options.now ? { now: options.now } : {}),
725
+ }),
726
+ };
727
+ }
728
+ catch (error) {
729
+ if (refreshErrorRequiresLocalClear(error)) {
730
+ clearDeviceCredentials(pathOptions);
731
+ }
732
+ else if (refreshErrorRequiresRecoveryBlock(error)) {
733
+ writeRefreshRecoveryRequired(current, pathOptions, options.now);
734
+ }
735
+ else if (error instanceof DeviceProtocolError
736
+ && (error.status === 409 || error.serverCode === 'refresh_in_progress')) {
737
+ return { kind: 'conflict', original: current, requestId, error };
738
+ }
739
+ throw error;
740
+ }
741
+ }, { maxWaitMs: 45_000 });
742
+ if (outcome.kind === 'credentials')
743
+ return outcome.value;
744
+ // A 409 names a different idempotency key. Release our lock so a legitimate
745
+ // older/local winner can publish its file, then inspect exactly once. Never
746
+ // send the losing old refresh again: after the server replay window that
747
+ // would become replay_expired and revoke the device.
748
+ await (options.sleep ?? defaultSleep)(250, options.signal);
749
+ const reread = await withFileLock(filePath, async () => {
750
+ const latest = readDeviceCredentials(pathOptions);
751
+ if (latest
752
+ && (latest.refreshToken !== outcome.original.refreshToken
753
+ || latest.issuer !== outcome.original.issuer)) {
754
+ return { kind: 'credentials', value: latest };
755
+ }
756
+ if (latest?.pendingRefreshRequestId
757
+ && latest.pendingRefreshRequestId !== outcome.requestId) {
758
+ // A newer local process durably journaled the server-winning key but
759
+ // crashed before publishing the response. The normal refresh path will
760
+ // replay exactly that journal once and converge on the same token bytes.
761
+ return { kind: 'foreign_journal' };
762
+ }
763
+ if (latest)
764
+ writeRefreshRecoveryRequired(latest, pathOptions, options.now);
765
+ return { kind: 'blocked', error: outcome.error };
766
+ }, { maxWaitMs: 15_000 });
767
+ if (reread.kind === 'credentials')
768
+ return reread.value;
769
+ if (reread.kind === 'foreign_journal') {
770
+ if (foreignJournalDepth >= MAX_FOREIGN_JOURNAL_DEPTH) {
771
+ // Prevent endless multi-process journal thrash from replaying forever.
772
+ const latest = readDeviceCredentials(pathOptions);
773
+ if (latest)
774
+ writeRefreshRecoveryRequired(latest, pathOptions, options.now);
775
+ throw new DeviceProtocolError(`设备续期 journal 切换超过 ${MAX_FOREIGN_JOURNAL_DEPTH} 次仍未收敛;已停止自动续期,请重新注册`, 'invalid_response');
776
+ }
777
+ return await refreshStoredDeviceCredentials(options, foreignJournalDepth + 1);
778
+ }
779
+ throw new DeviceProtocolError('检测到不同续期密钥且本地没有可恢复的赢家日志;设备凭证可能泄露。已停止自动续期,请立即在设备管理页吊销并重新注册', reread.error.code, reread.error.status, reread.error.serverCode);
780
+ }
781
+ //# sourceMappingURL=device-enroll.js.map