@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.
@@ -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',