testronaut 1.3.7 → 1.4.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/.github/dependabot.yml +11 -0
- package/.github/workflows/periapsis-vulnerability-daily.yml +58 -0
- package/.github/workflows/periapsis-vulnerability-exception-request.yml +86 -0
- package/.github/workflows/periapsis-vulnerability-pr-check.yml +23 -0
- package/README.md +71 -7
- package/TODO.md +15 -0
- package/bin/cli.js +117 -21
- package/core/agent.js +18 -5
- package/core/turnLoop.js +162 -2
- package/package.json +3 -2
- package/policy/vulnerability-exceptions.json +36 -0
- package/policy/vulnerability-notifications.json +1 -0
- package/policy/vulnerability-owners.json +1 -0
- package/policy/vulnerability-policy.json +33 -0
- package/tests/binTests/cli.helpers.test.js +55 -0
- package/tests/coreTests/turnLoop.test.js +114 -0
- package/tests/toolsTests/mfaCode.test.js +445 -0
- package/tests/toolsTests/toolSchema.test.js +8 -0
- package/tools/chromeBrowser.js +2 -0
- package/tools/mfaCode.js +637 -0
- package/tools/toolSchema.js +28 -0
package/tools/mfaCode.js
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
import fetch from 'node-fetch';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { loadConfig } from '../core/config.js';
|
|
5
|
+
import { maskPreview } from '../core/redaction.js';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_API_BASE = 'http://api.testronaut.app';
|
|
8
|
+
export const DEV_API_BASE = 'https://staging.api.testronaut.app';
|
|
9
|
+
|
|
10
|
+
const MIN_REMAINING_SECONDS = 5;
|
|
11
|
+
const MAX_REMAINING_SECONDS = 25;
|
|
12
|
+
const API_DEBUG_LOG_RELATIVE_PATH = 'missions/mission_reports/api-debug.log';
|
|
13
|
+
const REDACTED = '<redacted>';
|
|
14
|
+
|
|
15
|
+
const SENSITIVE_LOG_KEYS = new Set([
|
|
16
|
+
'authorization',
|
|
17
|
+
'apikey',
|
|
18
|
+
'api_key',
|
|
19
|
+
'api-key',
|
|
20
|
+
'bearer',
|
|
21
|
+
'code',
|
|
22
|
+
'mfacode',
|
|
23
|
+
'mfa_code',
|
|
24
|
+
'mfa-code',
|
|
25
|
+
'otp',
|
|
26
|
+
'password',
|
|
27
|
+
'secret',
|
|
28
|
+
'sessiontoken',
|
|
29
|
+
'session_token',
|
|
30
|
+
'session-token',
|
|
31
|
+
'token',
|
|
32
|
+
'totp',
|
|
33
|
+
'value',
|
|
34
|
+
'x-vercel-protection-bypass',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
function cleanString(value) {
|
|
38
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseDebugBool(value) {
|
|
42
|
+
const normalized = cleanString(value).toLowerCase();
|
|
43
|
+
return normalized === '1' ||
|
|
44
|
+
normalized === 'true' ||
|
|
45
|
+
normalized === 'yes' ||
|
|
46
|
+
normalized === 'on';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isApiDebugEnabled() {
|
|
50
|
+
return parseDebugBool(process.env.TESTRONAUT_API_DEBUG) ||
|
|
51
|
+
parseDebugBool(process.env.TESTRONAUT_DEBUG);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function apiDebugLogPath(cwd = process.cwd()) {
|
|
55
|
+
return path.resolve(cwd, API_DEBUG_LOG_RELATIVE_PATH);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeLogKey(key) {
|
|
59
|
+
return String(key || '').toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isSensitiveLogKey(key) {
|
|
63
|
+
return SENSITIVE_LOG_KEYS.has(normalizeLogKey(key));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function redactHeaders(headers = {}) {
|
|
67
|
+
const redacted = {};
|
|
68
|
+
for (const [key, value] of Object.entries(headers || {})) {
|
|
69
|
+
redacted[key] = isSensitiveLogKey(key) ? REDACTED : value;
|
|
70
|
+
}
|
|
71
|
+
return redacted;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function redactText(text = '') {
|
|
75
|
+
return String(text)
|
|
76
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, `Bearer ${REDACTED}`)
|
|
77
|
+
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, REDACTED)
|
|
78
|
+
.replace(/("(?:authorization|apiKey|api_key|code|mfaCode|otp|secret|sessionToken|token|totp|value)"\s*:\s*)"[^"]*"/gi, `$1"${REDACTED}"`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function redactLogValue(value, parentKey = '') {
|
|
82
|
+
if (isSensitiveLogKey(parentKey)) {
|
|
83
|
+
return REDACTED;
|
|
84
|
+
}
|
|
85
|
+
if (Array.isArray(value)) {
|
|
86
|
+
return value.map(item => redactLogValue(item));
|
|
87
|
+
}
|
|
88
|
+
if (value && typeof value === 'object') {
|
|
89
|
+
const redacted = {};
|
|
90
|
+
for (const [key, childValue] of Object.entries(value)) {
|
|
91
|
+
redacted[key] = redactLogValue(childValue, key);
|
|
92
|
+
}
|
|
93
|
+
return redacted;
|
|
94
|
+
}
|
|
95
|
+
if (typeof value === 'string') {
|
|
96
|
+
return redactText(value);
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function safeBodyPreview(text, data, limit = 2500) {
|
|
102
|
+
const source = data && typeof data === 'object'
|
|
103
|
+
? JSON.stringify(redactLogValue(data), null, 2)
|
|
104
|
+
: redactText(text || '');
|
|
105
|
+
|
|
106
|
+
if (source.length <= limit) return source;
|
|
107
|
+
return `${source.slice(0, limit)}... <truncated ${source.length - limit} chars>`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function responseKeysForLog(data) {
|
|
111
|
+
return data && typeof data === 'object' && !Array.isArray(data)
|
|
112
|
+
? Object.keys(data).slice(0, 20)
|
|
113
|
+
: [];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function writeApiDebugLog(entry, { cwd = process.cwd() } = {}) {
|
|
117
|
+
if (!isApiDebugEnabled()) return;
|
|
118
|
+
|
|
119
|
+
const filePath = apiDebugLogPath(cwd);
|
|
120
|
+
const safeEntry = redactLogValue({
|
|
121
|
+
timestamp: new Date().toISOString(),
|
|
122
|
+
...entry,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
127
|
+
fs.appendFileSync(filePath, `${JSON.stringify(safeEntry, null, 2)}\n`, 'utf8');
|
|
128
|
+
} catch {
|
|
129
|
+
// Debug logging should never break a mission run.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function cleanCodeValue(value) {
|
|
134
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
135
|
+
return String(Math.trunc(value)).padStart(6, '0');
|
|
136
|
+
}
|
|
137
|
+
return cleanString(value);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function normalizeNicknameForMatch(value) {
|
|
141
|
+
return cleanString(value).toLowerCase();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function compactNicknameForMatch(value) {
|
|
145
|
+
return normalizeNicknameForMatch(value).replace(/[^a-z0-9]/g, '');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function clampSeconds(raw, fallback = MIN_REMAINING_SECONDS) {
|
|
149
|
+
const n = Number(raw);
|
|
150
|
+
if (!Number.isFinite(n)) return fallback;
|
|
151
|
+
return Math.min(MAX_REMAINING_SECONDS, Math.max(0, Math.trunc(n)));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function bearerHeaders(sessionToken, extraHeaders = {}) {
|
|
155
|
+
const bypass =
|
|
156
|
+
cleanString(process.env.VERCEL_AUTOMATION_BYPASS_SECRET) ||
|
|
157
|
+
cleanString(process.env.TESTRONAUT_VERCEL_BYPASS);
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
Accept: 'application/json',
|
|
161
|
+
...extraHeaders,
|
|
162
|
+
Authorization: `Bearer ${sessionToken}`,
|
|
163
|
+
...(bypass ? { 'x-vercel-protection-bypass': bypass } : {}),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function resolveApiBase(options = {}) {
|
|
168
|
+
return (
|
|
169
|
+
cleanString(options.apiBase) ||
|
|
170
|
+
cleanString(process.env.TESTRONAUT_API_BASE_EFFECTIVE) ||
|
|
171
|
+
cleanString(process.env.TESTRONAUT_API_BASE) ||
|
|
172
|
+
DEFAULT_API_BASE
|
|
173
|
+
).replace(/\/+$/, '');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function resolveMfaNickname(args = {}, cfg = {}) {
|
|
177
|
+
return (
|
|
178
|
+
cleanString(args.nickname) ||
|
|
179
|
+
cleanString(args.mfaName) ||
|
|
180
|
+
cleanString(args.name) ||
|
|
181
|
+
cleanString(process.env.TESTRONAUT_MFA_NAME) ||
|
|
182
|
+
cleanString(cfg.mfaName) ||
|
|
183
|
+
cleanString(cfg.mfa?.name) ||
|
|
184
|
+
cleanString(cfg.mfa?.nickname)
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function resolveSessionToken(args = {}, cfg = {}) {
|
|
189
|
+
return (
|
|
190
|
+
cleanString(args.sessionToken) ||
|
|
191
|
+
cleanString(process.env.TESTRONAUT_SESSION_TOKEN) ||
|
|
192
|
+
cleanString(cfg.sessionToken)
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function errorCodeForStatus(status, error = '') {
|
|
197
|
+
const normalized = String(error).toLowerCase();
|
|
198
|
+
if (status === 401) return 'invalid_session';
|
|
199
|
+
if (status === 402) return 'premium_required';
|
|
200
|
+
if (status === 404 && normalized.includes('not enabled')) return 'feature_disabled';
|
|
201
|
+
if (status === 404) return 'not_found';
|
|
202
|
+
if (status === 429) return 'rate_limited';
|
|
203
|
+
return 'api_error';
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function friendlyMfaError(code, apiError = '') {
|
|
207
|
+
if (code === 'premium_required') {
|
|
208
|
+
return 'This Testronaut account is not eligible for automated MFA because it does not have an active paid subscription.';
|
|
209
|
+
}
|
|
210
|
+
if (code === 'invalid_session') {
|
|
211
|
+
return 'The saved Testronaut session token is invalid or expired. Run `testronaut login` again.';
|
|
212
|
+
}
|
|
213
|
+
if (code === 'feature_disabled') {
|
|
214
|
+
return 'Automated MFA is not enabled for this Testronaut environment.';
|
|
215
|
+
}
|
|
216
|
+
if (code === 'not_found') {
|
|
217
|
+
return 'No stored MFA entry was found for that nickname.';
|
|
218
|
+
}
|
|
219
|
+
if (code === 'rate_limited') {
|
|
220
|
+
return 'The MFA API is rate limited. Try again shortly.';
|
|
221
|
+
}
|
|
222
|
+
if (code === 'non_json_response') {
|
|
223
|
+
return 'The MFA API returned a non-JSON response. This usually means the CLI is pointed at the app host or a fallback page instead of the API host.';
|
|
224
|
+
}
|
|
225
|
+
return apiError || 'Unable to retrieve MFA code.';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function normalizeApiCode(data) {
|
|
229
|
+
const codeObject =
|
|
230
|
+
data?.mfaCode && typeof data.mfaCode === 'object'
|
|
231
|
+
? data.mfaCode
|
|
232
|
+
: data?.code && typeof data.code === 'object'
|
|
233
|
+
? data.code
|
|
234
|
+
: data?.totp && typeof data.totp === 'object'
|
|
235
|
+
? data.totp
|
|
236
|
+
: data?.otp && typeof data.otp === 'object'
|
|
237
|
+
? data.otp
|
|
238
|
+
: {
|
|
239
|
+
code:
|
|
240
|
+
data?.mfaCode ??
|
|
241
|
+
data?.code ??
|
|
242
|
+
data?.totp ??
|
|
243
|
+
data?.otp ??
|
|
244
|
+
data?.value ??
|
|
245
|
+
data?.token,
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const value = cleanCodeValue(
|
|
249
|
+
codeObject.code ??
|
|
250
|
+
codeObject.value ??
|
|
251
|
+
codeObject.mfaCode ??
|
|
252
|
+
codeObject.totp ??
|
|
253
|
+
codeObject.otp ??
|
|
254
|
+
codeObject.token
|
|
255
|
+
);
|
|
256
|
+
if (!value) return null;
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
value,
|
|
260
|
+
expiresAt: codeObject.expiresAt,
|
|
261
|
+
secondsRemaining: Number(codeObject.secondsRemaining),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function normalizeMfaNicknames(data) {
|
|
266
|
+
const fromNicknames = Array.isArray(data?.nicknames)
|
|
267
|
+
? data.nicknames
|
|
268
|
+
: [];
|
|
269
|
+
const fromEntries = Array.isArray(data?.entries)
|
|
270
|
+
? data.entries.map(entry => entry?.nickname)
|
|
271
|
+
: [];
|
|
272
|
+
const seen = new Set();
|
|
273
|
+
const nicknames = [];
|
|
274
|
+
|
|
275
|
+
for (const raw of [...fromNicknames, ...fromEntries]) {
|
|
276
|
+
const nickname = cleanString(raw);
|
|
277
|
+
const key = normalizeNicknameForMatch(nickname);
|
|
278
|
+
if (!nickname || seen.has(key)) continue;
|
|
279
|
+
seen.add(key);
|
|
280
|
+
nicknames.push(nickname);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return nicknames;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function findListedNicknameMatch(requestedNickname, availableNicknames = []) {
|
|
287
|
+
const requested = normalizeNicknameForMatch(requestedNickname);
|
|
288
|
+
const exact = availableNicknames.find(
|
|
289
|
+
nickname => normalizeNicknameForMatch(nickname) === requested
|
|
290
|
+
);
|
|
291
|
+
if (exact) return exact;
|
|
292
|
+
|
|
293
|
+
const compactRequested = compactNicknameForMatch(requestedNickname);
|
|
294
|
+
const compactMatches = availableNicknames.filter(
|
|
295
|
+
nickname => compactNicknameForMatch(nickname) === compactRequested
|
|
296
|
+
);
|
|
297
|
+
return compactMatches.length === 1 ? compactMatches[0] : null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function fetchMfaList({ apiBase, sessionToken, fetchImpl = fetch, cwd = process.cwd() }) {
|
|
301
|
+
const url = new URL('/api/mfa/list', `${apiBase}/`);
|
|
302
|
+
const headers = bearerHeaders(sessionToken);
|
|
303
|
+
const response = await fetchImpl(url.toString(), {
|
|
304
|
+
method: 'GET',
|
|
305
|
+
headers,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const text = await response.text();
|
|
309
|
+
const contentType = response.headers?.get?.('content-type') || response.headers?.get?.('Content-Type') || '';
|
|
310
|
+
let data = null;
|
|
311
|
+
try {
|
|
312
|
+
data = text ? JSON.parse(text) : null;
|
|
313
|
+
} catch {
|
|
314
|
+
data = null;
|
|
315
|
+
}
|
|
316
|
+
const availableNicknames = response.ok ? normalizeMfaNicknames(data) : [];
|
|
317
|
+
|
|
318
|
+
writeApiDebugLog({
|
|
319
|
+
type: 'mfa-api',
|
|
320
|
+
endpoint: 'list',
|
|
321
|
+
request: {
|
|
322
|
+
method: 'GET',
|
|
323
|
+
url: url.toString(),
|
|
324
|
+
headers: redactHeaders(headers),
|
|
325
|
+
},
|
|
326
|
+
response: {
|
|
327
|
+
ok: response.ok,
|
|
328
|
+
statusCode: response.status,
|
|
329
|
+
statusText: response.statusText,
|
|
330
|
+
contentType,
|
|
331
|
+
bodyLength: text.length,
|
|
332
|
+
responseKeys: responseKeysForLog(data),
|
|
333
|
+
bodyPreview: safeBodyPreview(text, data),
|
|
334
|
+
},
|
|
335
|
+
parsed: {
|
|
336
|
+
availableNicknames,
|
|
337
|
+
responseShape: data && typeof data === 'object' ? 'json' : 'non_json',
|
|
338
|
+
},
|
|
339
|
+
}, { cwd });
|
|
340
|
+
|
|
341
|
+
if (response.ok && !data) {
|
|
342
|
+
const apiError = `Expected JSON from ${url.origin}/api/mfa/list but received ${contentType || 'unknown content type'}.`;
|
|
343
|
+
return {
|
|
344
|
+
ok: false,
|
|
345
|
+
status: response.status,
|
|
346
|
+
code: 'non_json_response',
|
|
347
|
+
error: friendlyMfaError('non_json_response', apiError),
|
|
348
|
+
apiError,
|
|
349
|
+
contentType,
|
|
350
|
+
availableNicknames: [],
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (!response.ok) {
|
|
355
|
+
const apiError = data?.error || text || response.statusText || 'Unable to list MFA entries';
|
|
356
|
+
const code = errorCodeForStatus(response.status, apiError);
|
|
357
|
+
return {
|
|
358
|
+
ok: false,
|
|
359
|
+
status: response.status,
|
|
360
|
+
code,
|
|
361
|
+
error: friendlyMfaError(code, apiError),
|
|
362
|
+
apiError,
|
|
363
|
+
availableNicknames: [],
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
ok: true,
|
|
369
|
+
availableNicknames,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function addAvailableNicknames(result, { apiBase, sessionToken, fetchImpl, cwd }) {
|
|
374
|
+
const list = await fetchMfaList({ apiBase, sessionToken, fetchImpl, cwd });
|
|
375
|
+
return {
|
|
376
|
+
...result,
|
|
377
|
+
availableNicknames: list.ok ? list.availableNicknames : [],
|
|
378
|
+
mfaListStatus: list.ok
|
|
379
|
+
? 'available'
|
|
380
|
+
: {
|
|
381
|
+
code: list.code,
|
|
382
|
+
status: list.status,
|
|
383
|
+
error: list.error,
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function fetchMfaCode({ apiBase, sessionToken, nickname, fetchImpl = fetch, cwd = process.cwd() }) {
|
|
389
|
+
const url = new URL('/api/mfa', `${apiBase}/`);
|
|
390
|
+
url.searchParams.set('nickname', nickname);
|
|
391
|
+
const headers = bearerHeaders(sessionToken);
|
|
392
|
+
|
|
393
|
+
const response = await fetchImpl(url.toString(), {
|
|
394
|
+
method: 'GET',
|
|
395
|
+
headers,
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const text = await response.text();
|
|
399
|
+
const contentType = response.headers?.get?.('content-type') || response.headers?.get?.('Content-Type') || '';
|
|
400
|
+
let data = null;
|
|
401
|
+
try {
|
|
402
|
+
data = text ? JSON.parse(text) : null;
|
|
403
|
+
} catch {
|
|
404
|
+
data = null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
writeApiDebugLog({
|
|
408
|
+
type: 'mfa-api',
|
|
409
|
+
endpoint: 'get_code',
|
|
410
|
+
request: {
|
|
411
|
+
method: 'GET',
|
|
412
|
+
url: url.toString(),
|
|
413
|
+
nickname,
|
|
414
|
+
headers: redactHeaders(headers),
|
|
415
|
+
},
|
|
416
|
+
response: {
|
|
417
|
+
ok: response.ok,
|
|
418
|
+
statusCode: response.status,
|
|
419
|
+
statusText: response.statusText,
|
|
420
|
+
contentType,
|
|
421
|
+
bodyLength: text.length,
|
|
422
|
+
responseKeys: responseKeysForLog(data),
|
|
423
|
+
bodyPreview: safeBodyPreview(text, data),
|
|
424
|
+
},
|
|
425
|
+
parsed: {
|
|
426
|
+
responseShape: response.ok
|
|
427
|
+
? (data ? (normalizeApiCode(data) ? 'recognized_code' : 'unrecognized_code') : 'non_json')
|
|
428
|
+
: 'error_response',
|
|
429
|
+
},
|
|
430
|
+
}, { cwd });
|
|
431
|
+
|
|
432
|
+
if (response.ok && !data) {
|
|
433
|
+
const apiError = `Expected JSON from ${url.origin}/api/mfa but received ${contentType || 'unknown content type'}.`;
|
|
434
|
+
return {
|
|
435
|
+
ok: false,
|
|
436
|
+
status: response.status,
|
|
437
|
+
code: 'non_json_response',
|
|
438
|
+
error: friendlyMfaError('non_json_response', apiError),
|
|
439
|
+
apiError,
|
|
440
|
+
contentType,
|
|
441
|
+
nickname,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (!response.ok) {
|
|
446
|
+
const apiError = data?.error || text || response.statusText || 'Unable to retrieve MFA code';
|
|
447
|
+
const code = errorCodeForStatus(response.status, apiError);
|
|
448
|
+
return {
|
|
449
|
+
ok: false,
|
|
450
|
+
status: response.status,
|
|
451
|
+
code,
|
|
452
|
+
error: friendlyMfaError(code, apiError),
|
|
453
|
+
apiError,
|
|
454
|
+
nickname,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const mfaCode = normalizeApiCode(data);
|
|
459
|
+
if (!mfaCode) {
|
|
460
|
+
const responseKeys = data && typeof data === 'object' && !Array.isArray(data)
|
|
461
|
+
? Object.keys(data).slice(0, 12)
|
|
462
|
+
: [];
|
|
463
|
+
return {
|
|
464
|
+
ok: false,
|
|
465
|
+
status: response.status,
|
|
466
|
+
code: 'invalid_response',
|
|
467
|
+
error: responseKeys.length
|
|
468
|
+
? `MFA API response did not include a recognized code field. Response keys: ${responseKeys.join(', ')}.`
|
|
469
|
+
: 'MFA API response did not include a recognized code field.',
|
|
470
|
+
contentType,
|
|
471
|
+
responseKeys,
|
|
472
|
+
nickname,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
ok: true,
|
|
478
|
+
codeType: 'totp',
|
|
479
|
+
nickname: data?.nickname || nickname,
|
|
480
|
+
value: mfaCode.value,
|
|
481
|
+
redactedValue: maskPreview(mfaCode.value),
|
|
482
|
+
mfaCode: {
|
|
483
|
+
code: mfaCode.value,
|
|
484
|
+
expiresAt: mfaCode.expiresAt,
|
|
485
|
+
secondsRemaining: Number.isFinite(mfaCode.secondsRemaining)
|
|
486
|
+
? mfaCode.secondsRemaining
|
|
487
|
+
: undefined,
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Retrieve a TOTP MFA code from the Testronaut API.
|
|
494
|
+
*
|
|
495
|
+
* @param {{ nickname?: string, mfaName?: string, name?: string, sessionToken?: string, minSecondsRemaining?: number, waitForFreshCode?: boolean }} args
|
|
496
|
+
* @param {{ cwd?: string, apiBase?: string, fetchImpl?: Function, config?: object }} options
|
|
497
|
+
* @returns {Promise<object>}
|
|
498
|
+
*/
|
|
499
|
+
export async function getMfaCode(args = {}, options = {}) {
|
|
500
|
+
const cwd = options.cwd || process.cwd();
|
|
501
|
+
const cfg = options.config ?? await loadConfig(cwd);
|
|
502
|
+
const nickname = resolveMfaNickname(args, cfg);
|
|
503
|
+
const sessionToken = resolveSessionToken(args, cfg);
|
|
504
|
+
const apiBase = resolveApiBase(options);
|
|
505
|
+
const minSecondsRemaining = clampSeconds(args.minSecondsRemaining);
|
|
506
|
+
const waitForFreshCode = args.waitForFreshCode !== false;
|
|
507
|
+
const fetchImpl = options.fetchImpl || fetch;
|
|
508
|
+
|
|
509
|
+
async function fetchFreshCode(resolvedNickname, extra = {}) {
|
|
510
|
+
let result = await fetchMfaCode({
|
|
511
|
+
apiBase,
|
|
512
|
+
sessionToken,
|
|
513
|
+
nickname: resolvedNickname,
|
|
514
|
+
fetchImpl,
|
|
515
|
+
cwd,
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
const remaining = result?.mfaCode?.secondsRemaining;
|
|
519
|
+
if (
|
|
520
|
+
result.ok &&
|
|
521
|
+
waitForFreshCode &&
|
|
522
|
+
Number.isFinite(remaining) &&
|
|
523
|
+
remaining > 0 &&
|
|
524
|
+
remaining < minSecondsRemaining
|
|
525
|
+
) {
|
|
526
|
+
await new Promise(resolve => setTimeout(resolve, (remaining + 1) * 1000));
|
|
527
|
+
result = await fetchMfaCode({
|
|
528
|
+
apiBase,
|
|
529
|
+
sessionToken,
|
|
530
|
+
nickname: resolvedNickname,
|
|
531
|
+
fetchImpl,
|
|
532
|
+
cwd,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
...result,
|
|
538
|
+
...extra,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (!nickname) {
|
|
543
|
+
if (sessionToken) {
|
|
544
|
+
const list = await fetchMfaList({ apiBase, sessionToken, fetchImpl, cwd });
|
|
545
|
+
if (list.ok && list.availableNicknames.length === 1) {
|
|
546
|
+
return fetchFreshCode(list.availableNicknames[0], {
|
|
547
|
+
resolvedFromList: true,
|
|
548
|
+
requestedNickname: '',
|
|
549
|
+
availableNicknames: list.availableNicknames,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
return {
|
|
553
|
+
ok: false,
|
|
554
|
+
code: 'missing_nickname',
|
|
555
|
+
error: list.ok && list.availableNicknames.length
|
|
556
|
+
? `MFA nickname is required. Available MFA nicknames: ${list.availableNicknames.join(', ')}.`
|
|
557
|
+
: 'MFA nickname is required. Pass nickname to the tool, set mfaName in testronaut-config.json, or run with -o mfa=<nickname>.',
|
|
558
|
+
availableNicknames: list.ok ? list.availableNicknames : [],
|
|
559
|
+
mfaListStatus: list.ok
|
|
560
|
+
? 'available'
|
|
561
|
+
: {
|
|
562
|
+
code: list.code,
|
|
563
|
+
status: list.status,
|
|
564
|
+
error: list.error,
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return {
|
|
570
|
+
ok: false,
|
|
571
|
+
code: 'missing_nickname',
|
|
572
|
+
error:
|
|
573
|
+
'MFA nickname is required. Pass nickname to the tool, set mfaName in testronaut-config.json, or run with -o mfa=<nickname>.',
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (!sessionToken) {
|
|
578
|
+
return {
|
|
579
|
+
ok: false,
|
|
580
|
+
code: 'missing_session_token',
|
|
581
|
+
error:
|
|
582
|
+
'No sessionToken found. Run `testronaut login` or add sessionToken to testronaut-config.json.',
|
|
583
|
+
nickname,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
try {
|
|
588
|
+
const result = await fetchFreshCode(nickname);
|
|
589
|
+
if (result.ok) {
|
|
590
|
+
return result;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const resultWithList = await addAvailableNicknames(result, {
|
|
594
|
+
apiBase,
|
|
595
|
+
sessionToken,
|
|
596
|
+
fetchImpl,
|
|
597
|
+
cwd,
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
if (result.code === 'not_found' && resultWithList.availableNicknames?.length) {
|
|
601
|
+
const matchedNickname = findListedNicknameMatch(
|
|
602
|
+
nickname,
|
|
603
|
+
resultWithList.availableNicknames
|
|
604
|
+
);
|
|
605
|
+
if (matchedNickname && matchedNickname !== nickname) {
|
|
606
|
+
return fetchFreshCode(matchedNickname, {
|
|
607
|
+
requestedNickname: nickname,
|
|
608
|
+
resolvedFromList: true,
|
|
609
|
+
availableNicknames: resultWithList.availableNicknames,
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return resultWithList;
|
|
615
|
+
} catch (error) {
|
|
616
|
+
return {
|
|
617
|
+
ok: false,
|
|
618
|
+
code: 'network_error',
|
|
619
|
+
error: error?.message || 'Unable to reach MFA API',
|
|
620
|
+
nickname,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export const __test__ = {
|
|
626
|
+
resolveApiBase,
|
|
627
|
+
resolveMfaNickname,
|
|
628
|
+
errorCodeForStatus,
|
|
629
|
+
normalizeApiCode,
|
|
630
|
+
normalizeMfaNicknames,
|
|
631
|
+
findListedNicknameMatch,
|
|
632
|
+
bearerHeaders,
|
|
633
|
+
friendlyMfaError,
|
|
634
|
+
apiDebugLogPath,
|
|
635
|
+
isApiDebugEnabled,
|
|
636
|
+
redactLogValue,
|
|
637
|
+
};
|
package/tools/toolSchema.js
CHANGED
|
@@ -328,6 +328,34 @@ const toolsSchema = [
|
|
|
328
328
|
},
|
|
329
329
|
},
|
|
330
330
|
},
|
|
331
|
+
{
|
|
332
|
+
type: 'function',
|
|
333
|
+
function: {
|
|
334
|
+
name: 'get_mfa_code',
|
|
335
|
+
description:
|
|
336
|
+
'Retrieve a current TOTP MFA code from the Testronaut API using the configured sessionToken. Prefer this before request_human_input. If the nickname is missing or does not match, the tool can list available MFA nicknames and retry simple case/spacing/punctuation mismatches.',
|
|
337
|
+
parameters: {
|
|
338
|
+
type: 'object',
|
|
339
|
+
properties: {
|
|
340
|
+
nickname: {
|
|
341
|
+
type: 'string',
|
|
342
|
+
description: 'Nickname of the stored MFA entry, such as "github-test-mfa". Optional if mfaName is configured.'
|
|
343
|
+
},
|
|
344
|
+
minSecondsRemaining: {
|
|
345
|
+
type: 'number',
|
|
346
|
+
default: 5,
|
|
347
|
+
description: 'If the code is about to expire, wait for a fresher code when possible.'
|
|
348
|
+
},
|
|
349
|
+
waitForFreshCode: {
|
|
350
|
+
type: 'boolean',
|
|
351
|
+
default: true,
|
|
352
|
+
description: 'Whether to wait and retry when the current TOTP code has very little time remaining.'
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
required: []
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
},
|
|
331
359
|
{
|
|
332
360
|
type: 'function',
|
|
333
361
|
function: {
|