@apitap/core 1.5.3 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -8
- package/dist/auth/handoff.js +1 -1
- package/dist/auth/handoff.js.map +1 -1
- package/dist/capture/cdp-attach.d.ts +60 -0
- package/dist/capture/cdp-attach.js +422 -0
- package/dist/capture/cdp-attach.js.map +1 -0
- package/dist/capture/filter.js +6 -0
- package/dist/capture/filter.js.map +1 -1
- package/dist/capture/parameterize.d.ts +7 -6
- package/dist/capture/parameterize.js +204 -12
- package/dist/capture/parameterize.js.map +1 -1
- package/dist/capture/session.js +20 -10
- package/dist/capture/session.js.map +1 -1
- package/dist/cli.js +387 -20
- package/dist/cli.js.map +1 -1
- package/dist/discovery/openapi.js +23 -50
- package/dist/discovery/openapi.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +12 -0
- package/dist/mcp.js.map +1 -1
- package/dist/native-host.js +5 -0
- package/dist/native-host.js.map +1 -1
- package/dist/plugin.js +10 -3
- package/dist/plugin.js.map +1 -1
- package/dist/replay/engine.d.ts +13 -0
- package/dist/replay/engine.js +20 -0
- package/dist/replay/engine.js.map +1 -1
- package/dist/skill/apis-guru.d.ts +35 -0
- package/dist/skill/apis-guru.js +128 -0
- package/dist/skill/apis-guru.js.map +1 -0
- package/dist/skill/generator.d.ts +7 -1
- package/dist/skill/generator.js +35 -3
- package/dist/skill/generator.js.map +1 -1
- package/dist/skill/merge.d.ts +29 -0
- package/dist/skill/merge.js +252 -0
- package/dist/skill/merge.js.map +1 -0
- package/dist/skill/openapi-converter.d.ts +31 -0
- package/dist/skill/openapi-converter.js +383 -0
- package/dist/skill/openapi-converter.js.map +1 -0
- package/dist/types.d.ts +41 -0
- package/package.json +1 -1
- package/src/auth/handoff.ts +1 -1
- package/src/capture/cdp-attach.ts +501 -0
- package/src/capture/filter.ts +5 -0
- package/src/capture/parameterize.ts +207 -11
- package/src/capture/session.ts +20 -10
- package/src/cli.ts +420 -18
- package/src/discovery/openapi.ts +25 -56
- package/src/index.ts +1 -0
- package/src/mcp.ts +12 -0
- package/src/native-host.ts +7 -0
- package/src/plugin.ts +10 -3
- package/src/replay/engine.ts +19 -0
- package/src/skill/apis-guru.ts +163 -0
- package/src/skill/generator.ts +38 -3
- package/src/skill/merge.ts +281 -0
- package/src/skill/openapi-converter.ts +426 -0
- package/src/types.ts +42 -1
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
// src/capture/cdp-attach.ts
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import type { CapturedExchange } from '../types.js';
|
|
5
|
+
import { shouldCapture } from './filter.js';
|
|
6
|
+
import { SkillGenerator, deduplicateAuth } from '../skill/generator.js';
|
|
7
|
+
import { signSkillFile } from '../skill/signing.js';
|
|
8
|
+
import { writeSkillFile } from '../skill/store.js';
|
|
9
|
+
import { AuthManager, getMachineId } from '../auth/manager.js';
|
|
10
|
+
import { deriveSigningKey } from '../auth/crypto.js';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
// ---- Domain glob matching ----
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Test if a hostname matches any pattern in a domain glob list.
|
|
17
|
+
* Empty list means "match all" (no filter).
|
|
18
|
+
*
|
|
19
|
+
* Glob rules:
|
|
20
|
+
* - "api.github.com" — exact match
|
|
21
|
+
* - "*.github.com" — matches any subdomain AND the bare domain
|
|
22
|
+
* (the *. prefix means "zero or more subdomains")
|
|
23
|
+
*/
|
|
24
|
+
export function matchesDomainGlob(hostname: string, patterns: string[]): boolean {
|
|
25
|
+
if (patterns.length === 0) return true;
|
|
26
|
+
|
|
27
|
+
for (const pattern of patterns) {
|
|
28
|
+
if (pattern.startsWith('*.')) {
|
|
29
|
+
const base = pattern.slice(2); // "github.com"
|
|
30
|
+
// Match bare domain or any subdomain
|
|
31
|
+
if (hostname === base || hostname.endsWith('.' + base)) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
35
|
+
if (hostname === pattern) return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse a comma-separated domain pattern string into a list.
|
|
43
|
+
*/
|
|
44
|
+
export function parseDomainPatterns(input: string | undefined): string[] {
|
|
45
|
+
if (!input || input.trim() === '') return [];
|
|
46
|
+
return input.split(',').map(p => p.trim()).filter(p => p.length > 0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---- CDP HTTP discovery ----
|
|
50
|
+
|
|
51
|
+
function cdpGet<T>(url: string): Promise<T> {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
http.get(url, (res) => {
|
|
54
|
+
let data = '';
|
|
55
|
+
res.on('data', (chunk: string) => data += chunk);
|
|
56
|
+
res.on('end', () => {
|
|
57
|
+
try { resolve(JSON.parse(data) as T); }
|
|
58
|
+
catch { reject(new Error(`Invalid JSON from ${url}`)); }
|
|
59
|
+
});
|
|
60
|
+
}).on('error', reject);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Discover Chrome's browser-level WebSocket URL via the /json/version endpoint.
|
|
66
|
+
*/
|
|
67
|
+
export async function discoverBrowserWsUrl(port: number): Promise<{
|
|
68
|
+
wsUrl: string;
|
|
69
|
+
browser: string;
|
|
70
|
+
tabCount: number;
|
|
71
|
+
}> {
|
|
72
|
+
const versionInfo = await cdpGet<{
|
|
73
|
+
Browser: string;
|
|
74
|
+
webSocketDebuggerUrl: string;
|
|
75
|
+
}>(`http://127.0.0.1:${port}/json/version`);
|
|
76
|
+
|
|
77
|
+
const targets = await cdpGet<Array<{ type: string }>>(`http://127.0.0.1:${port}/json/list`);
|
|
78
|
+
const tabCount = targets.filter(t => t.type === 'page').length;
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
wsUrl: versionInfo.webSocketDebuggerUrl,
|
|
82
|
+
browser: versionInfo.Browser,
|
|
83
|
+
tabCount,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---- CDP WebSocket session ----
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Minimal CDP session over a single WebSocket.
|
|
91
|
+
* Supports browser-level commands and session-multiplexed target commands
|
|
92
|
+
* (via the sessionId parameter on send/events, using flatten: true).
|
|
93
|
+
*/
|
|
94
|
+
export class CDPSession {
|
|
95
|
+
private ws: WebSocket | null = null;
|
|
96
|
+
private nextId = 1;
|
|
97
|
+
private callbacks = new Map<number, {
|
|
98
|
+
resolve: (v: unknown) => void;
|
|
99
|
+
reject: (e: Error) => void;
|
|
100
|
+
timer: ReturnType<typeof setTimeout>;
|
|
101
|
+
}>();
|
|
102
|
+
private listeners = new Map<string, Array<(params: Record<string, unknown>) => void>>();
|
|
103
|
+
|
|
104
|
+
constructor(private wsUrl: string) {}
|
|
105
|
+
|
|
106
|
+
connect(): Promise<void> {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
this.ws = new WebSocket(this.wsUrl);
|
|
109
|
+
this.ws.onopen = () => resolve();
|
|
110
|
+
this.ws.onerror = (e) => reject(new Error(`CDP WebSocket error: ${e}`));
|
|
111
|
+
this.ws.onclose = () => {
|
|
112
|
+
// Reject all pending callbacks
|
|
113
|
+
for (const [, cb] of this.callbacks) {
|
|
114
|
+
clearTimeout(cb.timer);
|
|
115
|
+
cb.reject(new Error('CDP connection closed'));
|
|
116
|
+
}
|
|
117
|
+
this.callbacks.clear();
|
|
118
|
+
// Fire close listeners
|
|
119
|
+
for (const handler of this.listeners.get('close') ?? []) {
|
|
120
|
+
handler({});
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
this.ws.onmessage = (event) => {
|
|
124
|
+
const msg = JSON.parse(
|
|
125
|
+
typeof event.data === 'string' ? event.data : String(event.data),
|
|
126
|
+
) as Record<string, unknown>;
|
|
127
|
+
|
|
128
|
+
// Handle command responses
|
|
129
|
+
if (msg.id !== undefined && this.callbacks.has(msg.id as number)) {
|
|
130
|
+
const cb = this.callbacks.get(msg.id as number)!;
|
|
131
|
+
clearTimeout(cb.timer);
|
|
132
|
+
this.callbacks.delete(msg.id as number);
|
|
133
|
+
if (msg.error) {
|
|
134
|
+
const err = msg.error as { message: string };
|
|
135
|
+
cb.reject(new Error(`CDP: ${err.message}`));
|
|
136
|
+
} else {
|
|
137
|
+
cb.resolve(msg.result);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Handle events
|
|
142
|
+
if (msg.method) {
|
|
143
|
+
const sessionId = msg.sessionId as string | undefined;
|
|
144
|
+
// Fire session-scoped handlers: "sessionId:Event.name"
|
|
145
|
+
if (sessionId) {
|
|
146
|
+
const scopedKey = `${sessionId}:${msg.method as string}`;
|
|
147
|
+
for (const handler of this.listeners.get(scopedKey) ?? []) {
|
|
148
|
+
handler(msg.params as Record<string, unknown>);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Fire global handlers (non-session-scoped)
|
|
152
|
+
for (const handler of this.listeners.get(msg.method as string) ?? []) {
|
|
153
|
+
handler({
|
|
154
|
+
...(msg.params as Record<string, unknown>),
|
|
155
|
+
...(sessionId ? { _sessionId: sessionId } : {}),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
send(method: string, params: Record<string, unknown> = {}, sessionId?: string): Promise<Record<string, unknown>> {
|
|
164
|
+
const id = this.nextId++;
|
|
165
|
+
return new Promise((resolve, reject) => {
|
|
166
|
+
const timer = setTimeout(() => {
|
|
167
|
+
this.callbacks.delete(id);
|
|
168
|
+
reject(new Error(`CDP timeout: ${method}`));
|
|
169
|
+
}, 15000);
|
|
170
|
+
this.callbacks.set(id, {
|
|
171
|
+
resolve: resolve as (v: unknown) => void,
|
|
172
|
+
reject,
|
|
173
|
+
timer,
|
|
174
|
+
});
|
|
175
|
+
const msg: Record<string, unknown> = { id, method, params };
|
|
176
|
+
if (sessionId) msg.sessionId = sessionId;
|
|
177
|
+
this.ws!.send(JSON.stringify(msg));
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
on(event: string, handler: (params: Record<string, unknown>) => void): void {
|
|
182
|
+
if (!this.listeners.has(event)) this.listeners.set(event, []);
|
|
183
|
+
this.listeners.get(event)!.push(handler);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
close(): void {
|
|
187
|
+
if (this.ws) {
|
|
188
|
+
this.ws.close();
|
|
189
|
+
this.ws = null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---- Attach engine ----
|
|
195
|
+
|
|
196
|
+
export interface AttachOptions {
|
|
197
|
+
port: number;
|
|
198
|
+
domainPatterns: string[];
|
|
199
|
+
json: boolean;
|
|
200
|
+
onProgress?: (line: string) => void;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface AttachResult {
|
|
204
|
+
domains: Array<{
|
|
205
|
+
domain: string;
|
|
206
|
+
endpoints: number;
|
|
207
|
+
skillFile: string;
|
|
208
|
+
}>;
|
|
209
|
+
totalRequests: number;
|
|
210
|
+
filteredRequests: number;
|
|
211
|
+
duration: number;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Attach to a running Chrome instance via CDP, passively capture API traffic
|
|
216
|
+
* across all tabs, and generate signed skill files on SIGINT.
|
|
217
|
+
*/
|
|
218
|
+
export async function attach(options: AttachOptions): Promise<AttachResult> {
|
|
219
|
+
const { port, domainPatterns, json } = options;
|
|
220
|
+
const log = (msg: string) => {
|
|
221
|
+
if (!json) process.stderr.write(msg + '\n');
|
|
222
|
+
options.onProgress?.(msg);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// SIGINT state — registered before any connection attempt (spec requirement)
|
|
226
|
+
let stopping = false;
|
|
227
|
+
|
|
228
|
+
// Phase 0: Discover browser
|
|
229
|
+
let browserInfo;
|
|
230
|
+
try {
|
|
231
|
+
browserInfo = await discoverBrowserWsUrl(port);
|
|
232
|
+
} catch {
|
|
233
|
+
log(`[attach] Cannot connect to Chrome on :${port}`);
|
|
234
|
+
log('');
|
|
235
|
+
log('To enable remote debugging, relaunch Chrome with:');
|
|
236
|
+
log(` google-chrome --remote-debugging-port=${port}`);
|
|
237
|
+
log('');
|
|
238
|
+
log('Or on macOS:');
|
|
239
|
+
log(` /Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --remote-debugging-port=${port}`);
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
log(`[attach] Connected to ${browserInfo.browser} on :${port} (${browserInfo.tabCount} tabs)`);
|
|
244
|
+
if (domainPatterns.length > 0) {
|
|
245
|
+
log(`[attach] Watching domains: ${domainPatterns.join(', ')}`);
|
|
246
|
+
} else {
|
|
247
|
+
log('[attach] Watching all domains');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Phase 1: Connect browser-level CDP session
|
|
251
|
+
const browser = new CDPSession(browserInfo.wsUrl);
|
|
252
|
+
await browser.connect();
|
|
253
|
+
|
|
254
|
+
// Capture state
|
|
255
|
+
const generators = new Map<string, SkillGenerator>();
|
|
256
|
+
const requests = new Map<string, {
|
|
257
|
+
url: string; method: string; headers: Record<string, string>; postData?: string;
|
|
258
|
+
}>();
|
|
259
|
+
const responses = new Map<string, {
|
|
260
|
+
status: number; headers: Record<string, string>; mimeType: string;
|
|
261
|
+
}>();
|
|
262
|
+
let totalRequests = 0;
|
|
263
|
+
let filteredRequests = 0;
|
|
264
|
+
const startTime = Date.now();
|
|
265
|
+
const activeSessions = new Set<string>();
|
|
266
|
+
const loggedSkippedDomains = new Set<string>();
|
|
267
|
+
|
|
268
|
+
function enableNetworkForSession(sessionId: string): void {
|
|
269
|
+
if (activeSessions.has(sessionId)) return;
|
|
270
|
+
activeSessions.add(sessionId);
|
|
271
|
+
|
|
272
|
+
// Prefix requestIds with sessionId to avoid collisions across tabs
|
|
273
|
+
const prefix = sessionId.slice(0, 8);
|
|
274
|
+
|
|
275
|
+
browser.on(`${sessionId}:Network.requestWillBeSent`, (params) => {
|
|
276
|
+
// Evict oldest entries if Maps grow too large (memory safety)
|
|
277
|
+
if (requests.size > 10000) {
|
|
278
|
+
const keys = [...requests.keys()].slice(0, 1000);
|
|
279
|
+
for (const k of keys) { requests.delete(k); responses.delete(k); }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const key = `${prefix}:${params.requestId}`;
|
|
283
|
+
const request = params.request as Record<string, unknown>;
|
|
284
|
+
requests.set(key, {
|
|
285
|
+
url: request.url as string,
|
|
286
|
+
method: request.method as string,
|
|
287
|
+
headers: request.headers as Record<string, string>,
|
|
288
|
+
postData: request.postData as string | undefined,
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
browser.on(`${sessionId}:Network.responseReceived`, (params) => {
|
|
293
|
+
const key = `${prefix}:${params.requestId}`;
|
|
294
|
+
const response = params.response as Record<string, unknown>;
|
|
295
|
+
responses.set(key, {
|
|
296
|
+
status: response.status as number,
|
|
297
|
+
headers: response.headers as Record<string, string>,
|
|
298
|
+
mimeType: response.mimeType as string,
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
browser.on(`${sessionId}:Network.loadingFinished`, (params) => {
|
|
303
|
+
const key = `${prefix}:${params.requestId}`;
|
|
304
|
+
const req = requests.get(key);
|
|
305
|
+
const resp = responses.get(key);
|
|
306
|
+
if (!req || !resp) return;
|
|
307
|
+
|
|
308
|
+
totalRequests++;
|
|
309
|
+
|
|
310
|
+
// Get response body immediately (before Chrome evicts it from buffer).
|
|
311
|
+
// This MUST be called synchronously in the handler — deferring risks
|
|
312
|
+
// "No resource with given identifier found" on high-traffic tabs.
|
|
313
|
+
browser.send(
|
|
314
|
+
'Network.getResponseBody',
|
|
315
|
+
{ requestId: params.requestId },
|
|
316
|
+
sessionId,
|
|
317
|
+
).then((result) => {
|
|
318
|
+
processExchange(key, req, resp, (result.body as string) ?? '', log);
|
|
319
|
+
}).catch(() => {
|
|
320
|
+
// Body evicted or unavailable — still process exchange without body
|
|
321
|
+
processExchange(key, req, resp, '', log);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// Enable network capture for this session (fire-and-forget)
|
|
326
|
+
browser.send('Network.enable', {}, sessionId).catch(() => {
|
|
327
|
+
// Session may have been destroyed
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function processExchange(
|
|
332
|
+
key: string,
|
|
333
|
+
req: { url: string; method: string; headers: Record<string, string>; postData?: string },
|
|
334
|
+
resp: { status: number; headers: Record<string, string>; mimeType: string },
|
|
335
|
+
body: string,
|
|
336
|
+
logFn: (msg: string) => void,
|
|
337
|
+
): void {
|
|
338
|
+
// Apply shouldCapture filter
|
|
339
|
+
if (!shouldCapture({ url: req.url, status: resp.status, contentType: resp.mimeType })) {
|
|
340
|
+
filteredRequests++;
|
|
341
|
+
if (req.url.startsWith('chrome-extension://')) {
|
|
342
|
+
logFn(` [skip] ${req.url.slice(0, 50)}... (extension)`);
|
|
343
|
+
}
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Apply domain glob filter
|
|
348
|
+
let hostname: string;
|
|
349
|
+
try {
|
|
350
|
+
hostname = new URL(req.url).hostname;
|
|
351
|
+
} catch { return; }
|
|
352
|
+
|
|
353
|
+
if (!matchesDomainGlob(hostname, domainPatterns)) {
|
|
354
|
+
filteredRequests++;
|
|
355
|
+
if (!loggedSkippedDomains.has(hostname)) {
|
|
356
|
+
loggedSkippedDomains.add(hostname);
|
|
357
|
+
logFn(` [skip] ${hostname} (not in domain filter)`);
|
|
358
|
+
}
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Get or create generator for this domain
|
|
363
|
+
if (!generators.has(hostname)) {
|
|
364
|
+
generators.set(hostname, new SkillGenerator());
|
|
365
|
+
}
|
|
366
|
+
const gen = generators.get(hostname)!;
|
|
367
|
+
|
|
368
|
+
const exchange: CapturedExchange = {
|
|
369
|
+
request: {
|
|
370
|
+
url: req.url,
|
|
371
|
+
method: req.method,
|
|
372
|
+
headers: req.headers,
|
|
373
|
+
postData: req.postData,
|
|
374
|
+
},
|
|
375
|
+
response: {
|
|
376
|
+
status: resp.status,
|
|
377
|
+
headers: resp.headers,
|
|
378
|
+
body,
|
|
379
|
+
contentType: resp.mimeType,
|
|
380
|
+
},
|
|
381
|
+
timestamp: new Date().toISOString(),
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const endpoint = gen.addExchange(exchange);
|
|
385
|
+
if (endpoint) {
|
|
386
|
+
logFn(` [api] ${req.method} ${resp.status} ${hostname} ${endpoint.path}`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Clean up to avoid memory growth
|
|
390
|
+
requests.delete(key);
|
|
391
|
+
responses.delete(key);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Phase 2: Attach to all existing page targets
|
|
395
|
+
const { targetInfos } = await browser.send('Target.getTargets') as unknown as {
|
|
396
|
+
targetInfos: Array<{ type: string; targetId: string }>;
|
|
397
|
+
};
|
|
398
|
+
for (const target of targetInfos) {
|
|
399
|
+
if (target.type === 'page') {
|
|
400
|
+
try {
|
|
401
|
+
const result = await browser.send('Target.attachToTarget', {
|
|
402
|
+
targetId: target.targetId,
|
|
403
|
+
flatten: true,
|
|
404
|
+
});
|
|
405
|
+
enableNetworkForSession(result.sessionId as string);
|
|
406
|
+
} catch {
|
|
407
|
+
// Target may have navigated away
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Phase 3: Auto-attach to future targets (new tabs, popups, OAuth redirects).
|
|
413
|
+
// flatten: true is critical — uses session-based CDP multiplexing instead
|
|
414
|
+
// of legacy nested WebSocket connections.
|
|
415
|
+
browser.on('Target.attachedToTarget', (params) => {
|
|
416
|
+
const targetInfo = params.targetInfo as { type: string } | undefined;
|
|
417
|
+
if (targetInfo?.type === 'page') {
|
|
418
|
+
enableNetworkForSession(params.sessionId as string);
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
await browser.send('Target.setAutoAttach', {
|
|
423
|
+
autoAttach: true,
|
|
424
|
+
waitForDebuggerOnStart: false,
|
|
425
|
+
flatten: true,
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// Phase 4: Wait for SIGINT or browser disconnect
|
|
429
|
+
const result = await new Promise<AttachResult>((resolve) => {
|
|
430
|
+
const shutdown = async () => {
|
|
431
|
+
if (stopping) {
|
|
432
|
+
// Second SIGINT — force exit immediately
|
|
433
|
+
process.exit(1);
|
|
434
|
+
}
|
|
435
|
+
stopping = true;
|
|
436
|
+
log('');
|
|
437
|
+
|
|
438
|
+
const duration = Math.round((Date.now() - startTime) / 1000);
|
|
439
|
+
|
|
440
|
+
if (generators.size === 0) {
|
|
441
|
+
log('[attach] Nothing captured');
|
|
442
|
+
browser.close();
|
|
443
|
+
resolve({ domains: [], totalRequests, filteredRequests, duration });
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
log('[attach] Generating skill files...');
|
|
448
|
+
|
|
449
|
+
const machineId = await getMachineId();
|
|
450
|
+
const signingKey = deriveSigningKey(machineId);
|
|
451
|
+
const apitapDir = process.env.APITAP_DIR || join(homedir(), '.apitap');
|
|
452
|
+
const authManager = new AuthManager(apitapDir, machineId);
|
|
453
|
+
const domains: AttachResult['domains'] = [];
|
|
454
|
+
|
|
455
|
+
for (const [domain, gen] of generators) {
|
|
456
|
+
let skill = gen.toSkillFile(domain);
|
|
457
|
+
if (skill.endpoints.length === 0) continue;
|
|
458
|
+
|
|
459
|
+
// Store extracted auth credentials
|
|
460
|
+
const auth = deduplicateAuth(gen.getExtractedAuth());
|
|
461
|
+
if (auth) {
|
|
462
|
+
await authManager.store(domain, auth);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Store OAuth credentials if detected
|
|
466
|
+
const oauthConfig = gen.getOAuthConfig();
|
|
467
|
+
if (oauthConfig) {
|
|
468
|
+
const clientSecret = gen.getOAuthClientSecret();
|
|
469
|
+
const refreshToken = gen.getOAuthRefreshToken();
|
|
470
|
+
if (clientSecret || refreshToken) {
|
|
471
|
+
await authManager.storeOAuthCredentials(domain, {
|
|
472
|
+
...(clientSecret ? { clientSecret } : {}),
|
|
473
|
+
...(refreshToken ? { refreshToken } : {}),
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
skill = signSkillFile(skill, signingKey);
|
|
479
|
+
const skillPath = await writeSkillFile(skill);
|
|
480
|
+
|
|
481
|
+
const displayPath = skillPath.replace(homedir(), '~');
|
|
482
|
+
const count = skill.endpoints.length;
|
|
483
|
+
log(` ${domain} — ${count} endpoint${count === 1 ? '' : 's'} → ${displayPath}`);
|
|
484
|
+
|
|
485
|
+
domains.push({ domain, endpoints: count, skillFile: displayPath });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
browser.close();
|
|
489
|
+
resolve({ domains, totalRequests, filteredRequests, duration });
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
process.on('SIGINT', shutdown);
|
|
493
|
+
|
|
494
|
+
// Handle browser disconnect (user closed Chrome)
|
|
495
|
+
browser.on('close', () => {
|
|
496
|
+
if (!stopping) shutdown();
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
return result;
|
|
501
|
+
}
|
package/src/capture/filter.ts
CHANGED
|
@@ -36,6 +36,11 @@ export function isPathNoise(pathname: string): boolean {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export function shouldCapture(response: FilterableResponse): boolean {
|
|
39
|
+
// Block extension-internal traffic (prevents self-capture when
|
|
40
|
+
// attaching to a browser with ApiTap extension loaded)
|
|
41
|
+
if (response.url.startsWith('chrome-extension://')) return false;
|
|
42
|
+
if (response.url.startsWith('moz-extension://')) return false;
|
|
43
|
+
|
|
39
44
|
// Only keep 2xx success responses
|
|
40
45
|
if (response.status < 200 || response.status >= 300) return false;
|
|
41
46
|
|