mixdog 0.9.50 → 0.9.51
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/package.json +1 -1
- package/scripts/hook-bus-test.mjs +23 -0
- package/scripts/internal-tools-normalization-test.mjs +10 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +67 -1
- package/scripts/provider-toolcall-test.mjs +200 -2
- package/scripts/session-bench-cache-break-test.mjs +102 -0
- package/scripts/session-bench.mjs +101 -42
- package/scripts/shell-failure-diagnostics-test.mjs +73 -4
- package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
- package/scripts/smoke-loop-failure-summary.mjs +16 -0
- package/scripts/smoke-loop.mjs +2 -7
- package/scripts/tool-failures.mjs +15 -1
- package/scripts/tui-transcript-perf-test.mjs +38 -0
- package/scripts/web-fetch-routing-test.mjs +158 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
- package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +14 -4
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +75 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +4 -0
- package/src/runtime/search/index.mjs +41 -0
- package/src/runtime/search/lib/http-fetch.mjs +154 -0
- package/src/runtime/search/tool-defs.mjs +23 -0
- package/src/session-runtime/runtime-core.mjs +37 -15
- package/src/standalone/hook-bus/handlers.mjs +4 -0
- package/src/standalone/hook-bus/rules.mjs +7 -1
- package/src/standalone/hook-bus.mjs +10 -2
- package/src/tui/App.jsx +17 -11
- package/src/tui/app/live-spinner-visibility.mjs +20 -0
- package/src/tui/dist/index.mjs +28 -3
|
@@ -210,10 +210,27 @@ function cacheBreakExplanation(reason) {
|
|
|
210
210
|
return r ? '\uC6D0\uC778 \uBBF8\uBD84\uB958' : '\uC6D0\uC778 \uAE30\uB85D \uC5C6\uC74C';
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
function cacheBreakTransitionTag(row) {
|
|
214
|
+
const transition = field(row, 'intentional_transition');
|
|
215
|
+
return typeof transition === 'string' && transition ? transition : null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function cacheBreakIntentionalTransition(row) {
|
|
219
|
+
const transition = cacheBreakTransitionTag(row);
|
|
220
|
+
const reason = field(row, 'reason') || field(row, 'chain_delta_reason') || null;
|
|
221
|
+
if (transition === 'automatic_compaction' && reason === 'input_prefix_mismatch') return transition;
|
|
222
|
+
if (transition === 'transcript_rebuild' && reason === 'input_prefix_mismatch') return transition;
|
|
223
|
+
if (transition === 'explorer_hard_cap_final_tool_choice_none'
|
|
224
|
+
&& reason === 'request_properties_changed'
|
|
225
|
+
&& field(row, 'request_tool_choice') === 'none') return transition;
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
213
229
|
function classifyCacheBreakPhase(row, usageRows, transportRows) {
|
|
214
230
|
const reason = field(row, 'reason') || field(row, 'chain_delta_reason') || field(row, 'payload')?.reason || null;
|
|
215
231
|
const sid = sessionId(row);
|
|
216
|
-
|
|
232
|
+
const intentionalTransition = cacheBreakIntentionalTransition(row);
|
|
233
|
+
if (intentionalTransition) return `intentional_${intentionalTransition}`;
|
|
217
234
|
const ts = Number(row.ts || 0);
|
|
218
235
|
const priorUsage = usageRows.filter((r) => sessionId(r) === sid && Number(r.ts || 0) < ts)
|
|
219
236
|
.sort((a, b) => Number(b.ts || 0) - Number(a.ts || 0));
|
|
@@ -397,6 +414,44 @@ function buildCacheDiagnostics(rows) {
|
|
|
397
414
|
model: field(r, 'model'),
|
|
398
415
|
ts: r.ts,
|
|
399
416
|
}));
|
|
417
|
+
const cacheBreaks = breaks.map((r) => {
|
|
418
|
+
const reason = field(r, 'reason') || field(r, 'chain_delta_reason') || field(r, 'payload')?.reason || null;
|
|
419
|
+
const relatedUsage = nearestRowAround(
|
|
420
|
+
usage,
|
|
421
|
+
r.ts,
|
|
422
|
+
5_000,
|
|
423
|
+
(u) => sessionId(u) === sessionId(r) && num(u, 'iteration') === num(r, 'iteration'),
|
|
424
|
+
) || nearestRowBefore(
|
|
425
|
+
usage.filter((u) => sessionId(u) === sessionId(r)),
|
|
426
|
+
r.ts,
|
|
427
|
+
30_000,
|
|
428
|
+
);
|
|
429
|
+
const promptTokens = relatedUsage ? cacheDenom(relatedUsage) : 0;
|
|
430
|
+
const cachedTokens = relatedUsage ? (num(relatedUsage, 'cached_tokens') || 0) : 0;
|
|
431
|
+
const transitionTag = cacheBreakTransitionTag(r);
|
|
432
|
+
const transition = cacheBreakIntentionalTransition(r);
|
|
433
|
+
return {
|
|
434
|
+
session_id: sessionId(r),
|
|
435
|
+
iteration: num(r, 'iteration'),
|
|
436
|
+
reason,
|
|
437
|
+
phase: classifyCacheBreakPhase(r, usage, transport),
|
|
438
|
+
intentional_transition: transitionTag,
|
|
439
|
+
actionable: !transition,
|
|
440
|
+
explanation: cacheBreakExplanation(reason),
|
|
441
|
+
ws_mode: field(r, 'ws_mode'),
|
|
442
|
+
cache_key_hash: field(r, 'cache_key_hash'),
|
|
443
|
+
request_tool_choice: field(r, 'request_tool_choice'),
|
|
444
|
+
request_has_previous_response_id: field(r, 'request_has_previous_response_id'),
|
|
445
|
+
body_input_items: field(r, 'body_input_items'),
|
|
446
|
+
frame_input_items: field(r, 'frame_input_items'),
|
|
447
|
+
prompt_tokens: promptTokens,
|
|
448
|
+
cached_tokens: cachedTokens,
|
|
449
|
+
cache_ratio: promptTokens > 0 ? cachedTokens / promptTokens : null,
|
|
450
|
+
output_tokens: relatedUsage ? (num(relatedUsage, 'output_tokens') || 0) : 0,
|
|
451
|
+
ts: r.ts,
|
|
452
|
+
};
|
|
453
|
+
});
|
|
454
|
+
const actionableCacheBreaks = cacheBreaks.filter((b) => b.actionable);
|
|
400
455
|
return {
|
|
401
456
|
usage_cache_ratio: cacheRatioFromUsage(usage),
|
|
402
457
|
cached_tokens: sum(usage.map((r) => num(r, 'cached_tokens'))),
|
|
@@ -407,38 +462,9 @@ function buildCacheDiagnostics(rows) {
|
|
|
407
462
|
reused_connection: transport.filter((r) => field(r, 'reused_connection') === true).length,
|
|
408
463
|
previous_response_id: transport.filter((r) => field(r, 'request_has_previous_response_id') === true).length,
|
|
409
464
|
cache_key_hashes: [...keyCounts.entries()].map(([key, count]) => ({ key, count })).sort((a, b) => b.count - a.count),
|
|
410
|
-
cache_breaks:
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
usage,
|
|
414
|
-
r.ts,
|
|
415
|
-
5_000,
|
|
416
|
-
(u) => sessionId(u) === sessionId(r) && num(u, 'iteration') === num(r, 'iteration'),
|
|
417
|
-
) || nearestRowBefore(
|
|
418
|
-
usage.filter((u) => sessionId(u) === sessionId(r)),
|
|
419
|
-
r.ts,
|
|
420
|
-
30_000,
|
|
421
|
-
);
|
|
422
|
-
const promptTokens = relatedUsage ? cacheDenom(relatedUsage) : 0;
|
|
423
|
-
const cachedTokens = relatedUsage ? (num(relatedUsage, 'cached_tokens') || 0) : 0;
|
|
424
|
-
return {
|
|
425
|
-
session_id: sessionId(r),
|
|
426
|
-
iteration: num(r, 'iteration'),
|
|
427
|
-
reason,
|
|
428
|
-
phase: classifyCacheBreakPhase(r, usage, transport),
|
|
429
|
-
explanation: cacheBreakExplanation(reason),
|
|
430
|
-
ws_mode: field(r, 'ws_mode'),
|
|
431
|
-
cache_key_hash: field(r, 'cache_key_hash'),
|
|
432
|
-
request_has_previous_response_id: field(r, 'request_has_previous_response_id'),
|
|
433
|
-
body_input_items: field(r, 'body_input_items'),
|
|
434
|
-
frame_input_items: field(r, 'frame_input_items'),
|
|
435
|
-
prompt_tokens: promptTokens,
|
|
436
|
-
cached_tokens: cachedTokens,
|
|
437
|
-
cache_ratio: promptTokens > 0 ? cachedTokens / promptTokens : null,
|
|
438
|
-
output_tokens: relatedUsage ? (num(relatedUsage, 'output_tokens') || 0) : 0,
|
|
439
|
-
ts: r.ts,
|
|
440
|
-
};
|
|
441
|
-
}),
|
|
465
|
+
cache_breaks: cacheBreaks,
|
|
466
|
+
actionable_cache_breaks: actionableCacheBreaks,
|
|
467
|
+
intentional_cache_breaks: cacheBreaks.filter((b) => !b.actionable),
|
|
442
468
|
actual_cache_misses: misses.map((r) => ({
|
|
443
469
|
session_id: sessionId(r),
|
|
444
470
|
iteration: num(r, 'iteration'),
|
|
@@ -1146,6 +1172,18 @@ function buildCompactDiagnostics(rows, selectedIds) {
|
|
|
1146
1172
|
};
|
|
1147
1173
|
}
|
|
1148
1174
|
|
|
1175
|
+
function selectCacheBreakIssues(rows, limit = 10) {
|
|
1176
|
+
const ordered = [...rows].sort((a, b) => (
|
|
1177
|
+
Number(a.ts || 0) - Number(b.ts || 0)
|
|
1178
|
+
|| String(a.session_id || '').localeCompare(String(b.session_id || ''))
|
|
1179
|
+
|| Number(a.iteration || 0) - Number(b.iteration || 0)
|
|
1180
|
+
));
|
|
1181
|
+
const root = ordered.filter((row) => !isCompactSessionId(row.session_id));
|
|
1182
|
+
const compact = ordered.filter((row) => isCompactSessionId(row.session_id));
|
|
1183
|
+
if (!root.length || !compact.length || limit < 2) return ordered.slice(0, limit);
|
|
1184
|
+
return [...root.slice(0, limit - 1), ...compact.slice(0, 1)];
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1149
1187
|
function buildIssues(routeGroups, cache, tools) {
|
|
1150
1188
|
const issues = [];
|
|
1151
1189
|
for (const stall of (tools.readonly_stalls || []).slice(0, 10)) {
|
|
@@ -1181,7 +1219,7 @@ function buildIssues(routeGroups, cache, tools) {
|
|
|
1181
1219
|
issues.push({ severity: 'high', type: 'cache', message: `${g.agent || shortId(g.session_id)} low cache ratio ${fmtPct(g.cache_ratio * 100)}`, session_id: g.session_id });
|
|
1182
1220
|
}
|
|
1183
1221
|
}
|
|
1184
|
-
for (const b of cache.
|
|
1222
|
+
for (const b of selectCacheBreakIssues(cache.actionable_cache_breaks, 10)) {
|
|
1185
1223
|
issues.push({ severity: b.reason === 'no_anchor' ? 'medium' : 'high', type: 'cache_break', message: `cache_break ${b.reason || 'unknown'} at it=${b.iteration ?? '-'}`, session_id: b.session_id });
|
|
1186
1224
|
}
|
|
1187
1225
|
for (const m of (cache.actual_cache_misses || []).slice(0, 10)) {
|
|
@@ -1253,9 +1291,9 @@ function buildWhySlowRankings(report) {
|
|
|
1253
1291
|
const summary = countBy(report.tools.failures, (f) => f.category || f.tool || 'unknown').slice(0, 3).map(([k, v]) => `${k}×${v}`).join(', ');
|
|
1254
1292
|
ranks.push({ score: 50 + report.tools.failures.length, type: 'tool_failures', message: `${report.tools.failures.length} failed tool call(s): ${summary}` });
|
|
1255
1293
|
}
|
|
1256
|
-
if (report.cache.
|
|
1257
|
-
const summary = countBy(report.cache.
|
|
1258
|
-
ranks.push({ score: 40 + report.cache.
|
|
1294
|
+
if (report.cache.actionable_cache_breaks.length) {
|
|
1295
|
+
const summary = countBy(report.cache.actionable_cache_breaks, (b) => `${b.reason || 'unknown'}/${b.phase || 'unknown'}`).slice(0, 3).map(([k, v]) => `${k}×${v}`).join(', ');
|
|
1296
|
+
ranks.push({ score: 40 + report.cache.actionable_cache_breaks.length, type: 'cache_breaks', message: `${report.cache.actionable_cache_breaks.length} actionable cache break(s): ${summary}` });
|
|
1259
1297
|
}
|
|
1260
1298
|
if (report.cache.actual_cache_misses?.length) {
|
|
1261
1299
|
const top = [...report.cache.actual_cache_misses].sort((a, b) => (b.uncached_tokens || 0) - (a.uncached_tokens || 0))[0];
|
|
@@ -1311,11 +1349,24 @@ function buildReport(rows, selectedIds, failureRows = []) {
|
|
|
1311
1349
|
const selected = rows.filter((r) => selectedIds.includes(sessionId(r)));
|
|
1312
1350
|
const selectedFailures = failureRows.filter((r) => selectedIds.includes(sessionId(r)));
|
|
1313
1351
|
const routeGroups = buildRouteGroups(selected);
|
|
1314
|
-
const
|
|
1352
|
+
const selectedCache = buildCacheDiagnostics(selected);
|
|
1315
1353
|
const tools = buildToolDiagnostics(selected, selectedFailures);
|
|
1316
1354
|
const stages = buildTurnDiagnostics(selected, routeGroups);
|
|
1317
1355
|
const tokens = buildTokenDiagnostics(stages.turns);
|
|
1318
1356
|
const compact = buildCompactDiagnostics(rows, selectedIds);
|
|
1357
|
+
// Compact workers are normally sibling sessions rather than direct children,
|
|
1358
|
+
// so their genuine mismatches are absent from `selected`. Promote only those
|
|
1359
|
+
// actionable rows into the root report; intentional compact transitions stay
|
|
1360
|
+
// isolated in compact diagnostics and selected compact children never duplicate.
|
|
1361
|
+
const selectedSessionIds = new Set(selectedIds);
|
|
1362
|
+
const compactActionableBreaks = compact.cache_breaks.filter(
|
|
1363
|
+
(row) => row.actionable && !selectedSessionIds.has(row.session_id),
|
|
1364
|
+
);
|
|
1365
|
+
const cache = {
|
|
1366
|
+
...selectedCache,
|
|
1367
|
+
cache_breaks: [...selectedCache.cache_breaks, ...compactActionableBreaks],
|
|
1368
|
+
actionable_cache_breaks: [...selectedCache.actionable_cache_breaks, ...compactActionableBreaks],
|
|
1369
|
+
};
|
|
1319
1370
|
const issues = buildIssues(routeGroups, cache, tools);
|
|
1320
1371
|
const tsValues = selected.map((r) => Number(r.ts || 0)).filter((n) => n > 0);
|
|
1321
1372
|
const report = {
|
|
@@ -1417,9 +1468,17 @@ function renderText(report) {
|
|
|
1417
1468
|
}
|
|
1418
1469
|
}
|
|
1419
1470
|
}
|
|
1420
|
-
|
|
1471
|
+
const intentionalCompactBreaks = report.compact.cache_breaks.filter((b) => !b.actionable);
|
|
1472
|
+
const actionableCompactBreaks = report.compact.cache_breaks.filter((b) => b.actionable);
|
|
1473
|
+
if (intentionalCompactBreaks.length) {
|
|
1421
1474
|
lines.push('compact cache resets (intentional):');
|
|
1422
|
-
for (const b of
|
|
1475
|
+
for (const b of intentionalCompactBreaks.slice(0, 5)) {
|
|
1476
|
+
lines.push(`- ${shortId(b.session_id)} it=${b.iteration ?? '-'} phase=${b.phase || '-'} reason=${b.reason || '-'} prompt=${fmtTok(b.prompt_tokens)} out=${fmtTok(b.output_tokens)}`);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
if (actionableCompactBreaks.length) {
|
|
1480
|
+
lines.push('compact cache breaks (actionable):');
|
|
1481
|
+
for (const b of actionableCompactBreaks.slice(0, 5)) {
|
|
1423
1482
|
lines.push(`- ${shortId(b.session_id)} it=${b.iteration ?? '-'} phase=${b.phase || '-'} reason=${b.reason || '-'} prompt=${fmtTok(b.prompt_tokens)} out=${fmtTok(b.output_tokens)}`);
|
|
1424
1483
|
}
|
|
1425
1484
|
}
|
|
@@ -1458,9 +1517,9 @@ function renderText(report) {
|
|
|
1458
1517
|
lines.push(`ws: delta=${report.cache.ws_delta}, full=${report.cache.ws_full}, previous_response_id=${report.cache.previous_response_id}, reused=${report.cache.reused_connection}/${report.cache.transport_count}`);
|
|
1459
1518
|
if (report.cache.cache_key_hashes.length) lines.push(`cache keys: ${report.cache.cache_key_hashes.slice(0, 5).map((k) => `${k.key}×${k.count}`).join(', ')}`);
|
|
1460
1519
|
if (report.cache.cache_breaks.length) {
|
|
1461
|
-
lines.push(
|
|
1520
|
+
lines.push(`cache breaks (raw=${report.cache.cache_breaks.length}, actionable=${report.cache.actionable_cache_breaks.length}):`);
|
|
1462
1521
|
for (const b of report.cache.cache_breaks.slice(0, 10)) {
|
|
1463
|
-
lines.push(`- ${shortId(b.session_id)} it=${b.iteration ?? '-'} phase=${b.phase || '-'} reason=${b.reason || '-'} (${b.explanation || '-'}) ws=${b.ws_mode || '-'} prev=${b.request_has_previous_response_id} cache=${fmtPct((b.cache_ratio ?? 0) * 100)} prompt=${fmtTok(b.prompt_tokens)} out=${fmtTok(b.output_tokens)} body/frame=${b.body_input_items ?? '-'}/${b.frame_input_items ?? '-'}`);
|
|
1522
|
+
lines.push(`- ${shortId(b.session_id)} it=${b.iteration ?? '-'} ${b.actionable ? 'actionable' : `intentional:${b.intentional_transition}`} phase=${b.phase || '-'} reason=${b.reason || '-'} (${b.explanation || '-'}) ws=${b.ws_mode || '-'} tool_choice=${b.request_tool_choice ?? '-'} prev=${b.request_has_previous_response_id} cache=${fmtPct((b.cache_ratio ?? 0) * 100)} prompt=${fmtTok(b.prompt_tokens)} out=${fmtTok(b.output_tokens)} body/frame=${b.body_input_items ?? '-'}/${b.frame_input_items ?? '-'}`);
|
|
1464
1523
|
}
|
|
1465
1524
|
}
|
|
1466
1525
|
if (report.cache.actual_cache_misses?.length) {
|
|
@@ -29,6 +29,14 @@ test('shell trace classification uses only the leading status marker', () => {
|
|
|
29
29
|
'Error: [shell-run-failed] [signal: SIGKILL cause: output-limit]',
|
|
30
30
|
'shell',
|
|
31
31
|
), 'runtime/failure');
|
|
32
|
+
assert.equal(classifyToolFailure(
|
|
33
|
+
'Session "sess_cancelled" closed: aborted during call',
|
|
34
|
+
'shell',
|
|
35
|
+
), 'expected-cancellation');
|
|
36
|
+
assert.equal(classifyToolFailure(
|
|
37
|
+
'call aborted',
|
|
38
|
+
'read',
|
|
39
|
+
), 'timeout/abort');
|
|
32
40
|
assert.equal(classifyToolFailure(
|
|
33
41
|
'⚠️ destructive command warning\nError: [shell-run-failed] [signal: SIGKILL]',
|
|
34
42
|
'shell',
|
|
@@ -177,7 +185,7 @@ test('cancellation racing with auto-background adoption is returned as cancelled
|
|
|
177
185
|
assert.equal(result.killCause, 'cancellation');
|
|
178
186
|
});
|
|
179
187
|
|
|
180
|
-
test('tool-failures
|
|
188
|
+
test('tool-failures excludes session cancellations but retains real abort failures', () => {
|
|
181
189
|
const dir = mkdtempSync(join(tmpdir(), 'mixdog-tool-failures-test-'));
|
|
182
190
|
try {
|
|
183
191
|
const history = join(dir, 'history');
|
|
@@ -185,8 +193,17 @@ test('tool-failures separates actionable totals while retaining command-exit row
|
|
|
185
193
|
const rows = [
|
|
186
194
|
{ ts: 1, tool_name: 'shell', category: 'process/signal', error_first_line: 'SIGKILL' },
|
|
187
195
|
{ ts: 2, tool_name: 'shell', category: 'runtime/failure', error_first_line: 'capture guard' },
|
|
196
|
+
{ ts: 3, tool_name: 'shell', category: 'timeout/abort', error_first_line: 'Session "sess_cancelled" closed: aborted during call' },
|
|
197
|
+
{
|
|
198
|
+
ts: 4,
|
|
199
|
+
tool_name: 'shell',
|
|
200
|
+
category: 'timeout/abort',
|
|
201
|
+
error_first_line: '⚠️ destructive command warning',
|
|
202
|
+
error_preview: '⚠️ destructive command warning\nSession "sess_warning" closed: aborted during call',
|
|
203
|
+
},
|
|
204
|
+
{ ts: 5, tool_name: 'shell', category: 'timeout/abort', error_first_line: 'request timed out' },
|
|
188
205
|
...Array.from({ length: 45 }, (_, index) => ({
|
|
189
|
-
ts: index +
|
|
206
|
+
ts: index + 6,
|
|
190
207
|
tool_name: 'shell',
|
|
191
208
|
category: 'command-exit',
|
|
192
209
|
error_first_line: `exit ${index}`,
|
|
@@ -196,16 +213,68 @@ test('tool-failures separates actionable totals while retaining command-exit row
|
|
|
196
213
|
const script = resolve('scripts/tool-failures.mjs');
|
|
197
214
|
const text = spawnSync(process.execPath, [script, '--data-dir', dir, '--limit', '2'], { encoding: 'utf8' });
|
|
198
215
|
assert.equal(text.status, 0, text.stderr);
|
|
199
|
-
assert.match(text.stdout, /actionable failures: 2\/
|
|
216
|
+
assert.match(text.stdout, /actionable failures: 2\/3 shown/);
|
|
200
217
|
assert.match(text.stdout, /command exits: 2\/45 shown \(retained\)/);
|
|
218
|
+
assert.doesNotMatch(text.stdout, /aborted during call/);
|
|
201
219
|
assert.equal((text.stdout.match(/^- /gm) || []).length, 4);
|
|
202
220
|
const json = spawnSync(process.execPath, [script, '--data-dir', dir, '--limit', '2', '--json'], { encoding: 'utf8' });
|
|
203
221
|
assert.equal(json.status, 0, json.stderr);
|
|
204
222
|
const report = JSON.parse(json.stdout);
|
|
205
|
-
assert.deepEqual(report.actionable_failures, { shown: 2, matched:
|
|
223
|
+
assert.deepEqual(report.actionable_failures, { shown: 2, matched: 3 });
|
|
206
224
|
assert.deepEqual(report.command_exits, { shown: 2, matched: 45 });
|
|
207
225
|
assert.equal(report.rows.length, 4);
|
|
208
226
|
} finally {
|
|
209
227
|
rmSync(dir, { recursive: true, force: true });
|
|
210
228
|
}
|
|
211
229
|
});
|
|
230
|
+
|
|
231
|
+
test('session cancellations remain traceable without entering tool-failures.jsonl', () => {
|
|
232
|
+
const dir = mkdtempSync(join(tmpdir(), 'mixdog-session-cancellation-test-'));
|
|
233
|
+
try {
|
|
234
|
+
const tracePath = join(dir, 'agent-trace.jsonl');
|
|
235
|
+
const failurePath = join(dir, 'tool-failures.jsonl');
|
|
236
|
+
const child = spawnSync(process.execPath, ['--input-type=module', '-e', `
|
|
237
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
238
|
+
import { traceAgentTool } from './src/runtime/agent/orchestrator/agent-trace-format.mjs';
|
|
239
|
+
import { drainAgentTrace } from './src/runtime/agent/orchestrator/agent-trace-io.mjs';
|
|
240
|
+
traceAgentTool({
|
|
241
|
+
sessionId: 'sess_cancelled',
|
|
242
|
+
iteration: 1,
|
|
243
|
+
toolName: 'read',
|
|
244
|
+
toolKind: 'function',
|
|
245
|
+
toolMs: 1,
|
|
246
|
+
toolArgs: { path: 'ignored' },
|
|
247
|
+
agent: 'worker',
|
|
248
|
+
model: 'test',
|
|
249
|
+
cwd: process.cwd(),
|
|
250
|
+
resultKind: 'error',
|
|
251
|
+
resultText: 'Session "sess_cancelled" closed: aborted during call',
|
|
252
|
+
});
|
|
253
|
+
await drainAgentTrace();
|
|
254
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
255
|
+
const trace = JSON.parse(readFileSync(process.env.MIXDOG_AGENT_TRACE_PATH, 'utf8').trim());
|
|
256
|
+
process.stdout.write(JSON.stringify({
|
|
257
|
+
failureLogExists: existsSync(process.env.MIXDOG_TOOL_FAILURE_LOG_PATH),
|
|
258
|
+
category: trace.result_error_category,
|
|
259
|
+
}));
|
|
260
|
+
`], {
|
|
261
|
+
cwd: process.cwd(),
|
|
262
|
+
encoding: 'utf8',
|
|
263
|
+
env: {
|
|
264
|
+
...process.env,
|
|
265
|
+
MIXDOG_AGENT_TRACE_PATH: tracePath,
|
|
266
|
+
MIXDOG_TOOL_FAILURE_LOG_PATH: failurePath,
|
|
267
|
+
MIXDOG_AGENT_TRACE_DISABLE: '',
|
|
268
|
+
MIXDOG_AGENT_TRACE_LOCAL_DISABLE: '',
|
|
269
|
+
MIXDOG_RUNTIME_ROOT: join(dir, 'no-service'),
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
assert.equal(child.status, 0, child.stderr);
|
|
273
|
+
assert.deepEqual(JSON.parse(child.stdout), {
|
|
274
|
+
failureLogExists: false,
|
|
275
|
+
category: 'expected-cancellation',
|
|
276
|
+
});
|
|
277
|
+
} finally {
|
|
278
|
+
rmSync(dir, { recursive: true, force: true });
|
|
279
|
+
}
|
|
280
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { actionableFailureCount } from './smoke-loop-failure-summary.mjs';
|
|
4
|
+
|
|
5
|
+
test('returns a numeric actionable failure count', () => {
|
|
6
|
+
assert.equal(actionableFailureCount('{"actionable_failures":{"matched":0}}'), 0);
|
|
7
|
+
assert.equal(actionableFailureCount('{"actionable_failures":{"matched":1}}'), 1);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test('rejects malformed tool-failures JSON', () => {
|
|
11
|
+
assert.throws(
|
|
12
|
+
() => actionableFailureCount('{'),
|
|
13
|
+
/tool failures returned malformed JSON/,
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('rejects a missing or malformed actionable failure count', () => {
|
|
18
|
+
assert.throws(
|
|
19
|
+
() => actionableFailureCount('{}'),
|
|
20
|
+
/tool failures returned malformed result schema/,
|
|
21
|
+
);
|
|
22
|
+
assert.throws(
|
|
23
|
+
() => actionableFailureCount('{"actionable_failures":{"matched":"0"}}'),
|
|
24
|
+
/tool failures returned malformed result schema/,
|
|
25
|
+
);
|
|
26
|
+
assert.throws(
|
|
27
|
+
() => actionableFailureCount('{"actionable_failures":{"matched":-1}}'),
|
|
28
|
+
/tool failures returned malformed result schema/,
|
|
29
|
+
);
|
|
30
|
+
assert.throws(
|
|
31
|
+
() => actionableFailureCount('{"actionable_failures":{"matched":0.5}}'),
|
|
32
|
+
/tool failures returned malformed result schema/,
|
|
33
|
+
);
|
|
34
|
+
assert.throws(
|
|
35
|
+
() => actionableFailureCount('{"actionable_failures":{"matched":1e999}}'),
|
|
36
|
+
/tool failures returned malformed result schema/,
|
|
37
|
+
);
|
|
38
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function actionableFailureCount(stdout) {
|
|
2
|
+
let summary;
|
|
3
|
+
try {
|
|
4
|
+
summary = JSON.parse(stdout);
|
|
5
|
+
} catch {
|
|
6
|
+
throw new Error(`tool failures returned malformed JSON:\n${stdout}`);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const matched = summary?.actionable_failures?.matched;
|
|
10
|
+
if (!Number.isInteger(matched) || matched < 0) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
`tool failures returned malformed result schema: expected actionable_failures.matched to be a non-negative integer:\n${stdout}`,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
return matched;
|
|
16
|
+
}
|
package/scripts/smoke-loop.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { appendFileSync, mkdirSync } from 'node:fs';
|
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
4
|
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { actionableFailureCount } from './smoke-loop-failure-summary.mjs';
|
|
6
7
|
|
|
7
8
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
9
|
const DEFAULT_DURATION_MS = 5 * 60 * 60 * 1000;
|
|
@@ -162,13 +163,7 @@ while (Date.now() < deadline && iteration < maxIterations) {
|
|
|
162
163
|
const smoke = runSmokeAll(iteration);
|
|
163
164
|
smokeTimes.push(smoke.ms);
|
|
164
165
|
const failure = runNode(['scripts/tool-failures.mjs', '--since', since, '--limit', '1', '--json'], `failures iteration ${iteration}`, 60_000);
|
|
165
|
-
|
|
166
|
-
try {
|
|
167
|
-
failureSummary = JSON.parse(failure.stdout);
|
|
168
|
-
} catch {
|
|
169
|
-
throw new Error(`tool failures returned malformed JSON:\n${failure.stdout}`);
|
|
170
|
-
}
|
|
171
|
-
if (failureSummary?.actionable_failures?.matched !== 0) {
|
|
166
|
+
if (actionableFailureCount(failure.stdout) > 0) {
|
|
172
167
|
throw new Error(`tool failures appeared after loop start:\n${failure.stdout}`);
|
|
173
168
|
}
|
|
174
169
|
const currentRss = rssMb();
|
|
@@ -95,7 +95,21 @@ const rows = files.flatMap(readRows)
|
|
|
95
95
|
.filter((row) => !categoryFilter || rowCategory(row) === categoryFilter)
|
|
96
96
|
.sort((a, b) => Number(a.ts || 0) - Number(b.ts || 0));
|
|
97
97
|
const isCommandExit = (row) => rowCategory(row) === 'command-exit';
|
|
98
|
-
const
|
|
98
|
+
const rowLeadingErrorLine = (row) => [
|
|
99
|
+
row.error_first_line,
|
|
100
|
+
row.error_preview,
|
|
101
|
+
row.result,
|
|
102
|
+
row.error,
|
|
103
|
+
row.message,
|
|
104
|
+
].filter(Boolean).join('\n').split(/\r?\n/)
|
|
105
|
+
.map((line) => line.trim())
|
|
106
|
+
.find((line) => line && !line.startsWith('⚠️ '))
|
|
107
|
+
?.replace(/^Error:\s*/i, '') || '';
|
|
108
|
+
const isExpectedCancellation = (row) => {
|
|
109
|
+
if (rowCategory(row) === 'expected-cancellation') return true;
|
|
110
|
+
return /^Session\s+"[^"]+"\s+closed:\s*(?:aborted|closed)\s+during call\b/i.test(rowLeadingErrorLine(row));
|
|
111
|
+
};
|
|
112
|
+
const actionableRows = rows.filter((row) => !isCommandExit(row) && !isExpectedCancellation(row));
|
|
99
113
|
const commandExitRows = rows.filter(isCommandExit);
|
|
100
114
|
// Limit each partition independently so a burst of ordinary command exits
|
|
101
115
|
// cannot crowd runtime/actionable failures out of the displayed report.
|
|
@@ -5,6 +5,11 @@ import { createEngineItemMutators, replaceEngineItemsState } from '../src/tui/en
|
|
|
5
5
|
import { createEngineApiA } from '../src/tui/engine/session-api.mjs';
|
|
6
6
|
import { createRunTurn } from '../src/tui/engine/turn.mjs';
|
|
7
7
|
import { buildTranscriptRowIndexIncremental } from '../src/tui/app/transcript-window.mjs';
|
|
8
|
+
import {
|
|
9
|
+
isCompletedTranscriptTail,
|
|
10
|
+
isCompletedTranscriptTailAppendedThisCommit,
|
|
11
|
+
isLiveSpinnerMetaVisible,
|
|
12
|
+
} from '../src/tui/app/live-spinner-visibility.mjs';
|
|
8
13
|
|
|
9
14
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
15
|
|
|
@@ -91,6 +96,7 @@ function makeTurnHarness(ask, stateOverrides = {}, bagOverrides = {}) {
|
|
|
91
96
|
|
|
92
97
|
test('successful mid-turn compact trims history but preserves live turn references', async () => {
|
|
93
98
|
let preservedTail = false;
|
|
99
|
+
let compactKeepsSpinnerVisible = false;
|
|
94
100
|
let contextSyncs = 0;
|
|
95
101
|
const harness = makeTurnHarness(async (_text, options) => {
|
|
96
102
|
options.onTextDelta('before compact\n');
|
|
@@ -98,6 +104,8 @@ test('successful mid-turn compact trims history but preserves live turn referenc
|
|
|
98
104
|
options.onCompactEvent({ status: 'compacted', trigger: 'reactive' });
|
|
99
105
|
assert.equal(contextSyncs, 1, 'compact event must refresh context before returning');
|
|
100
106
|
preservedTail = harness.getState().streamingTail?.text === 'before compact\n';
|
|
107
|
+
compactKeepsSpinnerVisible = harness.getState().spinner?.active === true
|
|
108
|
+
&& harness.getState().items.at(-1)?.kind === 'statusdone';
|
|
101
109
|
options.onCompactEvent({ status: 'compacted', trigger: 'reactive' });
|
|
102
110
|
assert.equal(contextSyncs, 2, 'each compact event refreshes context immediately');
|
|
103
111
|
options.onTextDelta('after compact\n');
|
|
@@ -113,6 +121,7 @@ test('successful mid-turn compact trims history but preserves live turn referenc
|
|
|
113
121
|
|
|
114
122
|
assert.equal(await harness.runTurn('go', { submittedIds: ['current-user'] }), 'done');
|
|
115
123
|
assert.equal(preservedTail, true);
|
|
124
|
+
assert.equal(compactKeepsSpinnerVisible, true, 'a compact status leaves the active turn spinner in place');
|
|
116
125
|
assert.equal(harness.getState().items.some((item) => item.id === 'old'), false);
|
|
117
126
|
assert.equal(harness.getState().items.some((item) => item.id === 'current-user'), true);
|
|
118
127
|
assert.equal(
|
|
@@ -125,6 +134,35 @@ test('successful mid-turn compact trims history but preserves live turn referenc
|
|
|
125
134
|
);
|
|
126
135
|
});
|
|
127
136
|
|
|
137
|
+
test('live spinner remains visible across compact status and follows turn teardown', () => {
|
|
138
|
+
const activeTurnSpinner = { active: true };
|
|
139
|
+
const visible = (liveSpinner, liveSpinnerIsCommand, kind) => isLiveSpinnerMetaVisible({
|
|
140
|
+
inputBoxHidden: false,
|
|
141
|
+
slashPaletteOpen: false,
|
|
142
|
+
liveSpinner,
|
|
143
|
+
liveSpinnerIsCommand,
|
|
144
|
+
latestTranscriptItem: kind ? { kind } : null,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
assert.equal(visible(activeTurnSpinner, false, 'statusdone'), true);
|
|
148
|
+
assert.equal(visible(activeTurnSpinner, false, 'turndone'), false);
|
|
149
|
+
assert.equal(visible(null, false, 'turndone'), false);
|
|
150
|
+
assert.equal(visible({ active: true }, true, 'turndone'), true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('completed transcript tail masking requires a new done tail', () => {
|
|
154
|
+
assert.equal(isCompletedTranscriptTail({ id: 'turn', kind: 'turndone' }), true);
|
|
155
|
+
assert.equal(isCompletedTranscriptTail({ id: 'status', kind: 'statusdone' }), true);
|
|
156
|
+
assert.equal(isCompletedTranscriptTail({ id: 'assistant', kind: 'assistant' }), false);
|
|
157
|
+
assert.equal(isCompletedTranscriptTail({ id: 'user', kind: 'user' }), false);
|
|
158
|
+
assert.equal(isCompletedTranscriptTail(null), false);
|
|
159
|
+
assert.equal(isCompletedTranscriptTailAppendedThisCommit({ id: 'turn', kind: 'turndone' }, 'turn'), false);
|
|
160
|
+
assert.equal(isCompletedTranscriptTailAppendedThisCommit({ id: 'status', kind: 'statusdone' }, 'status'), false);
|
|
161
|
+
assert.equal(isCompletedTranscriptTailAppendedThisCommit({ id: 'next', kind: 'turndone' }, 'previous'), true);
|
|
162
|
+
assert.equal(isCompletedTranscriptTailAppendedThisCommit({ id: 'next-status', kind: 'statusdone' }, 'previous'), true);
|
|
163
|
+
assert.equal(isCompletedTranscriptTailAppendedThisCommit({ id: 'assistant', kind: 'assistant' }, 'previous'), false);
|
|
164
|
+
});
|
|
165
|
+
|
|
128
166
|
test('failed mid-turn compact leaves prior transcript items untouched', async () => {
|
|
129
167
|
const harness = makeTurnHarness(async (_text, options) => {
|
|
130
168
|
options.onCompactEvent({ status: 'failed' });
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import http from 'node:http';
|
|
5
|
+
import {
|
|
6
|
+
isLoopbackHttpUrl,
|
|
7
|
+
preDispatchDenyForSession,
|
|
8
|
+
} from '../src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs';
|
|
9
|
+
import { executeTool } from '../src/runtime/agent/orchestrator/session/loop/tool-exec.mjs';
|
|
10
|
+
import {
|
|
11
|
+
getInternalTools,
|
|
12
|
+
setInternalToolsProvider,
|
|
13
|
+
} from '../src/runtime/agent/orchestrator/internal-tools.mjs';
|
|
14
|
+
import { previewSessionTools } from '../src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs';
|
|
15
|
+
import { TOOL_DEFS as SEARCH_TOOL_DEFS } from '../src/runtime/search/tool-defs.mjs';
|
|
16
|
+
import { dispatchSearchRuntimeTool } from '../src/session-runtime/runtime-core.mjs';
|
|
17
|
+
import {
|
|
18
|
+
fetchLoopbackText,
|
|
19
|
+
fetchPublicImage,
|
|
20
|
+
} from '../src/runtime/search/lib/http-fetch.mjs';
|
|
21
|
+
|
|
22
|
+
test('pre-dispatch keeps hook-facing web_fetch name unchanged', () => {
|
|
23
|
+
const local = { name: 'web_fetch', arguments: { url: 'http://127.0.0.1:4321/status' } };
|
|
24
|
+
const image = { name: 'web_fetch', arguments: { url: 'https://cdn.example.com/a.png?x=1' } };
|
|
25
|
+
const document = { name: 'web_fetch', arguments: { url: 'https://example.com/docs' } };
|
|
26
|
+
assert.equal(preDispatchDenyForSession({}, local), null);
|
|
27
|
+
assert.equal(preDispatchDenyForSession({}, image), null);
|
|
28
|
+
assert.equal(preDispatchDenyForSession({}, document), null);
|
|
29
|
+
assert.equal(local.name, 'web_fetch');
|
|
30
|
+
assert.equal(image.name, 'web_fetch');
|
|
31
|
+
assert.equal(document.name, 'web_fetch');
|
|
32
|
+
assert.equal(isLoopbackHttpUrl('http://localhost:80/'), true);
|
|
33
|
+
assert.equal(isLoopbackHttpUrl('http://192.168.1.2/'), false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('hidden routed tools remain dispatchable but absent from every model schema', () => {
|
|
37
|
+
setInternalToolsProvider({
|
|
38
|
+
tools: SEARCH_TOOL_DEFS,
|
|
39
|
+
executor: async () => '',
|
|
40
|
+
});
|
|
41
|
+
const registered = getInternalTools().map((tool) => tool.name);
|
|
42
|
+
assert.equal(registered.includes('local_fetch'), true);
|
|
43
|
+
assert.equal(registered.includes('image_fetch'), true);
|
|
44
|
+
for (const spec of ['full', 'mcp', 'readonly', ['tools:mcp']]) {
|
|
45
|
+
const visible = previewSessionTools(spec, []).map((tool) => tool.name);
|
|
46
|
+
assert.equal(visible.includes('local_fetch'), false, `local_fetch leaked for ${JSON.stringify(spec)}`);
|
|
47
|
+
assert.equal(visible.includes('image_fetch'), false, `image_fetch leaked for ${JSON.stringify(spec)}`);
|
|
48
|
+
assert.equal(visible.includes('web_fetch'), true, `web_fetch missing for ${JSON.stringify(spec)}`);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('hook sees public name before routed dispatch and can change routing inputs', async () => {
|
|
53
|
+
const observed = [];
|
|
54
|
+
setInternalToolsProvider({
|
|
55
|
+
tools: SEARCH_TOOL_DEFS,
|
|
56
|
+
executor: async (name, args) => {
|
|
57
|
+
observed.push({ stage: 'dispatch', name, args });
|
|
58
|
+
return { content: [{ type: 'text', text: name }] };
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
const result = await executeTool(
|
|
62
|
+
'web_fetch',
|
|
63
|
+
{ url: 'https://example.com/document' },
|
|
64
|
+
process.cwd(),
|
|
65
|
+
'routing-order-test',
|
|
66
|
+
{
|
|
67
|
+
beforeToolHook: async ({ name, args }) => {
|
|
68
|
+
observed.push({ stage: 'hook', name, args });
|
|
69
|
+
return { action: 'modify', args: { url: 'http://127.0.0.1:4321/status' } };
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{ toolCallId: 'routing-order-call' },
|
|
73
|
+
);
|
|
74
|
+
assert.equal(result, 'local_fetch');
|
|
75
|
+
assert.deepEqual(observed.map(({ stage, name }) => ({ stage, name })), [
|
|
76
|
+
{ stage: 'hook', name: 'web_fetch' },
|
|
77
|
+
{ stage: 'dispatch', name: 'local_fetch' },
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('cancellation signal propagates across executeTool and runtime search dispatch', async () => {
|
|
82
|
+
const received = [];
|
|
83
|
+
setInternalToolsProvider({
|
|
84
|
+
tools: SEARCH_TOOL_DEFS,
|
|
85
|
+
executor: (name, args, callerCtx) => dispatchSearchRuntimeTool(name, args, callerCtx, {
|
|
86
|
+
getSearchModule: async () => ({
|
|
87
|
+
handleToolCall: async (_name, _args, options) => {
|
|
88
|
+
received.push({ name: _name, signal: options.signal });
|
|
89
|
+
await new Promise((resolve, reject) => {
|
|
90
|
+
if (options.signal.aborted) reject(options.signal.reason);
|
|
91
|
+
else options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true });
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
getCurrentCwd: () => process.cwd(),
|
|
96
|
+
getSession: () => null,
|
|
97
|
+
notifyFnForSession: () => null,
|
|
98
|
+
runNativeWebSearch: async () => null,
|
|
99
|
+
}),
|
|
100
|
+
});
|
|
101
|
+
for (const [index, testCase] of [
|
|
102
|
+
{ url: 'https://example.com/document', expectedName: 'web_fetch' },
|
|
103
|
+
{ url: 'http://127.0.0.1:4321/status', expectedName: 'local_fetch' },
|
|
104
|
+
].entries()) {
|
|
105
|
+
const controller = new AbortController();
|
|
106
|
+
const running = executeTool(
|
|
107
|
+
'web_fetch',
|
|
108
|
+
{ url: testCase.url },
|
|
109
|
+
process.cwd(),
|
|
110
|
+
`cancel-test-${index}`,
|
|
111
|
+
{},
|
|
112
|
+
{ toolCallId: `cancel-call-${index}`, signal: controller.signal },
|
|
113
|
+
);
|
|
114
|
+
controller.abort(new Error(`cancelled-by-test-${index}`));
|
|
115
|
+
await assert.rejects(running, new RegExp(`cancelled-by-test-${index}`));
|
|
116
|
+
assert.equal(received[index].name, testCase.expectedName);
|
|
117
|
+
assert.equal(received[index].signal, controller.signal);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('local_fetch reads loopback and rejects redirect escape', async (t) => {
|
|
122
|
+
const server = http.createServer((req, res) => {
|
|
123
|
+
if (req.url === '/escape') {
|
|
124
|
+
res.writeHead(302, { location: 'http://169.254.169.254/latest/meta-data/' });
|
|
125
|
+
res.end();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
res.writeHead(200, { 'content-type': 'text/plain' });
|
|
129
|
+
res.end('local-ok');
|
|
130
|
+
});
|
|
131
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
132
|
+
t.after(() => server.close());
|
|
133
|
+
const { port } = server.address();
|
|
134
|
+
assert.equal(await fetchLoopbackText(`http://127.0.0.1:${port}/ok`), 'local-ok');
|
|
135
|
+
await assert.rejects(fetchLoopbackText(`http://127.0.0.1:${port}/escape`), /non-loopback/);
|
|
136
|
+
await assert.rejects(fetchLoopbackText('http://10.0.0.1/'), /non-loopback/);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('public image fetch is bounded, media-shaped, and blocks private redirect targets', async () => {
|
|
140
|
+
const png = Buffer.from('89504e470d0a1a0a', 'hex');
|
|
141
|
+
const okFetch = async () => new Response(png, {
|
|
142
|
+
status: 200,
|
|
143
|
+
headers: { 'content-type': 'image/png', 'content-length': String(png.length) },
|
|
144
|
+
});
|
|
145
|
+
const image = await fetchPublicImage('https://images.example.com/a.png', { fetchImpl: okFetch });
|
|
146
|
+
assert.deepEqual(image, { mimeType: 'image/png', data: png.toString('base64'), bytes: png.length });
|
|
147
|
+
|
|
148
|
+
let calls = 0;
|
|
149
|
+
const redirectFetch = async () => {
|
|
150
|
+
calls++;
|
|
151
|
+
return new Response(null, { status: 302, headers: { location: 'http://169.254.169.254/latest/meta-data/' } });
|
|
152
|
+
};
|
|
153
|
+
await assert.rejects(
|
|
154
|
+
fetchPublicImage('https://images.example.com/a.png', { fetchImpl: redirectFetch }),
|
|
155
|
+
/private address/,
|
|
156
|
+
);
|
|
157
|
+
assert.equal(calls, 1);
|
|
158
|
+
});
|