@apitap/core 1.0.22 → 1.2.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 +38 -2
- package/dist/bridge/client.d.ts +19 -0
- package/dist/bridge/client.js +52 -0
- package/dist/bridge/client.js.map +1 -0
- package/dist/cli.js +41 -1
- package/dist/cli.js.map +1 -1
- package/dist/extension/install.d.ts +13 -0
- package/dist/extension/install.js +53 -0
- package/dist/extension/install.js.map +1 -0
- package/dist/native-host.d.ts +24 -0
- package/dist/native-host.js +288 -0
- package/dist/native-host.js.map +1 -0
- package/dist/orchestration/browse.d.ts +5 -1
- package/dist/orchestration/browse.js +109 -0
- package/dist/orchestration/browse.js.map +1 -1
- package/package.json +2 -1
- package/src/bridge/client.ts +67 -0
- package/src/cli.ts +47 -1
- package/src/extension/install.ts +73 -0
- package/src/native-host.ts +354 -0
- package/src/orchestration/browse.ts +122 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
|
|
5
|
+
const HOST_NAME = 'com.apitap.native';
|
|
6
|
+
|
|
7
|
+
export interface HostManifest {
|
|
8
|
+
name: string;
|
|
9
|
+
description: string;
|
|
10
|
+
path: string;
|
|
11
|
+
type: 'stdio';
|
|
12
|
+
allowed_origins: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function generateHostManifest(hostPath: string, extensionId: string): HostManifest {
|
|
16
|
+
return {
|
|
17
|
+
name: HOST_NAME,
|
|
18
|
+
description: 'ApiTap native messaging host — saves captured skill files to ~/.apitap/skills/',
|
|
19
|
+
path: hostPath,
|
|
20
|
+
type: 'stdio',
|
|
21
|
+
allowed_origins: [`chrome-extension://${extensionId}/`],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getBrowserPaths(platform: string = process.platform): string[] {
|
|
26
|
+
const home = os.homedir();
|
|
27
|
+
|
|
28
|
+
if (platform === 'linux') {
|
|
29
|
+
return [
|
|
30
|
+
path.join(home, '.config', 'google-chrome', 'NativeMessagingHosts'),
|
|
31
|
+
path.join(home, '.config', 'chromium', 'NativeMessagingHosts'),
|
|
32
|
+
path.join(home, '.config', 'BraveSoftware', 'Brave-Browser', 'NativeMessagingHosts'),
|
|
33
|
+
path.join(home, '.config', 'microsoft-edge', 'NativeMessagingHosts'),
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (platform === 'darwin') {
|
|
38
|
+
return [
|
|
39
|
+
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome', 'NativeMessagingHosts'),
|
|
40
|
+
path.join(home, 'Library', 'Application Support', 'Chromium', 'NativeMessagingHosts'),
|
|
41
|
+
path.join(home, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser', 'NativeMessagingHosts'),
|
|
42
|
+
path.join(home, 'Library', 'Application Support', 'Microsoft Edge', 'NativeMessagingHosts'),
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function installNativeHost(
|
|
50
|
+
hostPath: string,
|
|
51
|
+
extensionId: string,
|
|
52
|
+
browserDirs?: string[],
|
|
53
|
+
): Promise<{ installed: string[]; errors: string[] }> {
|
|
54
|
+
const dirs = browserDirs ?? getBrowserPaths();
|
|
55
|
+
const manifest = generateHostManifest(hostPath, extensionId);
|
|
56
|
+
const manifestJson = JSON.stringify(manifest, null, 2);
|
|
57
|
+
|
|
58
|
+
const installed: string[] = [];
|
|
59
|
+
const errors: string[] = [];
|
|
60
|
+
|
|
61
|
+
for (const dir of dirs) {
|
|
62
|
+
try {
|
|
63
|
+
await fs.mkdir(dir, { recursive: true });
|
|
64
|
+
const manifestPath = path.join(dir, `${HOST_NAME}.json`);
|
|
65
|
+
await fs.writeFile(manifestPath, manifestJson, 'utf-8');
|
|
66
|
+
installed.push(manifestPath);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
errors.push(`${dir}: ${err}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { installed, errors };
|
|
73
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ApiTap Native Messaging Host
|
|
3
|
+
// Receives skill files from the Chrome extension and saves to ~/.apitap/skills/
|
|
4
|
+
|
|
5
|
+
import { promises as fs } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import net from 'node:net';
|
|
9
|
+
import { signSkillFile } from './skill/signing.js';
|
|
10
|
+
import { deriveKey } from './auth/crypto.js';
|
|
11
|
+
import { getMachineId } from './auth/manager.js';
|
|
12
|
+
|
|
13
|
+
const SKILLS_DIR = path.join(os.homedir(), '.apitap', 'skills');
|
|
14
|
+
const VERSION = '1.0.0';
|
|
15
|
+
|
|
16
|
+
// Sign skill JSON using the CLI's HMAC signing infrastructure
|
|
17
|
+
async function signSkillJson(skillJson: string): Promise<string> {
|
|
18
|
+
try {
|
|
19
|
+
const skill = JSON.parse(skillJson);
|
|
20
|
+
const machineId = await getMachineId();
|
|
21
|
+
const key = deriveKey(machineId);
|
|
22
|
+
const signed = signSkillFile(skill, key);
|
|
23
|
+
return JSON.stringify(signed);
|
|
24
|
+
} catch {
|
|
25
|
+
return skillJson; // Return unsigned if signing fails
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface NativeRequest {
|
|
30
|
+
action: 'save_skill' | 'save_batch' | 'ping' | 'capture_request';
|
|
31
|
+
domain?: string;
|
|
32
|
+
skillJson?: string;
|
|
33
|
+
skills?: Array<{ domain: string; skillJson: string }>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface NativeResponse {
|
|
37
|
+
success: boolean;
|
|
38
|
+
action?: string;
|
|
39
|
+
path?: string;
|
|
40
|
+
paths?: string[];
|
|
41
|
+
error?: string;
|
|
42
|
+
version?: string;
|
|
43
|
+
skillsDir?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Domain validation — must match src/skill/store.ts conventions
|
|
47
|
+
function isValidDomain(domain: string): boolean {
|
|
48
|
+
if (!domain || domain.length === 0 || domain.length > 253) return false;
|
|
49
|
+
if (domain.includes('/') || domain.includes('\\')) return false;
|
|
50
|
+
if (domain.includes('..')) return false;
|
|
51
|
+
if (domain.startsWith('.') || domain.startsWith('-')) return false;
|
|
52
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(domain)) return false;
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function handleNativeMessage(
|
|
57
|
+
request: NativeRequest,
|
|
58
|
+
skillsDir: string = SKILLS_DIR,
|
|
59
|
+
): Promise<NativeResponse> {
|
|
60
|
+
try {
|
|
61
|
+
if (request.action === 'ping') {
|
|
62
|
+
return {
|
|
63
|
+
success: true,
|
|
64
|
+
action: 'pong',
|
|
65
|
+
version: VERSION,
|
|
66
|
+
skillsDir,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (request.action === 'save_skill') {
|
|
71
|
+
if (!request.domain || !isValidDomain(request.domain)) {
|
|
72
|
+
return { success: false, error: `Invalid domain: ${request.domain}` };
|
|
73
|
+
}
|
|
74
|
+
if (!request.skillJson) {
|
|
75
|
+
return { success: false, error: 'Missing skillJson' };
|
|
76
|
+
}
|
|
77
|
+
// Validate JSON
|
|
78
|
+
try {
|
|
79
|
+
JSON.parse(request.skillJson);
|
|
80
|
+
} catch {
|
|
81
|
+
return { success: false, error: 'Invalid JSON in skillJson' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await fs.mkdir(skillsDir, { recursive: true });
|
|
85
|
+
const filePath = path.join(skillsDir, `${request.domain}.json`);
|
|
86
|
+
// Sign the skill file on receive (CLI is the signing authority)
|
|
87
|
+
const signed = await signSkillJson(request.skillJson);
|
|
88
|
+
await fs.writeFile(filePath, signed, 'utf-8');
|
|
89
|
+
return { success: true, path: filePath };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (request.action === 'save_batch') {
|
|
93
|
+
if (!Array.isArray(request.skills) || request.skills.length === 0) {
|
|
94
|
+
return { success: false, error: 'Missing or empty skills array' };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await fs.mkdir(skillsDir, { recursive: true });
|
|
98
|
+
const paths: string[] = [];
|
|
99
|
+
|
|
100
|
+
for (const { domain, skillJson } of request.skills) {
|
|
101
|
+
if (!isValidDomain(domain)) {
|
|
102
|
+
return { success: false, error: `Invalid domain: ${domain}` };
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
JSON.parse(skillJson);
|
|
106
|
+
} catch {
|
|
107
|
+
return { success: false, error: `Invalid JSON for domain ${domain}` };
|
|
108
|
+
}
|
|
109
|
+
const filePath = path.join(skillsDir, `${domain}.json`);
|
|
110
|
+
const signed = await signSkillJson(skillJson);
|
|
111
|
+
await fs.writeFile(filePath, signed, 'utf-8');
|
|
112
|
+
paths.push(filePath);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { success: true, paths };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { success: false, error: `Unknown action: ${request.action}` };
|
|
119
|
+
} catch (err) {
|
|
120
|
+
return { success: false, error: String(err) };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- Relay handler ---
|
|
125
|
+
|
|
126
|
+
// Actions handled locally by the native host (filesystem operations)
|
|
127
|
+
const LOCAL_ACTIONS = new Set(['save_skill', 'save_batch', 'ping']);
|
|
128
|
+
|
|
129
|
+
// Actions relayed to the extension (browser operations)
|
|
130
|
+
const EXTENSION_ACTIONS = new Set(['capture_request']);
|
|
131
|
+
|
|
132
|
+
export function createRelayHandler(
|
|
133
|
+
sendToExtension: (msg: any) => Promise<any>,
|
|
134
|
+
skillsDir: string = SKILLS_DIR,
|
|
135
|
+
): MessageHandler {
|
|
136
|
+
return async (message: any) => {
|
|
137
|
+
if (LOCAL_ACTIONS.has(message.action)) {
|
|
138
|
+
return handleNativeMessage(message, skillsDir);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (EXTENSION_ACTIONS.has(message.action)) {
|
|
142
|
+
try {
|
|
143
|
+
return await sendToExtension(message);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
return { success: false, error: String(err) };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { success: false, error: `Unknown action: ${message.action}` };
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- Unix socket server for CLI relay ---
|
|
154
|
+
|
|
155
|
+
export type MessageHandler = (message: any) => Promise<any>;
|
|
156
|
+
|
|
157
|
+
let socketServer: net.Server | null = null;
|
|
158
|
+
|
|
159
|
+
export async function startSocketServer(
|
|
160
|
+
socketPath: string,
|
|
161
|
+
handler: MessageHandler,
|
|
162
|
+
): Promise<void> {
|
|
163
|
+
// Clean up stale socket
|
|
164
|
+
try { await fs.unlink(socketPath); } catch { /* doesn't exist — fine */ }
|
|
165
|
+
|
|
166
|
+
return new Promise((resolve, reject) => {
|
|
167
|
+
socketServer = net.createServer((conn) => {
|
|
168
|
+
let buffer = '';
|
|
169
|
+
|
|
170
|
+
conn.on('data', (chunk) => {
|
|
171
|
+
buffer += chunk.toString();
|
|
172
|
+
const newlineIdx = buffer.indexOf('\n');
|
|
173
|
+
if (newlineIdx === -1) return;
|
|
174
|
+
|
|
175
|
+
const line = buffer.slice(0, newlineIdx);
|
|
176
|
+
buffer = buffer.slice(newlineIdx + 1);
|
|
177
|
+
|
|
178
|
+
let request: any;
|
|
179
|
+
try {
|
|
180
|
+
request = JSON.parse(line);
|
|
181
|
+
} catch {
|
|
182
|
+
conn.end(JSON.stringify({ success: false, error: 'Invalid JSON' }) + '\n');
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
handler(request).then(
|
|
187
|
+
(response) => conn.end(JSON.stringify(response) + '\n'),
|
|
188
|
+
(err) => conn.end(JSON.stringify({ success: false, error: String(err) }) + '\n'),
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
conn.on('error', () => { /* client disconnect — ignore */ });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
socketServer.on('error', reject);
|
|
196
|
+
socketServer.listen(socketPath, () => resolve());
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function stopSocketServer(): Promise<void> {
|
|
201
|
+
if (!socketServer) return;
|
|
202
|
+
return new Promise((resolve) => {
|
|
203
|
+
socketServer!.close(() => resolve());
|
|
204
|
+
socketServer = null;
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// --- stdio framing (only runs when executed directly, not when imported for tests) ---
|
|
209
|
+
|
|
210
|
+
function readMessage(): Promise<NativeRequest | null> {
|
|
211
|
+
return new Promise((resolve) => {
|
|
212
|
+
const headerBuf = Buffer.alloc(4);
|
|
213
|
+
let headerRead = 0;
|
|
214
|
+
|
|
215
|
+
function onData(chunk: Buffer) {
|
|
216
|
+
let offset = 0;
|
|
217
|
+
|
|
218
|
+
// Read header
|
|
219
|
+
if (headerRead < 4) {
|
|
220
|
+
const needed = 4 - headerRead;
|
|
221
|
+
const toCopy = Math.min(needed, chunk.length);
|
|
222
|
+
chunk.copy(headerBuf, headerRead, 0, toCopy);
|
|
223
|
+
headerRead += toCopy;
|
|
224
|
+
offset = toCopy;
|
|
225
|
+
|
|
226
|
+
if (headerRead < 4) return; // need more data for header
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const messageLength = headerBuf.readUInt32LE(0);
|
|
230
|
+
if (messageLength > 1024 * 1024) {
|
|
231
|
+
process.stderr.write(`Message too large: ${messageLength}\n`);
|
|
232
|
+
resolve(null);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Accumulate message body
|
|
237
|
+
const bodyBuf = Buffer.alloc(messageLength);
|
|
238
|
+
let bodyRead = 0;
|
|
239
|
+
|
|
240
|
+
if (offset < chunk.length) {
|
|
241
|
+
const remaining = chunk.subarray(offset);
|
|
242
|
+
const toCopy = Math.min(remaining.length, messageLength);
|
|
243
|
+
remaining.copy(bodyBuf, 0, 0, toCopy);
|
|
244
|
+
bodyRead = toCopy;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (bodyRead >= messageLength) {
|
|
248
|
+
process.stdin.removeListener('data', onData);
|
|
249
|
+
try {
|
|
250
|
+
resolve(JSON.parse(bodyBuf.toString('utf-8')));
|
|
251
|
+
} catch {
|
|
252
|
+
resolve(null);
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Need more data
|
|
258
|
+
function onMoreData(moreChunk: Buffer) {
|
|
259
|
+
const toCopy = Math.min(moreChunk.length, messageLength - bodyRead);
|
|
260
|
+
moreChunk.copy(bodyBuf, bodyRead, 0, toCopy);
|
|
261
|
+
bodyRead += toCopy;
|
|
262
|
+
|
|
263
|
+
if (bodyRead >= messageLength) {
|
|
264
|
+
process.stdin.removeListener('data', onMoreData);
|
|
265
|
+
process.stdin.removeListener('data', onData);
|
|
266
|
+
try {
|
|
267
|
+
resolve(JSON.parse(bodyBuf.toString('utf-8')));
|
|
268
|
+
} catch {
|
|
269
|
+
resolve(null);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
process.stdin.on('data', onMoreData);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
process.stdin.on('data', onData);
|
|
277
|
+
process.stdin.on('end', () => resolve(null));
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function sendMessage(message: NativeResponse) {
|
|
282
|
+
const json = Buffer.from(JSON.stringify(message), 'utf-8');
|
|
283
|
+
const header = Buffer.alloc(4);
|
|
284
|
+
header.writeUInt32LE(json.length, 0);
|
|
285
|
+
process.stdout.write(header);
|
|
286
|
+
process.stdout.write(json);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Main loop — only runs when executed as a script
|
|
290
|
+
const isMainModule = process.argv[1] &&
|
|
291
|
+
(process.argv[1].endsWith('native-host.ts') || process.argv[1].endsWith('native-host.js'));
|
|
292
|
+
|
|
293
|
+
if (isMainModule) {
|
|
294
|
+
const bridgeDir = path.join(os.homedir(), '.apitap');
|
|
295
|
+
const socketPath = path.join(bridgeDir, 'bridge.sock');
|
|
296
|
+
|
|
297
|
+
// Pending CLI requests waiting for extension responses
|
|
298
|
+
const pendingRequests = new Map<string, {
|
|
299
|
+
resolve: (value: any) => void;
|
|
300
|
+
timer: ReturnType<typeof setTimeout>;
|
|
301
|
+
}>();
|
|
302
|
+
let requestCounter = 0;
|
|
303
|
+
|
|
304
|
+
// Send a message to the extension via stdout and wait for response
|
|
305
|
+
function sendToExtension(message: any): Promise<any> {
|
|
306
|
+
return new Promise((resolve) => {
|
|
307
|
+
const id = String(++requestCounter);
|
|
308
|
+
const timer = setTimeout(() => {
|
|
309
|
+
pendingRequests.delete(id);
|
|
310
|
+
resolve({ success: false, error: 'approval_timeout' });
|
|
311
|
+
}, 60_000);
|
|
312
|
+
|
|
313
|
+
pendingRequests.set(id, { resolve, timer });
|
|
314
|
+
|
|
315
|
+
// Tag message with ID so we can match the response
|
|
316
|
+
sendMessage({ ...message, _relayId: id } as any);
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const handler = createRelayHandler(sendToExtension);
|
|
321
|
+
|
|
322
|
+
(async () => {
|
|
323
|
+
// Ensure bridge directory exists
|
|
324
|
+
await fs.mkdir(bridgeDir, { recursive: true });
|
|
325
|
+
|
|
326
|
+
// Start socket server for CLI connections
|
|
327
|
+
await startSocketServer(socketPath, handler);
|
|
328
|
+
|
|
329
|
+
// Read messages from extension via stdin
|
|
330
|
+
while (true) {
|
|
331
|
+
const message = await readMessage();
|
|
332
|
+
if (!message) break;
|
|
333
|
+
|
|
334
|
+
// Check if this is a response to a relayed request
|
|
335
|
+
const relayId = (message as any)._relayId;
|
|
336
|
+
if (relayId && pendingRequests.has(relayId)) {
|
|
337
|
+
const pending = pendingRequests.get(relayId)!;
|
|
338
|
+
clearTimeout(pending.timer);
|
|
339
|
+
pendingRequests.delete(relayId);
|
|
340
|
+
const { _relayId, ...response } = message as any;
|
|
341
|
+
pending.resolve(response);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Otherwise, handle as a direct extension message (save_skill, etc.)
|
|
346
|
+
const response = await handleNativeMessage(message);
|
|
347
|
+
sendMessage(response);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Extension disconnected — clean up
|
|
351
|
+
await stopSocketServer();
|
|
352
|
+
try { await fs.unlink(socketPath); } catch { /* already gone */ }
|
|
353
|
+
})();
|
|
354
|
+
}
|
|
@@ -3,6 +3,7 @@ import { readSkillFile } from '../skill/store.js';
|
|
|
3
3
|
import { replayEndpoint } from '../replay/engine.js';
|
|
4
4
|
import { SessionCache } from './cache.js';
|
|
5
5
|
import { read } from '../read/index.js';
|
|
6
|
+
import { bridgeAvailable, requestBridgeCapture, DEFAULT_SOCKET } from '../bridge/client.js';
|
|
6
7
|
|
|
7
8
|
export interface BrowseOptions {
|
|
8
9
|
skillsDir?: string;
|
|
@@ -13,6 +14,10 @@ export interface BrowseOptions {
|
|
|
13
14
|
maxBytes?: number;
|
|
14
15
|
/** @internal Skip SSRF check — for testing only */
|
|
15
16
|
_skipSsrfCheck?: boolean;
|
|
17
|
+
/** @internal Override bridge socket path — for testing only */
|
|
18
|
+
_bridgeSocketPath?: string;
|
|
19
|
+
/** @internal Override bridge timeout — for testing only */
|
|
20
|
+
_bridgeTimeout?: number;
|
|
16
21
|
}
|
|
17
22
|
|
|
18
23
|
export interface BrowseSuccess {
|
|
@@ -22,7 +27,7 @@ export interface BrowseSuccess {
|
|
|
22
27
|
domain: string;
|
|
23
28
|
endpointId: string;
|
|
24
29
|
tier: string;
|
|
25
|
-
skillSource: 'disk' | 'discovered' | 'captured';
|
|
30
|
+
skillSource: 'disk' | 'discovered' | 'captured' | 'bridge';
|
|
26
31
|
capturedAt: string;
|
|
27
32
|
task?: string;
|
|
28
33
|
truncated?: boolean;
|
|
@@ -40,6 +45,106 @@ export interface BrowseGuidance {
|
|
|
40
45
|
|
|
41
46
|
export type BrowseResult = BrowseSuccess | BrowseGuidance;
|
|
42
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Try escalating to the Chrome extension bridge for authenticated capture.
|
|
50
|
+
* Returns a BrowseResult if the bridge handled it, or null to fall through.
|
|
51
|
+
*/
|
|
52
|
+
async function tryBridgeCapture(
|
|
53
|
+
domain: string,
|
|
54
|
+
fullUrl: string,
|
|
55
|
+
options: BrowseOptions,
|
|
56
|
+
): Promise<BrowseResult | null> {
|
|
57
|
+
const socketPath = options._bridgeSocketPath ?? DEFAULT_SOCKET;
|
|
58
|
+
if (!await bridgeAvailable(socketPath)) return null;
|
|
59
|
+
|
|
60
|
+
const result = await requestBridgeCapture(domain, socketPath, { timeout: options._bridgeTimeout });
|
|
61
|
+
|
|
62
|
+
if (result.success && result.skillFiles && result.skillFiles.length > 0) {
|
|
63
|
+
const skillFiles = result.skillFiles;
|
|
64
|
+
// Save each skill file to disk
|
|
65
|
+
try {
|
|
66
|
+
const { writeSkillFile: writeSF } = await import('../skill/store.js');
|
|
67
|
+
for (const skill of skillFiles) {
|
|
68
|
+
await writeSF(skill, options.skillsDir);
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// Saving failed — still have the data in memory
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Find the skill file matching the requested domain
|
|
75
|
+
const primarySkill = skillFiles.find((s: any) => s.domain === domain)
|
|
76
|
+
?? skillFiles[0];
|
|
77
|
+
|
|
78
|
+
if (primarySkill?.endpoints?.length > 0) {
|
|
79
|
+
// Pick the best endpoint and replay it
|
|
80
|
+
let urlPath = '/';
|
|
81
|
+
try { urlPath = new URL(fullUrl).pathname; } catch { /* use default */ }
|
|
82
|
+
const endpoint = pickEndpoint(primarySkill, urlPath);
|
|
83
|
+
|
|
84
|
+
if (endpoint) {
|
|
85
|
+
try {
|
|
86
|
+
const replayResult = await replayEndpoint(primarySkill, endpoint.id, {
|
|
87
|
+
maxBytes: options.maxBytes,
|
|
88
|
+
_skipSsrfCheck: options._skipSsrfCheck,
|
|
89
|
+
});
|
|
90
|
+
if (replayResult.status >= 200 && replayResult.status < 300) {
|
|
91
|
+
return {
|
|
92
|
+
success: true,
|
|
93
|
+
data: replayResult.data,
|
|
94
|
+
status: replayResult.status,
|
|
95
|
+
domain,
|
|
96
|
+
endpointId: endpoint.id,
|
|
97
|
+
tier: endpoint.replayability?.tier ?? 'unknown',
|
|
98
|
+
skillSource: 'bridge',
|
|
99
|
+
capturedAt: primarySkill.capturedAt ?? new Date().toISOString(),
|
|
100
|
+
task: options.task,
|
|
101
|
+
...(replayResult.truncated ? { truncated: true } : {}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// Replay failed — but skill file is saved for next time
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Skill file saved but replay didn't work
|
|
111
|
+
return {
|
|
112
|
+
success: false,
|
|
113
|
+
reason: 'bridge_capture_saved',
|
|
114
|
+
suggestion: `Captured ${skillFiles.length} skill file(s) from browser. Replay failed — try 'apitap replay ${domain}'.`,
|
|
115
|
+
domain,
|
|
116
|
+
url: fullUrl,
|
|
117
|
+
task: options.task,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Bridge returned an error
|
|
122
|
+
if (result.error === 'user_denied') {
|
|
123
|
+
return {
|
|
124
|
+
success: false,
|
|
125
|
+
reason: 'user_denied',
|
|
126
|
+
suggestion: `User denied browser access to ${domain}. Use 'apitap auth request ${domain}' for manual login instead.`,
|
|
127
|
+
domain,
|
|
128
|
+
url: fullUrl,
|
|
129
|
+
task: options.task,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (result.error === 'approval_timeout') {
|
|
134
|
+
return {
|
|
135
|
+
success: false,
|
|
136
|
+
reason: 'approval_timeout',
|
|
137
|
+
suggestion: `User approval pending for ${domain}. Click Allow in the ApiTap extension and try again.`,
|
|
138
|
+
domain,
|
|
139
|
+
url: fullUrl,
|
|
140
|
+
task: options.task,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Other bridge errors — fall through to existing fallback
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
43
148
|
/**
|
|
44
149
|
* High-level browse: check cache → disk → discover → replay.
|
|
45
150
|
* Auto-escalates cheap steps. Returns guidance for expensive ones.
|
|
@@ -121,6 +226,10 @@ export async function browse(
|
|
|
121
226
|
} catch {
|
|
122
227
|
// Read failed — fall through to capture_needed
|
|
123
228
|
}
|
|
229
|
+
// Try extension bridge before giving up
|
|
230
|
+
const bridgeResult1 = await tryBridgeCapture(domain, fullUrl, options);
|
|
231
|
+
if (bridgeResult1) return bridgeResult1;
|
|
232
|
+
|
|
124
233
|
return {
|
|
125
234
|
success: false,
|
|
126
235
|
reason: 'no_replayable_endpoints',
|
|
@@ -158,6 +267,10 @@ export async function browse(
|
|
|
158
267
|
// Read failed — fall through to capture_needed
|
|
159
268
|
}
|
|
160
269
|
}
|
|
270
|
+
// Try extension bridge before giving up
|
|
271
|
+
const bridgeResult2 = await tryBridgeCapture(domain, fullUrl, options);
|
|
272
|
+
if (bridgeResult2) return bridgeResult2;
|
|
273
|
+
|
|
161
274
|
return {
|
|
162
275
|
success: false,
|
|
163
276
|
reason: 'no_skill_file',
|
|
@@ -171,6 +284,10 @@ export async function browse(
|
|
|
171
284
|
// Step 4: Pick best endpoint
|
|
172
285
|
const endpoint = pickEndpoint(skill, urlPath);
|
|
173
286
|
if (!endpoint) {
|
|
287
|
+
// Try extension bridge before giving up
|
|
288
|
+
const bridgeResult3 = await tryBridgeCapture(domain, fullUrl, options);
|
|
289
|
+
if (bridgeResult3) return bridgeResult3;
|
|
290
|
+
|
|
174
291
|
return {
|
|
175
292
|
success: false,
|
|
176
293
|
reason: 'no_replayable_endpoints',
|
|
@@ -213,6 +330,10 @@ export async function browse(
|
|
|
213
330
|
...(result.truncated ? { truncated: true } : {}),
|
|
214
331
|
};
|
|
215
332
|
} catch {
|
|
333
|
+
// Try extension bridge before giving up
|
|
334
|
+
const bridgeResult4 = await tryBridgeCapture(domain, fullUrl, options);
|
|
335
|
+
if (bridgeResult4) return bridgeResult4;
|
|
336
|
+
|
|
216
337
|
return {
|
|
217
338
|
success: false,
|
|
218
339
|
reason: 'replay_failed',
|