@armoriq/sdk-dev 0.6.10 → 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.
- package/README.md +168 -1
- package/dist/_version.d.ts +1 -1
- package/dist/_version.d.ts.map +1 -1
- package/dist/_version.js +1 -1
- package/dist/_version.js.map +1 -1
- package/dist/cli/commands/auth.d.ts +12 -0
- package/dist/cli/commands/auth.d.ts.map +1 -1
- package/dist/cli/commands/auth.js +460 -42
- package/dist/cli/commands/auth.js.map +1 -1
- package/dist/client.d.ts +8 -15
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +20 -18
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +0 -17
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -19
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -14
- package/dist/index.js.map +1 -1
- package/dist/integrations/google_adk.d.ts +155 -7
- package/dist/integrations/google_adk.d.ts.map +1 -1
- package/dist/integrations/google_adk.js +727 -46
- package/dist/integrations/google_adk.js.map +1 -1
- package/dist/integrations/langchain.d.ts +48 -2
- package/dist/integrations/langchain.d.ts.map +1 -1
- package/dist/integrations/langchain.js +528 -33
- package/dist/integrations/langchain.js.map +1 -1
- package/dist/integrations/strands.d.ts +46 -1
- package/dist/integrations/strands.d.ts.map +1 -1
- package/dist/integrations/strands.js +421 -36
- package/dist/integrations/strands.js.map +1 -1
- package/dist/models.d.ts +2 -2
- package/dist/models.d.ts.map +1 -1
- package/dist/observability/content-capture.d.ts +75 -0
- package/dist/observability/content-capture.d.ts.map +1 -0
- package/dist/observability/content-capture.js +339 -0
- package/dist/observability/content-capture.js.map +1 -0
- package/dist/observability/index.d.ts +6 -7
- package/dist/observability/index.d.ts.map +1 -1
- package/dist/observability/index.js +18 -27
- package/dist/observability/index.js.map +1 -1
- package/dist/observability/otel-config.d.ts +47 -0
- package/dist/observability/otel-config.d.ts.map +1 -0
- package/dist/observability/otel-config.js +268 -0
- package/dist/observability/otel-config.js.map +1 -0
- package/dist/observability/otel-export-ceiling.d.ts +96 -0
- package/dist/observability/otel-export-ceiling.d.ts.map +1 -0
- package/dist/observability/otel-export-ceiling.js +264 -0
- package/dist/observability/otel-export-ceiling.js.map +1 -0
- package/dist/observability/otel-runtime.d.ts +103 -0
- package/dist/observability/otel-runtime.d.ts.map +1 -0
- package/dist/observability/otel-runtime.js +668 -0
- package/dist/observability/otel-runtime.js.map +1 -0
- package/dist/observability/otel-session.d.ts +168 -0
- package/dist/observability/otel-session.d.ts.map +1 -0
- package/dist/observability/otel-session.js +621 -0
- package/dist/observability/otel-session.js.map +1 -0
- package/dist/observability/otel-shutdown.d.ts +17 -0
- package/dist/observability/otel-shutdown.d.ts.map +1 -0
- package/dist/observability/otel-shutdown.js +54 -0
- package/dist/observability/otel-shutdown.js.map +1 -0
- package/dist/observability/policy-lease.d.ts +22 -0
- package/dist/observability/policy-lease.d.ts.map +1 -0
- package/dist/observability/policy-lease.js +102 -0
- package/dist/observability/policy-lease.js.map +1 -0
- package/dist/plan_builder.d.ts +5 -4
- package/dist/plan_builder.d.ts.map +1 -1
- package/dist/plan_builder.js +14 -15
- package/dist/plan_builder.js.map +1 -1
- package/dist/session.d.ts +61 -93
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +388 -804
- package/dist/session.js.map +1 -1
- package/dist/token_usage.d.ts +11 -18
- package/dist/token_usage.d.ts.map +1 -1
- package/dist/token_usage.js +29 -94
- package/dist/token_usage.js.map +1 -1
- package/dist/tool_name.d.ts +18 -0
- package/dist/tool_name.d.ts.map +1 -0
- package/dist/tool_name.js +29 -0
- package/dist/tool_name.js.map +1 -0
- package/dist/tool_push.d.ts +28 -0
- package/dist/tool_push.d.ts.map +1 -0
- package/dist/tool_push.js +151 -0
- package/dist/tool_push.js.map +1 -0
- package/dist/tool_registry.d.ts +100 -0
- package/dist/tool_registry.d.ts.map +1 -0
- package/dist/tool_registry.js +440 -0
- package/dist/tool_registry.js.map +1 -0
- package/dist/tool_schema.d.ts +22 -0
- package/dist/tool_schema.d.ts.map +1 -0
- package/dist/tool_schema.js +163 -0
- package/dist/tool_schema.js.map +1 -0
- package/package.json +12 -7
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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://
|
|
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
|
|
207
|
-
res.end('Invalid or missing state parameter.');
|
|
469
|
+
endResponse(res, 400, 'Invalid or missing state parameter.');
|
|
208
470
|
return;
|
|
209
471
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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.
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
|
257
|
-
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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)(
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
327
|
-
|
|
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
|
-
(
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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'})`);
|