@armoriq/sdk-dev 0.6.8 → 0.7.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 (99) hide show
  1. package/README.md +168 -1
  2. package/dist/_version.d.ts +1 -1
  3. package/dist/_version.js +1 -1
  4. package/dist/cli/commands/auth.d.ts +12 -0
  5. package/dist/cli/commands/auth.d.ts.map +1 -1
  6. package/dist/cli/commands/auth.js +460 -42
  7. package/dist/cli/commands/auth.js.map +1 -1
  8. package/dist/cli/index.js +0 -0
  9. package/dist/client.d.ts +8 -15
  10. package/dist/client.d.ts.map +1 -1
  11. package/dist/client.js +20 -18
  12. package/dist/client.js.map +1 -1
  13. package/dist/config.d.ts +0 -17
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +1 -19
  16. package/dist/config.js.map +1 -1
  17. package/dist/index.d.ts +3 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +8 -14
  20. package/dist/index.js.map +1 -1
  21. package/dist/integrations/google_adk.d.ts +168 -6
  22. package/dist/integrations/google_adk.d.ts.map +1 -1
  23. package/dist/integrations/google_adk.js +797 -103
  24. package/dist/integrations/google_adk.js.map +1 -1
  25. package/dist/integrations/langchain.d.ts +51 -1
  26. package/dist/integrations/langchain.d.ts.map +1 -1
  27. package/dist/integrations/langchain.js +563 -30
  28. package/dist/integrations/langchain.js.map +1 -1
  29. package/dist/integrations/strands.d.ts +48 -0
  30. package/dist/integrations/strands.d.ts.map +1 -1
  31. package/dist/integrations/strands.js +441 -27
  32. package/dist/integrations/strands.js.map +1 -1
  33. package/dist/models.d.ts +2 -2
  34. package/dist/models.d.ts.map +1 -1
  35. package/dist/observability/content-capture.d.ts +75 -0
  36. package/dist/observability/content-capture.d.ts.map +1 -0
  37. package/dist/observability/content-capture.js +339 -0
  38. package/dist/observability/content-capture.js.map +1 -0
  39. package/dist/observability/index.d.ts +6 -7
  40. package/dist/observability/index.d.ts.map +1 -1
  41. package/dist/observability/index.js +18 -27
  42. package/dist/observability/index.js.map +1 -1
  43. package/dist/observability/otel-config.d.ts +47 -0
  44. package/dist/observability/otel-config.d.ts.map +1 -0
  45. package/dist/observability/otel-config.js +268 -0
  46. package/dist/observability/otel-config.js.map +1 -0
  47. package/dist/observability/otel-export-ceiling.d.ts +96 -0
  48. package/dist/observability/otel-export-ceiling.d.ts.map +1 -0
  49. package/dist/observability/otel-export-ceiling.js +264 -0
  50. package/dist/observability/otel-export-ceiling.js.map +1 -0
  51. package/dist/observability/otel-runtime.d.ts +103 -0
  52. package/dist/observability/otel-runtime.d.ts.map +1 -0
  53. package/dist/observability/otel-runtime.js +668 -0
  54. package/dist/observability/otel-runtime.js.map +1 -0
  55. package/dist/observability/otel-session.d.ts +168 -0
  56. package/dist/observability/otel-session.d.ts.map +1 -0
  57. package/dist/observability/otel-session.js +621 -0
  58. package/dist/observability/otel-session.js.map +1 -0
  59. package/dist/observability/otel-shutdown.d.ts +17 -0
  60. package/dist/observability/otel-shutdown.d.ts.map +1 -0
  61. package/dist/observability/otel-shutdown.js +54 -0
  62. package/dist/observability/otel-shutdown.js.map +1 -0
  63. package/dist/observability/policy-lease.d.ts +22 -0
  64. package/dist/observability/policy-lease.d.ts.map +1 -0
  65. package/dist/observability/policy-lease.js +102 -0
  66. package/dist/observability/policy-lease.js.map +1 -0
  67. package/dist/plan_builder.d.ts +5 -4
  68. package/dist/plan_builder.d.ts.map +1 -1
  69. package/dist/plan_builder.js +14 -15
  70. package/dist/plan_builder.js.map +1 -1
  71. package/dist/session.d.ts +61 -93
  72. package/dist/session.d.ts.map +1 -1
  73. package/dist/session.js +388 -812
  74. package/dist/session.js.map +1 -1
  75. package/dist/token_usage.d.ts +11 -18
  76. package/dist/token_usage.d.ts.map +1 -1
  77. package/dist/token_usage.js +29 -94
  78. package/dist/token_usage.js.map +1 -1
  79. package/dist/tool_name.d.ts +18 -0
  80. package/dist/tool_name.d.ts.map +1 -0
  81. package/dist/tool_name.js +29 -0
  82. package/dist/tool_name.js.map +1 -0
  83. package/dist/tool_push.d.ts +28 -0
  84. package/dist/tool_push.d.ts.map +1 -0
  85. package/dist/tool_push.js +151 -0
  86. package/dist/tool_push.js.map +1 -0
  87. package/dist/tool_registry.d.ts +100 -0
  88. package/dist/tool_registry.d.ts.map +1 -0
  89. package/dist/tool_registry.js +440 -0
  90. package/dist/tool_registry.js.map +1 -0
  91. package/dist/tool_schema.d.ts +22 -0
  92. package/dist/tool_schema.d.ts.map +1 -0
  93. package/dist/tool_schema.js +163 -0
  94. package/dist/tool_schema.js.map +1 -0
  95. package/package.json +13 -7
  96. package/dist/integrations/microsoft_copilot.d.ts +0 -84
  97. package/dist/integrations/microsoft_copilot.d.ts.map +0 -1
  98. package/dist/integrations/microsoft_copilot.js +0 -126
  99. package/dist/integrations/microsoft_copilot.js.map +0 -1
@@ -44,12 +44,16 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
44
44
  return (mod && mod.__esModule) ? mod : { "default": mod };
45
45
  };
46
46
  Object.defineProperty(exports, "__esModule", { value: true });
47
+ exports.loadOrCreateDeviceId = loadOrCreateDeviceId;
47
48
  exports.cmdLogin = cmdLogin;
48
49
  exports.cmdLogout = cmdLogout;
49
50
  exports.cmdWhoami = cmdWhoami;
50
51
  const crypto = __importStar(require("crypto"));
52
+ const fs = __importStar(require("fs"));
51
53
  const http = __importStar(require("http"));
52
54
  const net = __importStar(require("net"));
55
+ const os = __importStar(require("os"));
56
+ const path = __importStar(require("path"));
53
57
  const url_1 = require("url");
54
58
  const child_process_1 = require("child_process");
55
59
  const axios_1 = __importDefault(require("axios"));
@@ -64,15 +68,207 @@ const PRODUCT_LABELS = {
64
68
  platform: 'ArmorIQ Platform',
65
69
  sdk: 'ArmorIQ SDK',
66
70
  };
71
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
72
+ function getDeviceIdentityPath() {
73
+ return (process.env.ARMORIQ_DEVICE_ID_PATH ?? path.join(path.dirname((0, credentials_1.getCredentialsPath)()), 'device-id'));
74
+ }
75
+ /**
76
+ * A device identity was written but could not be durably persisted.
77
+ *
78
+ * Distinct from a collision or a corrupt file: callers may read a concurrent
79
+ * winner, while corrupt files require manual recovery. This invocation must
80
+ * report no identity rather than vouch for bytes whose write or fsync failed.
81
+ */
82
+ class DurabilityError extends Error {
83
+ }
84
+ /** One reusable cell for Atomics.wait; the value is never read. */
85
+ const IDLE_CELL = new Int32Array(new SharedArrayBuffer(4));
86
+ const WAIT_ATTEMPTS = 20;
87
+ const WAIT_INTERVAL_MS = 10;
88
+ function readDeviceId(identityPath, identityFs) {
89
+ try {
90
+ const existing = identityFs.readFileSync(identityPath, 'utf8').trim();
91
+ return UUID_RE.test(existing) ? existing.toLowerCase() : null;
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ }
97
+ function isAlreadyExists(error) {
98
+ return error?.code === 'EEXIST';
99
+ }
100
+ function deviceIdentityPathExists(identityPath, identityFs) {
101
+ try {
102
+ identityFs.statSync(identityPath);
103
+ return true;
104
+ }
105
+ catch (error) {
106
+ // Treat an inaccessible path as occupied. Attempting publication cannot
107
+ // repair permissions and must never replace something we could not read.
108
+ return error?.code !== 'ENOENT';
109
+ }
110
+ }
111
+ /**
112
+ * Wait briefly for a valid identity to appear. Only used after a create race,
113
+ * where the hard-link fallback may have published an empty path immediately
114
+ * before writing its UUID.
115
+ */
116
+ function awaitIdentity(identityPath, identityFs) {
117
+ for (let attempt = 0; attempt < WAIT_ATTEMPTS; attempt += 1) {
118
+ const value = readDeviceId(identityPath, identityFs);
119
+ if (value)
120
+ return value;
121
+ Atomics.wait(IDLE_CELL, 0, 0, WAIT_INTERVAL_MS);
122
+ }
123
+ return null;
124
+ }
125
+ /**
126
+ * Install `tempPath` at `identityPath` only if nothing is there, and report the
127
+ * winner.
128
+ *
129
+ * link() is create-if-absent and publishes the path already fully written, so
130
+ * exactly one caller can ever install and no reader can observe a half-written
131
+ * file. With `flag: 'wx'` the file exists and is empty for an instant, and a
132
+ * concurrent login reading it in that window sees a corrupt identity.
133
+ */
134
+ function publishIdentity(identityPath, tempPath, candidate, identityFs) {
135
+ try {
136
+ identityFs.linkSync(tempPath, identityPath);
137
+ return candidate;
138
+ }
139
+ catch (error) {
140
+ if (isAlreadyExists(error)) {
141
+ return readDeviceId(identityPath, identityFs) ?? awaitIdentity(identityPath, identityFs);
142
+ }
143
+ }
144
+ // Filesystems without hard links (some network and Windows mounts).
145
+ // Exclusive create is the next best thing; it carries the brief empty-file
146
+ // window that link() avoids.
147
+ //
148
+ // Opened by descriptor rather than writeFileSync so the create and the write
149
+ // are separable: exclusive create publishes the path before it is written,
150
+ // and the failure branch has to know that it did.
151
+ let fd;
152
+ try {
153
+ fd = identityFs.openSync(identityPath, 'wx', 0o600);
154
+ }
155
+ catch (error) {
156
+ if (!isAlreadyExists(error))
157
+ return null;
158
+ return readDeviceId(identityPath, identityFs) ?? awaitIdentity(identityPath, identityFs);
159
+ }
160
+ try {
161
+ identityFs.writeSync(fd, `${candidate}\n`);
162
+ identityFs.fsyncSync(fd);
163
+ return candidate;
164
+ }
165
+ catch {
166
+ // Whatever this created is deliberately left in place. Removing it cannot
167
+ // be done safely: verifying the inode and unlinking the pathname are
168
+ // separate operations, so a concurrent publisher could install a real
169
+ // identity in between and have its winner deleted. A later login reports
170
+ // the incomplete file and asks the operator to recover it manually.
171
+ //
172
+ // Thrown rather than returned: the bytes may well have landed before the
173
+ // fsync failed, and re-reading them would report an identity whose
174
+ // durability just failed as a stable one.
175
+ throw new DurabilityError('device identity could not be persisted');
176
+ }
177
+ finally {
178
+ try {
179
+ identityFs.closeSync(fd);
180
+ }
181
+ catch {
182
+ // Already closed.
183
+ }
184
+ }
185
+ }
186
+ /**
187
+ * The installation's stable, opaque device id, or null when it could not be
188
+ * persisted.
189
+ *
190
+ * Null rather than an unpersisted UUID: a value that is not on disk differs on
191
+ * every login, and the backend keys per-device key rotation on it, so returning
192
+ * one would provision a new key each time for what is one installation.
193
+ */
194
+ function loadOrCreateDeviceId(identityFs = fs) {
195
+ const identityPath = getDeviceIdentityPath();
196
+ const existing = readDeviceId(identityPath, identityFs);
197
+ if (existing)
198
+ return existing;
199
+ // Existing but invalid identity files belong to the operator. Do not rename,
200
+ // truncate, or replace them automatically; recovery must be an explicit
201
+ // manual action after the contents have been inspected.
202
+ if (deviceIdentityPathExists(identityPath, identityFs))
203
+ return null;
204
+ const candidate = crypto.randomUUID();
205
+ const tempPath = `${identityPath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}`;
206
+ try {
207
+ identityFs.mkdirSync(path.dirname(identityPath), { recursive: true });
208
+ identityFs.writeFileSync(tempPath, `${candidate}\n`, { mode: 0o600 });
209
+ }
210
+ catch {
211
+ return null;
212
+ }
213
+ try {
214
+ try {
215
+ const published = publishIdentity(identityPath, tempPath, candidate, identityFs);
216
+ return published;
217
+ }
218
+ catch (error) {
219
+ if (error instanceof DurabilityError)
220
+ return null;
221
+ throw error;
222
+ }
223
+ }
224
+ finally {
225
+ try {
226
+ identityFs.unlinkSync(tempPath);
227
+ }
228
+ catch {
229
+ // Already consumed by rename(), or never created.
230
+ }
231
+ }
232
+ }
233
+ const DEFAULT_DASHBOARD_URL = 'https://tools.armoriq.ai';
234
+ const TOOL_CATALOG_PRODUCTS = new Set(['armorclaude', 'armorcodex']);
235
+ const CALLBACK_SETTLED_GRACE_MS = 1000;
236
+ const PROOF_SERVER_DEADLINE_MS = 15_000;
237
+ function configuredDashboardUrl() {
238
+ let dashboardUrl;
239
+ try {
240
+ dashboardUrl = new url_1.URL(process.env.ARMORIQ_DASHBOARD_URL ?? DEFAULT_DASHBOARD_URL);
241
+ if (dashboardUrl.protocol !== 'https:' && dashboardUrl.protocol !== 'http:') {
242
+ dashboardUrl = new url_1.URL(DEFAULT_DASHBOARD_URL);
243
+ }
244
+ }
245
+ catch {
246
+ dashboardUrl = new url_1.URL(DEFAULT_DASHBOARD_URL);
247
+ }
248
+ dashboardUrl.username = '';
249
+ dashboardUrl.password = '';
250
+ return dashboardUrl;
251
+ }
252
+ function buildDashboardRedirect(product) {
253
+ const dashboardUrl = configuredDashboardUrl();
254
+ // Treat the configured value as an origin only. This prevents stale query,
255
+ // fragment, or user-info data from being carried into the callback redirect.
256
+ dashboardUrl.pathname = '/tools';
257
+ dashboardUrl.search = '';
258
+ dashboardUrl.hash = '';
259
+ const productKey = (product ?? '').toLowerCase().trim();
260
+ if (TOOL_CATALOG_PRODUCTS.has(productKey)) {
261
+ dashboardUrl.searchParams.set('product', productKey);
262
+ }
263
+ return dashboardUrl.toString();
264
+ }
67
265
  function escapeHtml(s) {
68
266
  return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] ?? c);
69
267
  }
70
268
  function renderSuccessHtml(opts) {
71
269
  const productKey = (opts.product ?? '').toLowerCase().trim();
72
270
  const productLabel = PRODUCT_LABELS[productKey] ?? null;
73
- const emailHtml = opts.email
74
- ? `<div class="email">${escapeHtml(opts.email)}</div>`
75
- : '';
271
+ const emailHtml = opts.email ? `<div class="email">${escapeHtml(opts.email)}</div>` : '';
76
272
  const productHtml = productLabel
77
273
  ? `<div class="chip">Connected to ${escapeHtml(productLabel)}</div>`
78
274
  : '';
@@ -166,7 +362,7 @@ function renderSuccessHtml(opts) {
166
362
  <div>© ${year} ArmorIQ Inc.</div>
167
363
  <nav>
168
364
  <a href="https://docs.armoriq.ai" target="_blank" rel="noreferrer">Docs</a>
169
- <a href="https://dev.armoriq.ai" target="_blank" rel="noreferrer">Dashboard</a>
365
+ <a href="https://tools.armoriq.ai/tools" target="_blank" rel="noreferrer">Tools</a>
170
366
  <a href="https://armoriq.ai/privacy" target="_blank" rel="noreferrer">Privacy</a>
171
367
  <a href="https://armoriq.ai/terms" target="_blank" rel="noreferrer">Terms</a>
172
368
  </nav>
@@ -191,57 +387,189 @@ function findFreePort() {
191
387
  });
192
388
  }
193
389
  function startCallbackServer(port, expectedState, product) {
390
+ let lifecycleState = 'accepting';
391
+ let acceptedResult;
194
392
  let resolveResult;
195
393
  const once = new Promise((resolve) => {
196
394
  resolveResult = resolve;
197
395
  });
396
+ let resolveProofConsumed;
397
+ const proofConsumed = new Promise((resolve) => {
398
+ resolveProofConsumed = resolve;
399
+ });
400
+ let proofWasConsumed = false;
401
+ const markProofConsumed = () => {
402
+ if (proofWasConsumed)
403
+ return;
404
+ proofWasConsumed = true;
405
+ resolveProofConsumed();
406
+ };
407
+ const endResponse = (res, statusCode, body, headers = {}, onFinished) => {
408
+ if (res.writableEnded || res.destroyed)
409
+ return;
410
+ res.writeHead(statusCode, {
411
+ 'Content-Type': 'text/plain; charset=utf-8',
412
+ 'Cache-Control': 'no-store',
413
+ Connection: 'close',
414
+ ...headers,
415
+ });
416
+ res.end(body, onFinished);
417
+ };
418
+ const settle = () => {
419
+ lifecycleState = 'settled';
420
+ if (acceptedResult?.response && !acceptedResult.response.headersSent) {
421
+ endResponse(acceptedResult.response, 409, 'Authorization completed before this callback could be applied.');
422
+ }
423
+ };
424
+ const dashboardOrigin = configuredDashboardUrl().origin;
198
425
  const server = http.createServer((req, res) => {
199
426
  const url = new url_1.URL(req.url ?? '/', `http://localhost:${port}`);
427
+ if (url.pathname === '/.well-known/armoriq-device-proof') {
428
+ const requestOrigin = req.headers.origin;
429
+ if (requestOrigin !== dashboardOrigin) {
430
+ endResponse(res, 403, 'Origin is not allowed.');
431
+ return;
432
+ }
433
+ const candidateState = url.searchParams.get('state');
434
+ const candidateBytes = Buffer.from(candidateState ?? '');
435
+ const expectedBytes = Buffer.from(expectedState);
436
+ const stateMatches = candidateBytes.length === expectedBytes.length &&
437
+ crypto.timingSafeEqual(candidateBytes, expectedBytes);
438
+ if (!stateMatches) {
439
+ endResponse(res, 400, 'Invalid or missing state parameter.');
440
+ return;
441
+ }
442
+ const corsHeaders = {
443
+ 'Access-Control-Allow-Origin': dashboardOrigin,
444
+ Vary: 'Origin',
445
+ };
446
+ if (req.method === 'OPTIONS') {
447
+ endResponse(res, 204, '', {
448
+ ...corsHeaders,
449
+ 'Access-Control-Allow-Methods': 'GET, OPTIONS',
450
+ });
451
+ return;
452
+ }
453
+ if (req.method !== 'GET') {
454
+ endResponse(res, 404, 'Not found.', corsHeaders);
455
+ return;
456
+ }
457
+ endResponse(res, 200, JSON.stringify({ ok: true }), {
458
+ ...corsHeaders,
459
+ 'Content-Type': 'application/json; charset=utf-8',
460
+ }, markProofConsumed);
461
+ return;
462
+ }
200
463
  if (url.pathname === '/callback') {
201
464
  const params = Object.fromEntries(url.searchParams.entries());
202
465
  // Reject any callback that doesn't carry the state nonce we generated.
203
466
  // Without this, any local process or web page could hit the loopback
204
467
  // port with ?key=<attacker> during the login window (key fixation).
205
468
  if (!params.state || params.state !== expectedState) {
206
- res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
207
- res.end('Invalid or missing state parameter.');
469
+ endResponse(res, 400, 'Invalid or missing state parameter.');
208
470
  return;
209
471
  }
210
- const callbackProduct = params.product || product;
211
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
212
- res.end(renderSuccessHtml({
213
- email: params.email,
214
- product: callbackProduct,
215
- orgId: params.org_id,
216
- }));
472
+ // Product routing is established by the local command invocation. Do
473
+ // not allow callback query data to upgrade or change that decision.
474
+ const callbackProduct = product;
217
475
  const result = {
218
476
  api_key: params.key,
219
477
  email: params.email,
220
478
  user_id: params.user_id,
221
479
  org_id: params.org_id,
480
+ product: callbackProduct,
222
481
  };
223
- if (result.api_key)
482
+ if (result.api_key) {
483
+ if (lifecycleState === 'pending') {
484
+ endResponse(res, 409, 'Authorization callback already received.');
485
+ return;
486
+ }
487
+ if (lifecycleState === 'settled') {
488
+ endResponse(res, 410, 'Authorization flow is no longer accepting callbacks.');
489
+ return;
490
+ }
491
+ // Keep the loopback response pending until cmdLogin has persisted the
492
+ // credentials. The remote redirect is produced only from bounded,
493
+ // non-secret local context.
494
+ result.response = res;
495
+ acceptedResult = result;
496
+ lifecycleState = 'pending';
224
497
  resolveResult(result);
498
+ }
499
+ else {
500
+ // Preserve the existing response for callbacks without a key. Polling
501
+ // remains active and may still complete the device flow.
502
+ res.writeHead(200, {
503
+ 'Content-Type': 'text/html; charset=utf-8',
504
+ 'Cache-Control': 'no-store',
505
+ Connection: 'close',
506
+ });
507
+ res.end(renderSuccessHtml({
508
+ email: params.email,
509
+ product: callbackProduct,
510
+ orgId: params.org_id,
511
+ }));
512
+ }
225
513
  }
226
514
  else {
227
- res.writeHead(404);
228
- res.end();
515
+ endResponse(res, 404, 'Not found.');
229
516
  }
230
517
  });
518
+ const sockets = new Set();
519
+ server.on('connection', (socket) => {
520
+ sockets.add(socket);
521
+ socket.once('close', () => sockets.delete(socket));
522
+ });
231
523
  server.listen(port, '127.0.0.1');
232
- return { server, once };
524
+ return { server, sockets, once, proofConsumed, settle };
233
525
  }
234
526
  /**
235
527
  * close() does nothing while the socket is still binding, so a device-code
236
528
  * request that fails before 'listening' fires would leave the callback server
237
529
  * holding the loopback port for the rest of the process.
238
530
  */
239
- function closeCallbackServer(server) {
240
- if (server.listening) {
241
- server.close();
531
+ function closeCallbackServer(server, sockets, graceMs = 0) {
532
+ const close = () => {
533
+ const destroyConnections = () => {
534
+ // Node 18's server.close() does not reliably reap idle keep-alive or
535
+ // incomplete HTTP connections. Use the supported helpers when present,
536
+ // then enforce the bound with the sockets observed by this server.
537
+ server.closeIdleConnections?.();
538
+ server.closeAllConnections?.();
539
+ for (const socket of sockets)
540
+ socket.destroy();
541
+ };
542
+ if (server.listening) {
543
+ server.close();
544
+ destroyConnections();
545
+ return;
546
+ }
547
+ server.once('listening', () => {
548
+ server.close();
549
+ destroyConnections();
550
+ });
551
+ };
552
+ if (graceMs > 0) {
553
+ setTimeout(close, graceMs);
242
554
  return;
243
555
  }
244
- server.once('listening', () => server.close());
556
+ close();
557
+ }
558
+ function closeCallbackServerAfterProof(server, sockets, proofConsumed, deadlineMs) {
559
+ let closed = false;
560
+ const close = () => {
561
+ if (closed)
562
+ return;
563
+ closed = true;
564
+ closeCallbackServer(server, sockets);
565
+ };
566
+ const deadline = setTimeout(close, deadlineMs);
567
+ void proofConsumed.then(() => {
568
+ if (closed)
569
+ return;
570
+ clearTimeout(deadline);
571
+ close();
572
+ });
245
573
  }
246
574
  function openBrowser(url) {
247
575
  const cmd = process.platform === 'darwin'
@@ -253,8 +581,26 @@ function openBrowser(url) {
253
581
  /* best-effort */
254
582
  });
255
583
  }
256
- function sleep(ms) {
257
- return new Promise((r) => setTimeout(r, ms));
584
+ function cancellableDelay(ms) {
585
+ let settled = false;
586
+ let resolveDelay;
587
+ const promise = new Promise((resolve) => {
588
+ resolveDelay = resolve;
589
+ });
590
+ const timer = setTimeout(() => {
591
+ settled = true;
592
+ resolveDelay('tick');
593
+ }, ms);
594
+ return {
595
+ promise,
596
+ cancel: () => {
597
+ if (settled)
598
+ return;
599
+ settled = true;
600
+ clearTimeout(timer);
601
+ resolveDelay('cancelled');
602
+ },
603
+ };
258
604
  }
259
605
  async function cmdLogin(args) {
260
606
  const backend = (args.backend ?? process.env.ARMORIQ_BACKEND_URL ?? (0, util_1.backendBase)()).replace(/\/+$/, '');
@@ -279,17 +625,32 @@ async function cmdLogin(args) {
279
625
  const port = await findFreePort();
280
626
  const state = crypto.randomBytes(16).toString('hex');
281
627
  const callbackUrl = `http://localhost:${port}/callback?state=${state}`;
282
- const { server, once: callbackOnce } = startCallbackServer(port, state, product);
628
+ const { server, sockets: callbackSockets, once: callbackOnce, proofConsumed, settle: settleCallbackServer, } = startCallbackServer(port, state, product);
283
629
  let dc;
284
630
  try {
285
- const deviceCodeRequest = product
286
- ? { callback_url: callbackUrl, product }
287
- : { callback_url: callbackUrl };
288
- const r = await axios_1.default.post(`${backend}/auth/device/code`, deviceCodeRequest, { timeout: 10000 });
631
+ const deviceId = loadOrCreateDeviceId();
632
+ if (!deviceId) {
633
+ const identityPath = getDeviceIdentityPath();
634
+ if (!readDeviceId(identityPath, fs) && deviceIdentityPathExists(identityPath, fs)) {
635
+ (0, util_1.out)(` ${util_1.WARN} Device identity at ${identityPath} is invalid or unreadable; ` +
636
+ 'move or remove it manually, then run `armoriq login --force` again.');
637
+ }
638
+ }
639
+ const deviceCodeRequest = {
640
+ callback_url: callbackUrl,
641
+ ...(product ? { product } : {}),
642
+ // Both fields or neither: deviceName exists only to label deviceId, so
643
+ // sending it alone would attach a hostname to no device.
644
+ ...(deviceId ? { deviceId, deviceName: os.hostname().trim().slice(0, 200) } : {}),
645
+ };
646
+ const r = await axios_1.default.post(`${backend}/auth/device/code`, deviceCodeRequest, {
647
+ timeout: 10000,
648
+ });
289
649
  dc = r.data;
290
650
  }
291
651
  catch (e) {
292
- closeCallbackServer(server);
652
+ settleCallbackServer();
653
+ closeCallbackServer(server, callbackSockets);
293
654
  (0, util_1.out)(` ${util_1.CROSS} Failed to request device code: ${e.message}`);
294
655
  return 1;
295
656
  }
@@ -304,7 +665,7 @@ async function cmdLogin(args) {
304
665
  browserUrl += `&product=${encodeURIComponent(product)}`;
305
666
  (0, util_1.out)(' Opening browser...\n');
306
667
  openBrowser(browserUrl);
307
- (0, util_1.out)(' If the browser didn\'t open, visit:');
668
+ (0, util_1.out)(" If the browser didn't open, visit:");
308
669
  (0, util_1.out)(` \x1b[36m\x1b[1m${browserUrl}\x1b[0m\n`);
309
670
  (0, util_1.out)(` Confirm this code in your browser: \x1b[1m${user_code}\x1b[0m\n`);
310
671
  process.stdout.write(' Waiting for authorization...');
@@ -312,19 +673,43 @@ async function cmdLogin(args) {
312
673
  let result;
313
674
  let lastErr;
314
675
  while (Date.now() < deadline && !result) {
315
- // Race callback vs poll; whichever resolves first wins.
676
+ // Wait for a callback until the next polling interval.
677
+ const delay = cancellableDelay(interval * 1000);
316
678
  const callbackOrTimeout = Promise.race([
317
679
  callbackOnce.then((r) => ({ kind: 'cb', value: r })),
318
- sleep(interval * 1000).then(() => ({ kind: 'tick' })),
680
+ delay.promise.then((value) => ({ kind: value })),
319
681
  ]);
320
682
  const tick = await callbackOrTimeout;
321
683
  if (tick.kind === 'cb' && tick.value.api_key) {
684
+ delay.cancel();
322
685
  result = tick.value;
323
686
  break;
324
687
  }
325
688
  try {
326
- const pr = await axios_1.default.post(`${backend}/auth/device/token`, { deviceCode: device_code }, { timeout: 10000, validateStatus: () => true });
327
- const data = pr.data ?? {};
689
+ // Keep observing the callback while the token request is in flight. A
690
+ // callback accepted during polling owns an open browser response and
691
+ // must be persisted and redirected instead of being discarded when a
692
+ // concurrent poll also succeeds.
693
+ const pollController = new AbortController();
694
+ const pollOutcome = axios_1.default
695
+ .post(`${backend}/auth/device/token`, { deviceCode: device_code }, { timeout: 10000, validateStatus: () => true, signal: pollController.signal })
696
+ .then((r) => ({ kind: 'poll', value: r }), () => ({ kind: 'poll-error' }));
697
+ const pollOrCallback = await Promise.race([
698
+ callbackOnce.then((r) => ({ kind: 'cb', value: r })),
699
+ pollOutcome,
700
+ ]);
701
+ if (pollOrCallback.kind === 'cb') {
702
+ pollController.abort();
703
+ await pollOutcome;
704
+ if (pollOrCallback.value.api_key) {
705
+ result = pollOrCallback.value;
706
+ break;
707
+ }
708
+ continue;
709
+ }
710
+ if (pollOrCallback.kind === 'poll-error')
711
+ continue;
712
+ const data = pollOrCallback.value.data ?? {};
328
713
  const err = data.error;
329
714
  if (err === 'authorization_pending' || err === 'slow_down')
330
715
  continue;
@@ -346,19 +731,52 @@ async function cmdLogin(args) {
346
731
  // network blip; keep polling
347
732
  }
348
733
  }
349
- closeCallbackServer(server);
350
734
  if (!result || !result.api_key) {
735
+ settleCallbackServer();
736
+ closeCallbackServer(server, callbackSockets, CALLBACK_SETTLED_GRACE_MS);
351
737
  (0, util_1.out)(` ${util_1.CROSS}`);
352
738
  (0, util_1.out)(` ${util_1.CROSS} ${lastErr ?? 'Timed out waiting for authorization. Run `armoriq login` again.'}`);
353
739
  return 1;
354
740
  }
355
- (0, credentials_1.saveCredentials)({
356
- apiKey: result.api_key,
357
- email: result.email ?? '',
358
- userId: result.user_id ?? '',
359
- orgId: result.org_id ?? '',
360
- savedAt: new Date().toISOString(),
361
- });
741
+ if (!result.response)
742
+ settleCallbackServer();
743
+ try {
744
+ (0, credentials_1.saveCredentials)({
745
+ apiKey: result.api_key,
746
+ email: result.email ?? '',
747
+ userId: result.user_id ?? '',
748
+ orgId: result.org_id ?? '',
749
+ savedAt: new Date().toISOString(),
750
+ });
751
+ }
752
+ catch (error) {
753
+ if (result.response && !result.response.headersSent) {
754
+ result.response.writeHead(500, {
755
+ 'Content-Type': 'text/plain; charset=utf-8',
756
+ 'Cache-Control': 'no-store',
757
+ Connection: 'close',
758
+ });
759
+ result.response.end('Authorization succeeded, but credentials could not be saved locally.');
760
+ }
761
+ settleCallbackServer();
762
+ closeCallbackServer(server, callbackSockets, CALLBACK_SETTLED_GRACE_MS);
763
+ throw error;
764
+ }
765
+ if (result.response && !result.response.headersSent) {
766
+ result.response.writeHead(302, {
767
+ Location: buildDashboardRedirect(result.product),
768
+ 'Cache-Control': 'no-store',
769
+ Connection: 'close',
770
+ });
771
+ result.response.end();
772
+ }
773
+ settleCallbackServer();
774
+ if (result.response) {
775
+ closeCallbackServerAfterProof(server, callbackSockets, proofConsumed, PROOF_SERVER_DEADLINE_MS);
776
+ }
777
+ else {
778
+ closeCallbackServer(server, callbackSockets, CALLBACK_SETTLED_GRACE_MS);
779
+ }
362
780
  (0, util_1.out)(` ${util_1.CHECK}`);
363
781
  (0, util_1.out)('');
364
782
  (0, util_1.out)(` ${util_1.CHECK} Logged in as \x1b[1m${result.email ?? 'unknown'}\x1b[0m (org: ${result.org_id ?? 'unknown'})`);