@apitap/core 1.6.0 → 1.6.2
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/dist/capture/cdp-attach.js +36 -2
- package/dist/capture/cdp-attach.js.map +1 -1
- package/dist/cli.js +59 -22
- package/dist/cli.js.map +1 -1
- package/dist/replay/engine.d.ts +7 -1
- package/dist/replay/engine.js +30 -6
- package/dist/replay/engine.js.map +1 -1
- package/dist/skill/apis-guru.js +23 -15
- package/dist/skill/apis-guru.js.map +1 -1
- package/dist/skill/generator.d.ts +18 -0
- package/dist/skill/generator.js +98 -24
- package/dist/skill/generator.js.map +1 -1
- package/dist/skill/merge.js +71 -0
- package/dist/skill/merge.js.map +1 -1
- package/dist/skill/openapi-converter.js +7 -0
- package/dist/skill/openapi-converter.js.map +1 -1
- package/dist/skill/signing.d.ts +5 -0
- package/dist/skill/signing.js +9 -0
- package/dist/skill/signing.js.map +1 -1
- package/dist/skill/store.js +9 -4
- package/dist/skill/store.js.map +1 -1
- package/dist/types.d.ts +3 -3
- package/package.json +3 -1
- package/src/capture/cdp-attach.ts +34 -2
- package/src/cli.ts +63 -22
- package/src/replay/engine.ts +36 -6
- package/src/skill/apis-guru.ts +25 -19
- package/src/skill/generator.ts +132 -25
- package/src/skill/merge.ts +72 -0
- package/src/skill/openapi-converter.ts +8 -0
- package/src/skill/signing.ts +14 -0
- package/src/skill/store.ts +8 -4
- package/src/types.ts +3 -3
package/src/replay/engine.ts
CHANGED
|
@@ -45,6 +45,15 @@ const BLOCKED_REPLAY_HEADERS = new Set([
|
|
|
45
45
|
'sec-fetch-user',
|
|
46
46
|
]);
|
|
47
47
|
|
|
48
|
+
export function safeParseJson(text: string): unknown {
|
|
49
|
+
if (text.length === 0) return text;
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(text);
|
|
52
|
+
} catch {
|
|
53
|
+
return text;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
48
57
|
export interface ReplayOptions {
|
|
49
58
|
/** User-provided parameters for path, query, and body substitution */
|
|
50
59
|
params?: Record<string, string>;
|
|
@@ -70,6 +79,8 @@ export interface ReplayResult {
|
|
|
70
79
|
truncated?: boolean;
|
|
71
80
|
/** Contract warnings from schema drift detection */
|
|
72
81
|
contractWarnings?: ContractWarning[];
|
|
82
|
+
/** Upgrade hint: set when a low-confidence endpoint gets a 2xx response */
|
|
83
|
+
upgrade?: { confidence: 1.0; endpointProvenance: 'captured' };
|
|
73
84
|
}
|
|
74
85
|
|
|
75
86
|
/**
|
|
@@ -207,9 +218,15 @@ function wrapAuthError(
|
|
|
207
218
|
/**
|
|
208
219
|
* Returns a user-facing hint about confidence level, or null if confidence is high enough.
|
|
209
220
|
*/
|
|
210
|
-
export function getConfidenceHint(
|
|
221
|
+
export function getConfidenceHint(
|
|
222
|
+
confidence: number | undefined,
|
|
223
|
+
provenance?: string,
|
|
224
|
+
): string | null {
|
|
211
225
|
const c = confidence ?? 1.0;
|
|
212
226
|
if (c >= 0.85) return null;
|
|
227
|
+
if (provenance === 'skeleton') {
|
|
228
|
+
return '(observed in traffic, no response shape — first successful replay will upgrade)';
|
|
229
|
+
}
|
|
213
230
|
if (c >= 0.7) return '(imported from spec — params may need adjustment)';
|
|
214
231
|
return '(imported from spec — provide params explicitly, no captured examples available)';
|
|
215
232
|
}
|
|
@@ -548,8 +565,8 @@ export async function replayEndpoint(
|
|
|
548
565
|
let retryData: unknown;
|
|
549
566
|
const retryCt = retryResponse.headers.get('content-type') ?? '';
|
|
550
567
|
const retryText = await retryResponse.text();
|
|
551
|
-
if (retryCt.includes('json')
|
|
552
|
-
retryData =
|
|
568
|
+
if (retryCt.includes('json')) {
|
|
569
|
+
retryData = safeParseJson(retryText);
|
|
553
570
|
} else {
|
|
554
571
|
retryData = retryText;
|
|
555
572
|
}
|
|
@@ -558,6 +575,11 @@ export async function replayEndpoint(
|
|
|
558
575
|
? wrapAuthError(retryResponse.status, retryData, skill.domain)
|
|
559
576
|
: retryData;
|
|
560
577
|
|
|
578
|
+
const retryUpgrade = (endpoint.confidence !== undefined && endpoint.confidence < 1.0
|
|
579
|
+
&& retryResponse.status >= 200 && retryResponse.status < 300)
|
|
580
|
+
? { confidence: 1.0 as const, endpointProvenance: 'captured' as const }
|
|
581
|
+
: undefined;
|
|
582
|
+
|
|
561
583
|
if (options.maxBytes) {
|
|
562
584
|
const truncated = truncateResponse(retryFinalData, { maxBytes: options.maxBytes });
|
|
563
585
|
return {
|
|
@@ -566,6 +588,7 @@ export async function replayEndpoint(
|
|
|
566
588
|
data: truncated.data,
|
|
567
589
|
refreshed,
|
|
568
590
|
...(truncated.truncated ? { truncated: true } : {}),
|
|
591
|
+
...(retryUpgrade ? { upgrade: retryUpgrade } : {}),
|
|
569
592
|
};
|
|
570
593
|
}
|
|
571
594
|
|
|
@@ -574,6 +597,7 @@ export async function replayEndpoint(
|
|
|
574
597
|
headers: retryHeaders,
|
|
575
598
|
data: retryFinalData,
|
|
576
599
|
refreshed,
|
|
600
|
+
...(retryUpgrade ? { upgrade: retryUpgrade } : {}),
|
|
577
601
|
};
|
|
578
602
|
}
|
|
579
603
|
}
|
|
@@ -586,8 +610,8 @@ export async function replayEndpoint(
|
|
|
586
610
|
let data: unknown;
|
|
587
611
|
const ct = response.headers.get('content-type') ?? '';
|
|
588
612
|
const text = await response.text();
|
|
589
|
-
if (ct.includes('json')
|
|
590
|
-
data =
|
|
613
|
+
if (ct.includes('json')) {
|
|
614
|
+
data = safeParseJson(text);
|
|
591
615
|
} else {
|
|
592
616
|
data = text;
|
|
593
617
|
}
|
|
@@ -606,6 +630,11 @@ export async function replayEndpoint(
|
|
|
606
630
|
}
|
|
607
631
|
}
|
|
608
632
|
|
|
633
|
+
const upgrade = (endpoint.confidence !== undefined && endpoint.confidence < 1.0
|
|
634
|
+
&& response.status >= 200 && response.status < 300)
|
|
635
|
+
? { confidence: 1.0 as const, endpointProvenance: 'captured' as const }
|
|
636
|
+
: undefined;
|
|
637
|
+
|
|
609
638
|
// Apply truncation if maxBytes is set
|
|
610
639
|
if (options.maxBytes) {
|
|
611
640
|
const truncated = truncateResponse(finalData, { maxBytes: options.maxBytes });
|
|
@@ -616,10 +645,11 @@ export async function replayEndpoint(
|
|
|
616
645
|
...(refreshed ? { refreshed } : {}),
|
|
617
646
|
...(truncated.truncated ? { truncated: true } : {}),
|
|
618
647
|
...(contractWarnings ? { contractWarnings } : {}),
|
|
648
|
+
...(upgrade ? { upgrade } : {}),
|
|
619
649
|
};
|
|
620
650
|
}
|
|
621
651
|
|
|
622
|
-
return { status: response.status, headers: responseHeaders, data: finalData, ...(refreshed ? { refreshed } : {}), ...(contractWarnings ? { contractWarnings } : {}) };
|
|
652
|
+
return { status: response.status, headers: responseHeaders, data: finalData, ...(refreshed ? { refreshed } : {}), ...(contractWarnings ? { contractWarnings } : {}), ...(upgrade ? { upgrade } : {}) };
|
|
623
653
|
}
|
|
624
654
|
|
|
625
655
|
// --- Batch replay ---
|
package/src/skill/apis-guru.ts
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
// src/skill/apis-guru.ts
|
|
2
2
|
import { resolveAndValidateUrl } from '../skill/ssrf.js';
|
|
3
3
|
|
|
4
|
+
const MAX_SPEC_SIZE = 10 * 1024 * 1024; // 10 MB per spec
|
|
5
|
+
const MAX_LIST_SIZE = 100 * 1024 * 1024; // 100 MB for APIs.guru list
|
|
6
|
+
|
|
7
|
+
async function fetchWithSizeLimit(url: string, maxBytes: number, options?: RequestInit): Promise<string> {
|
|
8
|
+
const response = await fetch(url, {
|
|
9
|
+
signal: AbortSignal.timeout(30_000),
|
|
10
|
+
...options,
|
|
11
|
+
headers: { 'User-Agent': 'apitap-import/1.0', ...(options?.headers as Record<string, string> || {}) },
|
|
12
|
+
});
|
|
13
|
+
if (!response.ok) {
|
|
14
|
+
throw new Error(`HTTP ${response.status} ${response.statusText} for ${url}`);
|
|
15
|
+
}
|
|
16
|
+
const contentLength = response.headers.get('content-length');
|
|
17
|
+
if (contentLength && parseInt(contentLength, 10) > maxBytes) {
|
|
18
|
+
throw new Error(`Response too large: ${contentLength} bytes (limit: ${maxBytes})`);
|
|
19
|
+
}
|
|
20
|
+
const text = await response.text();
|
|
21
|
+
if (text.length > maxBytes) {
|
|
22
|
+
throw new Error(`Response body too large: ${text.length} bytes (limit: ${maxBytes})`);
|
|
23
|
+
}
|
|
24
|
+
return text;
|
|
25
|
+
}
|
|
26
|
+
|
|
4
27
|
export interface ApisGuruEntry {
|
|
5
28
|
apiId: string; // e.g., "twilio.com:api"
|
|
6
29
|
providerName: string; // e.g., "twilio.com"
|
|
@@ -121,15 +144,7 @@ export async function fetchApisGuruList(): Promise<ApisGuruEntry[]> {
|
|
|
121
144
|
if (!ssrf.safe) {
|
|
122
145
|
throw new Error(`SSRF check failed for APIs.guru list URL: ${ssrf.reason}`);
|
|
123
146
|
}
|
|
124
|
-
const
|
|
125
|
-
signal: AbortSignal.timeout(30_000),
|
|
126
|
-
});
|
|
127
|
-
if (!response.ok) {
|
|
128
|
-
throw new Error(
|
|
129
|
-
`Failed to fetch APIs.guru list: ${response.status} ${response.statusText}`,
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
const text = await response.text();
|
|
147
|
+
const text = await fetchWithSizeLimit(APIS_GURU_LIST_URL, MAX_LIST_SIZE);
|
|
133
148
|
try {
|
|
134
149
|
return parseApisGuruList(JSON.parse(text) as Record<string, any>);
|
|
135
150
|
} catch {
|
|
@@ -145,16 +160,7 @@ export async function fetchSpec(specUrl: string): Promise<Record<string, any>> {
|
|
|
145
160
|
if (!ssrf.safe) {
|
|
146
161
|
throw new Error(`SSRF check failed for spec URL ${specUrl}: ${ssrf.reason}`);
|
|
147
162
|
}
|
|
148
|
-
const
|
|
149
|
-
signal: AbortSignal.timeout(30_000),
|
|
150
|
-
redirect: 'error',
|
|
151
|
-
});
|
|
152
|
-
if (!response.ok) {
|
|
153
|
-
throw new Error(
|
|
154
|
-
`Failed to fetch spec at ${specUrl}: ${response.status} ${response.statusText}`,
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
const text = await response.text();
|
|
163
|
+
const text = await fetchWithSizeLimit(specUrl, MAX_SPEC_SIZE, { redirect: 'error' });
|
|
158
164
|
try {
|
|
159
165
|
return JSON.parse(text) as Record<string, any>;
|
|
160
166
|
} catch {
|
package/src/skill/generator.ts
CHANGED
|
@@ -292,6 +292,7 @@ export class SkillGenerator {
|
|
|
292
292
|
private oauthClientSecret: string | undefined;
|
|
293
293
|
private oauthRefreshToken: string | undefined;
|
|
294
294
|
private totalNetworkBytes = 0; // v1.0: accumulate all response sizes
|
|
295
|
+
private _lastDedupKey: string | null = null; // side-channel for dedup key when _prepareEndpoint returns null
|
|
295
296
|
|
|
296
297
|
/** Number of unique endpoints captured so far */
|
|
297
298
|
get endpointCount(): number {
|
|
@@ -305,13 +306,37 @@ export class SkillGenerator {
|
|
|
305
306
|
};
|
|
306
307
|
}
|
|
307
308
|
|
|
308
|
-
/**
|
|
309
|
-
|
|
309
|
+
/**
|
|
310
|
+
* Extract shared request-side logic: URL parsing, GraphQL detection, path
|
|
311
|
+
* parameterization, dedup, OAuth, auth extraction, header/query scrubbing,
|
|
312
|
+
* and endpoint ID generation.
|
|
313
|
+
*
|
|
314
|
+
* Returns null when the endpoint is a duplicate (existing non-skeleton).
|
|
315
|
+
* When returning null, stores the dedup key in `_lastDedupKey` so callers
|
|
316
|
+
* can still perform body storage for cross-request diffing.
|
|
317
|
+
*/
|
|
318
|
+
private _prepareEndpoint(request: {
|
|
319
|
+
url: string;
|
|
320
|
+
method: string;
|
|
321
|
+
headers: Record<string, string>;
|
|
322
|
+
postData?: string;
|
|
323
|
+
}): {
|
|
324
|
+
key: string;
|
|
325
|
+
method: string;
|
|
326
|
+
paramPath: string;
|
|
327
|
+
queryParams: Record<string, { type: string; example: string }>;
|
|
328
|
+
safeHeaders: Record<string, string>;
|
|
329
|
+
exampleUrl: string;
|
|
330
|
+
endpointId: string;
|
|
331
|
+
graphqlInfo: { operationName: string; query: string; variables: Record<string, unknown> | null } | null;
|
|
332
|
+
entropyDetected: Set<string>;
|
|
333
|
+
isSkeletonReplacement: boolean;
|
|
334
|
+
} | null {
|
|
310
335
|
this.captureCount++;
|
|
311
336
|
|
|
312
|
-
const url = new URL(
|
|
313
|
-
const method =
|
|
314
|
-
const contentType =
|
|
337
|
+
const url = new URL(request.url);
|
|
338
|
+
const method = request.method;
|
|
339
|
+
const contentType = request.headers['content-type'] ?? '';
|
|
315
340
|
|
|
316
341
|
// Track baseUrl from the first captured exchange
|
|
317
342
|
if (!this.baseUrl) {
|
|
@@ -319,11 +344,11 @@ export class SkillGenerator {
|
|
|
319
344
|
}
|
|
320
345
|
|
|
321
346
|
// Check for GraphQL
|
|
322
|
-
const isGraphQL = isGraphQLEndpoint(url.pathname, contentType,
|
|
347
|
+
const isGraphQL = isGraphQLEndpoint(url.pathname, contentType, request.postData ?? null);
|
|
323
348
|
let graphqlInfo: { operationName: string; query: string; variables: Record<string, unknown> | null } | null = null;
|
|
324
349
|
|
|
325
|
-
if (isGraphQL &&
|
|
326
|
-
const parsed = parseGraphQLBody(
|
|
350
|
+
if (isGraphQL && request.postData) {
|
|
351
|
+
const parsed = parseGraphQLBody(request.postData);
|
|
327
352
|
if (parsed) {
|
|
328
353
|
const opName = extractOperationName(parsed.query, parsed.operationName);
|
|
329
354
|
graphqlInfo = {
|
|
@@ -344,20 +369,22 @@ export class SkillGenerator {
|
|
|
344
369
|
? `${method} graphql:${graphqlInfo.operationName}`
|
|
345
370
|
: `${method} ${dedupPath}`;
|
|
346
371
|
|
|
347
|
-
// Track response bytes for all exchanges (for browser cost measurement)
|
|
348
|
-
this.totalNetworkBytes += exchange.response.body.length;
|
|
349
|
-
|
|
350
372
|
if (this.endpoints.has(key)) {
|
|
351
|
-
|
|
352
|
-
if (
|
|
353
|
-
|
|
354
|
-
|
|
373
|
+
const existing = this.endpoints.get(key)!;
|
|
374
|
+
if (existing.endpointProvenance === 'skeleton') {
|
|
375
|
+
// Skeleton endpoint — fall through to replace it with captured data
|
|
376
|
+
} else {
|
|
377
|
+
// Non-skeleton duplicate — store key for body diffing, return null
|
|
378
|
+
this._lastDedupKey = key;
|
|
379
|
+
return null;
|
|
355
380
|
}
|
|
356
|
-
return null;
|
|
357
381
|
}
|
|
358
382
|
|
|
383
|
+
const isSkeletonReplacement = this.endpoints.has(key)
|
|
384
|
+
&& this.endpoints.get(key)!.endpointProvenance === 'skeleton';
|
|
385
|
+
|
|
359
386
|
// Detect OAuth token requests from captured traffic
|
|
360
|
-
const oauthInfo = isOAuthTokenRequest(
|
|
387
|
+
const oauthInfo = isOAuthTokenRequest(request);
|
|
361
388
|
if (oauthInfo && !this.oauthConfig) {
|
|
362
389
|
this.oauthConfig = {
|
|
363
390
|
tokenEndpoint: oauthInfo.tokenEndpoint,
|
|
@@ -370,11 +397,11 @@ export class SkillGenerator {
|
|
|
370
397
|
}
|
|
371
398
|
|
|
372
399
|
// Extract auth before filtering headers (includes entropy-based detection)
|
|
373
|
-
const [auth, entropyDetected] = extractAuth(
|
|
400
|
+
const [auth, entropyDetected] = extractAuth(request.headers);
|
|
374
401
|
this.extractedAuthList.push(...auth);
|
|
375
402
|
|
|
376
403
|
// Filter headers, then strip auth values (including entropy-detected tokens)
|
|
377
|
-
const filtered = filterHeaders(
|
|
404
|
+
const filtered = filterHeaders(request.headers);
|
|
378
405
|
const safeHeaders = stripAuth(filtered, entropyDetected);
|
|
379
406
|
|
|
380
407
|
// Build query params, optionally scrub PII
|
|
@@ -384,11 +411,50 @@ export class SkillGenerator {
|
|
|
384
411
|
}
|
|
385
412
|
|
|
386
413
|
// Build example URL, optionally scrub PII and sensitive query params
|
|
387
|
-
let exampleUrl =
|
|
414
|
+
let exampleUrl = request.url;
|
|
388
415
|
if (this.options.scrub) {
|
|
389
416
|
exampleUrl = scrubUrlQueryParams(scrubPII(exampleUrl));
|
|
390
417
|
}
|
|
391
418
|
|
|
419
|
+
// Generate endpoint ID - use GraphQL operation name if applicable
|
|
420
|
+
const endpointId = graphqlInfo
|
|
421
|
+
? `${method.toLowerCase()}-graphql-${graphqlInfo.operationName}`
|
|
422
|
+
: generateEndpointId(method, paramPath);
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
key,
|
|
426
|
+
method,
|
|
427
|
+
paramPath,
|
|
428
|
+
queryParams,
|
|
429
|
+
safeHeaders,
|
|
430
|
+
exampleUrl,
|
|
431
|
+
endpointId,
|
|
432
|
+
graphqlInfo,
|
|
433
|
+
entropyDetected,
|
|
434
|
+
isSkeletonReplacement,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Add a captured exchange. Returns the new endpoint if first seen, null if duplicate. */
|
|
439
|
+
addExchange(exchange: CapturedExchange): SkillEndpoint | null {
|
|
440
|
+
// Track response bytes for all exchanges (for browser cost measurement)
|
|
441
|
+
this.totalNetworkBytes += exchange.response.body.length;
|
|
442
|
+
|
|
443
|
+
const prep = this._prepareEndpoint(exchange.request);
|
|
444
|
+
|
|
445
|
+
if (!prep) {
|
|
446
|
+
// Dedup: store body for cross-request diffing (Strategy 1) — scrubbed (H3 fix)
|
|
447
|
+
const dedupKey = this._lastDedupKey;
|
|
448
|
+
if (dedupKey && exchange.request.postData) {
|
|
449
|
+
const bodies = this.exchangeBodies.get(dedupKey);
|
|
450
|
+
if (bodies) bodies.push(this.scrubBodyString(exchange.request.postData));
|
|
451
|
+
}
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const { key, method, paramPath, queryParams, safeHeaders, exampleUrl,
|
|
456
|
+
endpointId, graphqlInfo, entropyDetected } = prep;
|
|
457
|
+
|
|
392
458
|
// Response preview: null by default, populated with --preview
|
|
393
459
|
let responsePreview: unknown = null;
|
|
394
460
|
if (this.options.enablePreview) {
|
|
@@ -445,11 +511,6 @@ export class SkillGenerator {
|
|
|
445
511
|
}
|
|
446
512
|
}
|
|
447
513
|
|
|
448
|
-
// Generate endpoint ID - use GraphQL operation name if applicable
|
|
449
|
-
const endpointId = graphqlInfo
|
|
450
|
-
? `${method.toLowerCase()}-graphql-${graphqlInfo.operationName}`
|
|
451
|
-
: generateEndpointId(method, paramPath);
|
|
452
|
-
|
|
453
514
|
const endpoint: SkillEndpoint = {
|
|
454
515
|
id: endpointId,
|
|
455
516
|
method: exchange.request.method,
|
|
@@ -503,6 +564,52 @@ export class SkillGenerator {
|
|
|
503
564
|
return endpoint;
|
|
504
565
|
}
|
|
505
566
|
|
|
567
|
+
/** Add a skeleton endpoint (request data only, no response body). Confidence 0.8. */
|
|
568
|
+
addSkeleton(request: {
|
|
569
|
+
url: string;
|
|
570
|
+
method: string;
|
|
571
|
+
headers: Record<string, string>;
|
|
572
|
+
postData?: string;
|
|
573
|
+
}): SkillEndpoint | null {
|
|
574
|
+
const prep = this._prepareEndpoint(request);
|
|
575
|
+
if (!prep) return null;
|
|
576
|
+
|
|
577
|
+
const { key, paramPath, queryParams, safeHeaders, exampleUrl,
|
|
578
|
+
endpointId, entropyDetected, isSkeletonReplacement } = prep;
|
|
579
|
+
|
|
580
|
+
// If a skeleton already occupies this key, don't overwrite with another skeleton
|
|
581
|
+
if (isSkeletonReplacement) return null;
|
|
582
|
+
|
|
583
|
+
const endpoint: SkillEndpoint = {
|
|
584
|
+
id: endpointId,
|
|
585
|
+
method: request.method,
|
|
586
|
+
path: paramPath,
|
|
587
|
+
queryParams,
|
|
588
|
+
headers: safeHeaders,
|
|
589
|
+
responseShape: { type: 'unknown' },
|
|
590
|
+
examples: {
|
|
591
|
+
request: {
|
|
592
|
+
url: exampleUrl,
|
|
593
|
+
headers: stripAuth(filterHeaders(request.headers)),
|
|
594
|
+
},
|
|
595
|
+
responsePreview: null,
|
|
596
|
+
},
|
|
597
|
+
confidence: 0.8,
|
|
598
|
+
endpointProvenance: 'skeleton',
|
|
599
|
+
responseBytes: 0,
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
// Also strip entropy-detected tokens from example headers
|
|
603
|
+
if (entropyDetected.size > 0) {
|
|
604
|
+
endpoint.examples.request.headers = stripAuth(
|
|
605
|
+
filterHeaders(request.headers), entropyDetected
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
this.endpoints.set(key, endpoint);
|
|
610
|
+
return endpoint;
|
|
611
|
+
}
|
|
612
|
+
|
|
506
613
|
/** Record a filtered-out request (for metadata tracking). */
|
|
507
614
|
recordFiltered(): void {
|
|
508
615
|
this.filteredCount++;
|
package/src/skill/merge.ts
CHANGED
|
@@ -82,6 +82,33 @@ function wouldEnrich(existing: SkillEndpoint, imported: SkillEndpoint): boolean
|
|
|
82
82
|
return false;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Determine whether an imported endpoint would enrich an existing skeleton
|
|
87
|
+
* endpoint. Skeletons are enrichable by imports: imports can provide response
|
|
88
|
+
* shape (replacing the placeholder `{ type: 'unknown' }`), description,
|
|
89
|
+
* specSource, and query param metadata.
|
|
90
|
+
*/
|
|
91
|
+
function wouldEnrichSkeleton(existing: SkillEndpoint, imported: SkillEndpoint): boolean {
|
|
92
|
+
// Import can replace an unknown response shape
|
|
93
|
+
const existingShape = existing.responseShape;
|
|
94
|
+
const isUnknownShape = existingShape.type === 'unknown' && !existingShape.fields;
|
|
95
|
+
if (isUnknownShape && imported.responseShape.type !== 'unknown') return true;
|
|
96
|
+
|
|
97
|
+
if (!existing.description && imported.description) return true;
|
|
98
|
+
if (!existing.specSource && imported.specSource) return true;
|
|
99
|
+
|
|
100
|
+
// Check if any new query params would be added or enriched
|
|
101
|
+
for (const [name, specParam] of Object.entries(imported.queryParams)) {
|
|
102
|
+
if (!(name in existing.queryParams)) return true;
|
|
103
|
+
const ep = existing.queryParams[name];
|
|
104
|
+
if (!ep.enum && specParam.enum) return true;
|
|
105
|
+
if (ep.required === undefined && specParam.required !== undefined) return true;
|
|
106
|
+
if (ep.type === 'string' && specParam.type !== 'string') return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
85
112
|
/**
|
|
86
113
|
* Pure function — no I/O.
|
|
87
114
|
*
|
|
@@ -182,6 +209,51 @@ export function mergeSkillFile(
|
|
|
182
209
|
continue;
|
|
183
210
|
}
|
|
184
211
|
|
|
212
|
+
// --- Skeleton branch: import can enrich skeleton endpoints ---
|
|
213
|
+
if (existingEp.endpointProvenance === 'skeleton') {
|
|
214
|
+
if (!wouldEnrichSkeleton(existingEp, importedEp)) {
|
|
215
|
+
resultEndpoints.push({
|
|
216
|
+
...existingEp,
|
|
217
|
+
normalizedPath: normalizePath(existingEp.path),
|
|
218
|
+
});
|
|
219
|
+
const existingHasSpecData = !!(existingEp.specSource || existingEp.description);
|
|
220
|
+
const importHasSpecData = !!(importedEp.specSource || importedEp.description);
|
|
221
|
+
if (existingHasSpecData || importHasSpecData) {
|
|
222
|
+
skipped++;
|
|
223
|
+
} else {
|
|
224
|
+
preserved++;
|
|
225
|
+
}
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const mergedQueryParams = mergeQueryParams(existingEp.queryParams, importedEp.queryParams);
|
|
230
|
+
|
|
231
|
+
// For skeletons, import can replace responseShape if existing is unknown
|
|
232
|
+
const existingShape = existingEp.responseShape;
|
|
233
|
+
const isUnknownShape = existingShape.type === 'unknown' && !existingShape.fields;
|
|
234
|
+
const responseShape = isUnknownShape && importedEp.responseShape.type !== 'unknown'
|
|
235
|
+
? importedEp.responseShape
|
|
236
|
+
: existingShape;
|
|
237
|
+
|
|
238
|
+
const enrichedSkeleton: SkillEndpoint = {
|
|
239
|
+
...existingEp,
|
|
240
|
+
normalizedPath: normalizePath(existingEp.path),
|
|
241
|
+
responseShape,
|
|
242
|
+
// Augment with spec fields (only if not already present)
|
|
243
|
+
...(importedEp.description && !existingEp.description ? { description: importedEp.description } : {}),
|
|
244
|
+
...(importedEp.specSource && !existingEp.specSource ? { specSource: importedEp.specSource } : {}),
|
|
245
|
+
// Confidence = max(skeleton, import)
|
|
246
|
+
confidence: Math.max(existingEp.confidence ?? 0, importedEp.confidence ?? 0) || existingEp.confidence,
|
|
247
|
+
// Provenance stays 'skeleton'
|
|
248
|
+
endpointProvenance: 'skeleton',
|
|
249
|
+
queryParams: mergedQueryParams,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
resultEndpoints.push(enrichedSkeleton);
|
|
253
|
+
enriched++;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
185
257
|
// Imported endpoint matches an existing one — check if it would add anything new
|
|
186
258
|
if (!wouldEnrich(existingEp, importedEp)) {
|
|
187
259
|
resultEndpoints.push({
|
|
@@ -31,6 +31,14 @@ export function resolveRef(
|
|
|
31
31
|
return merged;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// Handle oneOf/anyOf: use first option as representative
|
|
35
|
+
if (obj.oneOf && Array.isArray(obj.oneOf) && obj.oneOf.length > 0) {
|
|
36
|
+
return resolveRef(obj.oneOf[0], spec, new Set(visited), depth + 1);
|
|
37
|
+
}
|
|
38
|
+
if (obj.anyOf && Array.isArray(obj.anyOf) && obj.anyOf.length > 0) {
|
|
39
|
+
return resolveRef(obj.anyOf[0], spec, new Set(visited), depth + 1);
|
|
40
|
+
}
|
|
41
|
+
|
|
34
42
|
if (!obj.$ref) return obj;
|
|
35
43
|
|
|
36
44
|
const ref = obj.$ref as string;
|
package/src/skill/signing.ts
CHANGED
|
@@ -44,6 +44,20 @@ export function signSkillFile(skill: SkillFile, key: Buffer): SkillFile {
|
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Sign a skill file with a specific provenance value.
|
|
49
|
+
* Use 'self' for captured files, 'imported-signed' for import-only files.
|
|
50
|
+
*/
|
|
51
|
+
export function signSkillFileAs(
|
|
52
|
+
skill: SkillFile,
|
|
53
|
+
key: Buffer,
|
|
54
|
+
provenance: 'self' | 'imported-signed',
|
|
55
|
+
): SkillFile {
|
|
56
|
+
const payload = canonicalize(skill);
|
|
57
|
+
const signature = hmacSign(payload, key);
|
|
58
|
+
return { ...skill, provenance, signature };
|
|
59
|
+
}
|
|
60
|
+
|
|
47
61
|
/**
|
|
48
62
|
* Verify a skill file's signature.
|
|
49
63
|
* Returns true if the signature is valid for the given key.
|
package/src/skill/store.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/skill/store.ts
|
|
2
|
-
import { readFile, writeFile, mkdir, readdir, access } from 'node:fs/promises';
|
|
2
|
+
import { readFile, writeFile, mkdir, readdir, access, rename } from 'node:fs/promises';
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import type { SkillFile, SkillSummary } from '../types.js';
|
|
@@ -40,7 +40,10 @@ export async function writeSkillFile(
|
|
|
40
40
|
await mkdir(skillsDir, { recursive: true, mode: 0o700 });
|
|
41
41
|
await ensureGitignore(skillsDir);
|
|
42
42
|
const filePath = skillPath(skill.domain, skillsDir);
|
|
43
|
-
|
|
43
|
+
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
44
|
+
const content = JSON.stringify(skill, null, 2) + '\n';
|
|
45
|
+
await writeFile(tmpPath, content, { mode: 0o600 });
|
|
46
|
+
await rename(tmpPath, filePath);
|
|
44
47
|
return filePath;
|
|
45
48
|
}
|
|
46
49
|
|
|
@@ -114,8 +117,9 @@ export async function listSkillFiles(
|
|
|
114
117
|
let files: string[];
|
|
115
118
|
try {
|
|
116
119
|
files = await readdir(skillsDir);
|
|
117
|
-
} catch {
|
|
118
|
-
return [];
|
|
120
|
+
} catch (err: unknown) {
|
|
121
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
|
122
|
+
throw err;
|
|
119
123
|
}
|
|
120
124
|
|
|
121
125
|
const summaries: SkillSummary[] = [];
|
package/src/types.ts
CHANGED
|
@@ -133,7 +133,7 @@ export interface SkillEndpoint {
|
|
|
133
133
|
responseSchema?: SchemaNode; // v1.1: schema snapshot for contract validation
|
|
134
134
|
normalizedPath?: string;
|
|
135
135
|
confidence?: number;
|
|
136
|
-
endpointProvenance?: 'captured' | 'openapi-import';
|
|
136
|
+
endpointProvenance?: 'captured' | 'openapi-import' | 'skeleton';
|
|
137
137
|
specSource?: string;
|
|
138
138
|
description?: string;
|
|
139
139
|
}
|
|
@@ -162,7 +162,7 @@ export interface SkillFile {
|
|
|
162
162
|
endpointsEnriched: number;
|
|
163
163
|
}>;
|
|
164
164
|
};
|
|
165
|
-
provenance: 'self' | 'imported' | 'unsigned';
|
|
165
|
+
provenance: 'self' | 'imported' | 'imported-signed' | 'unsigned';
|
|
166
166
|
signature?: string;
|
|
167
167
|
auth?: SkillAuth; // v0.8: top-level auth config
|
|
168
168
|
}
|
|
@@ -215,7 +215,7 @@ export interface SkillSummary {
|
|
|
215
215
|
skillFile: string;
|
|
216
216
|
endpointCount: number;
|
|
217
217
|
capturedAt: string;
|
|
218
|
-
provenance: 'self' | 'imported' | 'unsigned';
|
|
218
|
+
provenance: 'self' | 'imported' | 'imported-signed' | 'unsigned';
|
|
219
219
|
}
|
|
220
220
|
|
|
221
221
|
// --- Discovery types (Milestone 2: Smart Discovery) ---
|