iframer-toolkit 0.8.0 → 0.10.1
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/cli.js +8922 -4191
- package/dist/local-server.cjs +6776 -0
- package/dist/mcp-server.cjs +827 -201
- package/extension/README.md +134 -0
- package/extension/background.js +639 -0
- package/extension/manifest.json +32 -0
- package/extension/native-host.cjs +91 -0
- package/extension/popup.html +72 -0
- package/extension/popup.js +108 -0
- package/package.json +13 -5
package/dist/mcp-server.cjs
CHANGED
|
@@ -35,8 +35,8 @@ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
|
35
35
|
var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
36
36
|
|
|
37
37
|
// src/mcp/helpers.ts
|
|
38
|
-
var
|
|
39
|
-
var
|
|
38
|
+
var import_path5 = __toESM(require("path"));
|
|
39
|
+
var import_os3 = __toESM(require("os"));
|
|
40
40
|
|
|
41
41
|
// src/lib/logger.ts
|
|
42
42
|
var LEVELS = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
|
|
@@ -66,6 +66,7 @@ function createLogger(tag) {
|
|
|
66
66
|
// src/lib/auth/crypto.ts
|
|
67
67
|
var import_crypto = __toESM(require("crypto"));
|
|
68
68
|
var import_fs = __toESM(require("fs"));
|
|
69
|
+
var import_os2 = __toESM(require("os"));
|
|
69
70
|
var import_path2 = __toESM(require("path"));
|
|
70
71
|
|
|
71
72
|
// src/lib/paths.ts
|
|
@@ -79,42 +80,76 @@ function getDataDir() {
|
|
|
79
80
|
function getLocalToken() {
|
|
80
81
|
if (process.env.IFRAMER_SECRET)
|
|
81
82
|
return process.env.IFRAMER_SECRET;
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
import_fs.default.writeFileSync(file, secret, { mode: 384 });
|
|
93
|
-
return secret;
|
|
94
|
-
} catch {
|
|
95
|
-
return "iframer-local-default-token";
|
|
83
|
+
const candidates = [
|
|
84
|
+
import_path2.default.join(getDataDir(), "secret"),
|
|
85
|
+
import_path2.default.join(process.env.XDG_RUNTIME_DIR || import_os2.default.tmpdir(), "iframer-secret")
|
|
86
|
+
];
|
|
87
|
+
for (const file of candidates) {
|
|
88
|
+
try {
|
|
89
|
+
const existing = import_fs.default.readFileSync(file, "utf8").trim();
|
|
90
|
+
if (existing)
|
|
91
|
+
return existing;
|
|
92
|
+
} catch {}
|
|
96
93
|
}
|
|
94
|
+
for (const file of candidates) {
|
|
95
|
+
try {
|
|
96
|
+
import_fs.default.mkdirSync(import_path2.default.dirname(file), { recursive: true });
|
|
97
|
+
const secret = import_crypto.default.randomBytes(32).toString("hex");
|
|
98
|
+
import_fs.default.writeFileSync(file, secret, { mode: 384 });
|
|
99
|
+
return secret;
|
|
100
|
+
} catch {}
|
|
101
|
+
}
|
|
102
|
+
throw new Error("iframer: could not read or create a persistent encryption secret in any " + `writable location (${candidates.join(", ")}). Set IFRAMER_SECRET to a ` + "stable value shared between the MCP server and CLI (openssl rand -hex 32).");
|
|
97
103
|
}
|
|
98
104
|
|
|
99
105
|
// src/mcp/local-server.ts
|
|
100
106
|
var import_child_process = require("child_process");
|
|
101
107
|
var import_net = __toESM(require("net"));
|
|
108
|
+
var import_fs3 = __toESM(require("fs"));
|
|
109
|
+
var import_path4 = __toESM(require("path"));
|
|
110
|
+
|
|
111
|
+
// src/lib/browser/registry.ts
|
|
102
112
|
var import_fs2 = __toESM(require("fs"));
|
|
103
113
|
var import_path3 = __toESM(require("path"));
|
|
114
|
+
var log = createLogger("registry");
|
|
115
|
+
function serverInfoPath() {
|
|
116
|
+
return import_path3.default.join(getDataDir(), "server.json");
|
|
117
|
+
}
|
|
118
|
+
function isPidAlive(pid) {
|
|
119
|
+
if (!Number.isInteger(pid) || pid <= 1)
|
|
120
|
+
return false;
|
|
121
|
+
try {
|
|
122
|
+
process.kill(pid, 0);
|
|
123
|
+
return true;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function readServerInfo() {
|
|
129
|
+
try {
|
|
130
|
+
const info = JSON.parse(import_fs2.default.readFileSync(serverInfoPath(), "utf8"));
|
|
131
|
+
if (!Number.isInteger(info.pid) || !Number.isInteger(info.port))
|
|
132
|
+
return null;
|
|
133
|
+
return info;
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// src/mcp/local-server.ts
|
|
104
140
|
var __dirname = "/Users/eduardoverona/tools/iframer-toolkit/src/mcp";
|
|
105
141
|
var BASE_PORT = parseInt(process.env.IFRAMER_LOCAL_PORT || "3022", 10);
|
|
106
|
-
var PORT_SCAN_ATTEMPTS =
|
|
142
|
+
var PORT_SCAN_ATTEMPTS = 21;
|
|
107
143
|
var STARTUP_TIMEOUT_MS = 15000;
|
|
108
144
|
var HEALTH_POLL_MS = 300;
|
|
145
|
+
var SPAWN_LOCK_STALE_MS = 20000;
|
|
109
146
|
|
|
110
147
|
class LocalServerManager {
|
|
111
|
-
child = null;
|
|
112
148
|
startingPromise = null;
|
|
113
|
-
port = null;
|
|
114
149
|
baseUrl = "";
|
|
115
150
|
logPath;
|
|
116
151
|
constructor() {
|
|
117
|
-
this.logPath =
|
|
152
|
+
this.logPath = import_path4.default.join(getDataDir(), "local-server.log");
|
|
118
153
|
}
|
|
119
154
|
getBaseUrl() {
|
|
120
155
|
if (!this.baseUrl) {
|
|
@@ -123,113 +158,154 @@ class LocalServerManager {
|
|
|
123
158
|
return this.baseUrl;
|
|
124
159
|
}
|
|
125
160
|
async ensureRunning() {
|
|
126
|
-
if (this.child && !this.child.killed && await this.healthCheck())
|
|
127
|
-
return;
|
|
128
161
|
if (this.startingPromise)
|
|
129
162
|
return this.startingPromise;
|
|
130
|
-
|
|
163
|
+
if (await this.adoptExisting())
|
|
164
|
+
return;
|
|
165
|
+
this.startingPromise = this.startShared().finally(() => {
|
|
131
166
|
this.startingPromise = null;
|
|
132
167
|
});
|
|
133
168
|
return this.startingPromise;
|
|
134
169
|
}
|
|
135
|
-
async
|
|
136
|
-
|
|
137
|
-
|
|
170
|
+
async adoptExisting() {
|
|
171
|
+
const info = readServerInfo();
|
|
172
|
+
if (!info || !isPidAlive(info.pid))
|
|
173
|
+
return false;
|
|
174
|
+
const url = `http://127.0.0.1:${info.port}`;
|
|
175
|
+
if (await healthCheck(url)) {
|
|
176
|
+
this.baseUrl = url;
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
async startShared() {
|
|
138
182
|
const dataDir = getDataDir();
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
183
|
+
import_fs3.default.mkdirSync(dataDir, { recursive: true });
|
|
184
|
+
const lockDir = import_path4.default.join(dataDir, "server.spawn-lock");
|
|
185
|
+
let holdingLock = false;
|
|
186
|
+
try {
|
|
187
|
+
import_fs3.default.mkdirSync(lockDir);
|
|
188
|
+
holdingLock = true;
|
|
189
|
+
} catch {
|
|
190
|
+
const stale = (() => {
|
|
191
|
+
try {
|
|
192
|
+
return Date.now() - import_fs3.default.statSync(lockDir).mtimeMs > SPAWN_LOCK_STALE_MS;
|
|
193
|
+
} catch {
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
})();
|
|
197
|
+
if (stale) {
|
|
198
|
+
try {
|
|
199
|
+
import_fs3.default.rmdirSync(lockDir);
|
|
200
|
+
import_fs3.default.mkdirSync(lockDir);
|
|
201
|
+
holdingLock = true;
|
|
202
|
+
} catch {}
|
|
203
|
+
}
|
|
157
204
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if (await this.healthCheck())
|
|
205
|
+
try {
|
|
206
|
+
if (!holdingLock) {
|
|
207
|
+
const deadline2 = Date.now() + STARTUP_TIMEOUT_MS;
|
|
208
|
+
while (Date.now() < deadline2) {
|
|
209
|
+
if (await this.adoptExisting())
|
|
210
|
+
return;
|
|
211
|
+
await sleep(HEALTH_POLL_MS);
|
|
212
|
+
}
|
|
213
|
+
throw new Error("Timed out waiting for another session to start the shared iframer server.");
|
|
214
|
+
}
|
|
215
|
+
if (await this.adoptExisting())
|
|
170
216
|
return;
|
|
171
|
-
await
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
217
|
+
const port = await findFreePort(BASE_PORT, PORT_SCAN_ATTEMPTS);
|
|
218
|
+
const url = `http://127.0.0.1:${port}`;
|
|
219
|
+
const { command, args } = this.resolveRuntime();
|
|
220
|
+
const logFd = import_fs3.default.openSync(this.logPath, "a");
|
|
221
|
+
const env = {
|
|
222
|
+
...process.env,
|
|
223
|
+
PORT: String(port),
|
|
224
|
+
IFRAMER_MODE: "local",
|
|
225
|
+
IFRAMER_DATA_DIR: dataDir
|
|
226
|
+
};
|
|
227
|
+
if (process.env.IFRAMER_SECRET) {
|
|
228
|
+
env.IFRAMER_SECRET = process.env.IFRAMER_SECRET;
|
|
229
|
+
} else {
|
|
230
|
+
try {
|
|
231
|
+
const secret = import_fs3.default.readFileSync(import_path4.default.join(dataDir, "secret"), "utf8").trim();
|
|
232
|
+
if (secret)
|
|
233
|
+
env.IFRAMER_SECRET = secret;
|
|
234
|
+
} catch {}
|
|
235
|
+
}
|
|
236
|
+
const child = import_child_process.spawn(command, args, {
|
|
237
|
+
env,
|
|
238
|
+
stdio: ["ignore", logFd, logFd],
|
|
239
|
+
detached: true
|
|
240
|
+
});
|
|
241
|
+
child.unref();
|
|
242
|
+
import_fs3.default.closeSync(logFd);
|
|
243
|
+
const deadline = Date.now() + STARTUP_TIMEOUT_MS;
|
|
244
|
+
while (Date.now() < deadline) {
|
|
245
|
+
if (await healthCheck(url)) {
|
|
246
|
+
this.baseUrl = url;
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
await sleep(HEALTH_POLL_MS);
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
child.kill("SIGKILL");
|
|
253
|
+
} catch {}
|
|
254
|
+
throw new Error(`Local iframer server failed to start on port ${port} within ${STARTUP_TIMEOUT_MS}ms.
|
|
176
255
|
` + `Last log lines:
|
|
177
|
-
${
|
|
256
|
+
${this.readLogTail()}`);
|
|
257
|
+
} finally {
|
|
258
|
+
if (holdingLock) {
|
|
259
|
+
try {
|
|
260
|
+
import_fs3.default.rmdirSync(lockDir);
|
|
261
|
+
} catch {}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
178
264
|
}
|
|
179
265
|
resolveRuntime() {
|
|
266
|
+
const serverTs = import_path4.default.join(__dirname, "..", "..", "index.ts");
|
|
267
|
+
const serverCjs = import_path4.default.join(__dirname, "..", "..", "dist", "local-server.cjs");
|
|
268
|
+
if (import_fs3.default.existsSync(serverCjs)) {
|
|
269
|
+
return { command: "node", args: [serverCjs] };
|
|
270
|
+
}
|
|
271
|
+
if (import_fs3.default.existsSync(serverTs)) {
|
|
272
|
+
try {
|
|
273
|
+
require.resolve("/Users/eduardoverona/tools/iframer-toolkit/node_modules/tsx/dist/loader.mjs");
|
|
274
|
+
return { command: "node", args: ["--import", "tsx", serverTs] };
|
|
275
|
+
} catch {}
|
|
276
|
+
}
|
|
180
277
|
try {
|
|
181
278
|
const bunPath = require("child_process").execSync("which bun", { encoding: "utf8" }).trim();
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
return { command: bunPath, args: ["run",
|
|
279
|
+
if (bunPath && import_fs3.default.existsSync(serverTs)) {
|
|
280
|
+
console.error("[iframer] running local server under bun — extension mode (connectOverCDP) will not work; run `bun run build` to get the node bundle");
|
|
281
|
+
return { command: bunPath, args: ["run", serverTs] };
|
|
185
282
|
}
|
|
186
283
|
} catch {}
|
|
187
|
-
|
|
188
|
-
if (import_fs2.default.existsSync(serverCjs)) {
|
|
189
|
-
return { command: "node", args: [serverCjs] };
|
|
190
|
-
}
|
|
191
|
-
const serverTs = import_path3.default.join(__dirname, "..", "..", "index.ts");
|
|
192
|
-
return { command: "node", args: ["--import", "tsx", serverTs] };
|
|
284
|
+
throw new Error("No runnable iframer server entry found: dist/local-server.cjs is missing, tsx is not " + "installed, and bun is unavailable. Run `bun install && bun run build` in the iframer repo.");
|
|
193
285
|
}
|
|
194
286
|
async restart() {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
await this.ensureRunning();
|
|
198
|
-
}
|
|
199
|
-
shutdown() {
|
|
200
|
-
this.kill();
|
|
201
|
-
}
|
|
202
|
-
kill() {
|
|
203
|
-
if (this.child && !this.child.killed) {
|
|
287
|
+
const info = readServerInfo();
|
|
288
|
+
if (info && this.baseUrl) {
|
|
204
289
|
try {
|
|
205
|
-
this.
|
|
290
|
+
await fetch(`${this.baseUrl}/shutdown`, { method: "POST", signal: AbortSignal.timeout(3000) });
|
|
206
291
|
} catch {}
|
|
207
|
-
const
|
|
208
|
-
|
|
292
|
+
const deadline = Date.now() + 12000;
|
|
293
|
+
while (Date.now() < deadline && isPidAlive(info.pid)) {
|
|
294
|
+
await sleep(200);
|
|
295
|
+
}
|
|
296
|
+
if (isPidAlive(info.pid)) {
|
|
209
297
|
try {
|
|
210
|
-
|
|
211
|
-
c.kill("SIGKILL");
|
|
298
|
+
process.kill(info.pid, "SIGKILL");
|
|
212
299
|
} catch {}
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
this.child = null;
|
|
216
|
-
}
|
|
217
|
-
async healthCheck() {
|
|
218
|
-
if (!this.baseUrl)
|
|
219
|
-
return false;
|
|
220
|
-
try {
|
|
221
|
-
const res = await fetch(`${this.baseUrl}/health`, {
|
|
222
|
-
signal: AbortSignal.timeout(2000)
|
|
223
|
-
});
|
|
224
|
-
const data = await res.json();
|
|
225
|
-
return data.ok === true;
|
|
226
|
-
} catch {
|
|
227
|
-
return false;
|
|
300
|
+
}
|
|
228
301
|
}
|
|
302
|
+
this.baseUrl = "";
|
|
303
|
+
await this.ensureRunning();
|
|
229
304
|
}
|
|
305
|
+
shutdown() {}
|
|
230
306
|
readLogTail() {
|
|
231
307
|
try {
|
|
232
|
-
const content =
|
|
308
|
+
const content = import_fs3.default.readFileSync(this.logPath, "utf8");
|
|
233
309
|
const lines = content.trim().split(`
|
|
234
310
|
`);
|
|
235
311
|
return lines.slice(-10).join(`
|
|
@@ -242,6 +318,15 @@ ${logTail}`);
|
|
|
242
318
|
function sleep(ms) {
|
|
243
319
|
return new Promise((r) => setTimeout(r, ms));
|
|
244
320
|
}
|
|
321
|
+
async function healthCheck(baseUrl) {
|
|
322
|
+
try {
|
|
323
|
+
const res = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(2000) });
|
|
324
|
+
const data = await res.json();
|
|
325
|
+
return data.ok === true;
|
|
326
|
+
} catch {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
245
330
|
function findFreePort(start, attempts) {
|
|
246
331
|
return new Promise((resolve, reject) => {
|
|
247
332
|
const tryPort = (p) => {
|
|
@@ -263,8 +348,13 @@ function findFreePort(start, attempts) {
|
|
|
263
348
|
});
|
|
264
349
|
}
|
|
265
350
|
|
|
351
|
+
// src/lib/errors.ts
|
|
352
|
+
function getErrorMessage(err) {
|
|
353
|
+
return err instanceof Error ? err.message : String(err);
|
|
354
|
+
}
|
|
355
|
+
|
|
266
356
|
// src/mcp/helpers.ts
|
|
267
|
-
var
|
|
357
|
+
var log2 = createLogger("mcp");
|
|
268
358
|
var BASE_URL = process.env.IFRAMER_URL || "http://localhost:3021";
|
|
269
359
|
var IFRAMER_SECRET = process.env.IFRAMER_SECRET;
|
|
270
360
|
var IFRAMER_MODE = process.env.IFRAMER_MODE;
|
|
@@ -290,7 +380,7 @@ async function apiPost(endpoint, body) {
|
|
|
290
380
|
return res.json();
|
|
291
381
|
}
|
|
292
382
|
async function apiGet(endpoint) {
|
|
293
|
-
const res = await fetch(`${BASE_URL}${endpoint}`, { headers: authHeaders() });
|
|
383
|
+
const res = await fetch(`${BASE_URL}${endpoint}`, { headers: authHeaders(), signal: AbortSignal.timeout(30000) });
|
|
294
384
|
return res.json();
|
|
295
385
|
}
|
|
296
386
|
async function localApiPost(endpoint, body) {
|
|
@@ -307,13 +397,13 @@ async function localApiPost(endpoint, body) {
|
|
|
307
397
|
async function localApiGet(endpoint) {
|
|
308
398
|
await ensureLocalServer();
|
|
309
399
|
const url = localServer.getBaseUrl();
|
|
310
|
-
const res = await fetch(`${url}${endpoint}`, { headers: authHeaders(LOCAL_TOKEN) });
|
|
400
|
+
const res = await fetch(`${url}${endpoint}`, { headers: authHeaders(LOCAL_TOKEN), signal: AbortSignal.timeout(30000) });
|
|
311
401
|
return res.json();
|
|
312
402
|
}
|
|
313
403
|
async function localApiDelete(endpoint) {
|
|
314
404
|
await ensureLocalServer();
|
|
315
405
|
const url = localServer.getBaseUrl();
|
|
316
|
-
const res = await fetch(`${url}${endpoint}`, { method: "DELETE", headers: authHeaders(LOCAL_TOKEN) });
|
|
406
|
+
const res = await fetch(`${url}${endpoint}`, { method: "DELETE", headers: authHeaders(LOCAL_TOKEN), signal: AbortSignal.timeout(30000) });
|
|
317
407
|
return res.json();
|
|
318
408
|
}
|
|
319
409
|
async function isDockerRunning() {
|
|
@@ -330,8 +420,8 @@ async function resolveScreenshotPath(url) {
|
|
|
330
420
|
if (url.startsWith("file://")) {
|
|
331
421
|
const { fileURLToPath } = await import("url");
|
|
332
422
|
const filePath2 = fileURLToPath(url);
|
|
333
|
-
const
|
|
334
|
-
if (
|
|
423
|
+
const fs5 = await import("fs");
|
|
424
|
+
if (fs5.existsSync(filePath2))
|
|
335
425
|
return filePath2;
|
|
336
426
|
return null;
|
|
337
427
|
}
|
|
@@ -339,11 +429,11 @@ async function resolveScreenshotPath(url) {
|
|
|
339
429
|
if (!res.ok)
|
|
340
430
|
return null;
|
|
341
431
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
342
|
-
const
|
|
343
|
-
const dir =
|
|
344
|
-
|
|
345
|
-
const filePath =
|
|
346
|
-
|
|
432
|
+
const fs4 = await import("fs");
|
|
433
|
+
const dir = import_path5.default.join(import_os3.default.tmpdir(), "iframer-screenshots", "screenshots");
|
|
434
|
+
fs4.mkdirSync(dir, { recursive: true });
|
|
435
|
+
const filePath = import_path5.default.join(dir, `docker-${Date.now()}.jpg`);
|
|
436
|
+
fs4.writeFileSync(filePath, buf);
|
|
347
437
|
return filePath;
|
|
348
438
|
} catch {
|
|
349
439
|
return null;
|
|
@@ -352,16 +442,13 @@ async function resolveScreenshotPath(url) {
|
|
|
352
442
|
function err(message) {
|
|
353
443
|
return { content: [{ type: "text", text: message }], isError: true };
|
|
354
444
|
}
|
|
355
|
-
function getErrorMessage(err2) {
|
|
356
|
-
return err2 instanceof Error ? err2.message : String(err2);
|
|
357
|
-
}
|
|
358
445
|
|
|
359
446
|
// src/lib/domain-modes.ts
|
|
360
|
-
var
|
|
361
|
-
var
|
|
362
|
-
var
|
|
447
|
+
var import_fs4 = __toESM(require("fs"));
|
|
448
|
+
var import_path6 = __toESM(require("path"));
|
|
449
|
+
var log3 = createLogger("domain-modes");
|
|
363
450
|
function defaultFile() {
|
|
364
|
-
return
|
|
451
|
+
return import_path6.default.join(getDataDir(), "domain-modes.json");
|
|
365
452
|
}
|
|
366
453
|
var TTL_DAYS = 14;
|
|
367
454
|
var ESCALATION_LADDER = ["headless", "docker-headful", "binary-headful"];
|
|
@@ -442,8 +529,8 @@ class DomainModeStore {
|
|
|
442
529
|
}
|
|
443
530
|
load() {
|
|
444
531
|
try {
|
|
445
|
-
if (
|
|
446
|
-
this.data = JSON.parse(
|
|
532
|
+
if (import_fs4.default.existsSync(this.filePath)) {
|
|
533
|
+
this.data = JSON.parse(import_fs4.default.readFileSync(this.filePath, "utf-8"));
|
|
447
534
|
}
|
|
448
535
|
} catch {
|
|
449
536
|
this.data = {};
|
|
@@ -451,10 +538,10 @@ class DomainModeStore {
|
|
|
451
538
|
}
|
|
452
539
|
save() {
|
|
453
540
|
try {
|
|
454
|
-
|
|
455
|
-
|
|
541
|
+
import_fs4.default.mkdirSync(import_path6.default.dirname(this.filePath), { recursive: true });
|
|
542
|
+
import_fs4.default.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
|
|
456
543
|
} catch (err2) {
|
|
457
|
-
|
|
544
|
+
log3.error("Failed to save:", err2);
|
|
458
545
|
}
|
|
459
546
|
}
|
|
460
547
|
}
|
|
@@ -542,7 +629,7 @@ var import_zod2 = require("zod");
|
|
|
542
629
|
var stepSchema = import_zod2.z.discriminatedUnion("type", [
|
|
543
630
|
import_zod2.z.object({ type: import_zod2.z.literal("navigate"), url: import_zod2.z.string(), waitUntil: import_zod2.z.string().optional() }),
|
|
544
631
|
import_zod2.z.object({ type: import_zod2.z.literal("click"), selector: import_zod2.z.string() }),
|
|
545
|
-
import_zod2.z.object({ type: import_zod2.z.literal("fill"), selector: import_zod2.z.string(), value: import_zod2.z.string() }),
|
|
632
|
+
import_zod2.z.object({ type: import_zod2.z.literal("fill"), selector: import_zod2.z.string(), value: import_zod2.z.string().describe("Sets an input/textarea's value. Framework-aware: fires the React-safe native setter + input/change/blur, so controlled forms (React, react-hook-form, Formik, Vue) register the value AND mark the field touched. This is the fix for 'I filled the field but submit says it's still empty' — always use fill for form fields, not evaluate.") }),
|
|
546
633
|
import_zod2.z.object({ type: import_zod2.z.literal("human-click"), selector: import_zod2.z.string().optional(), x: import_zod2.z.number().optional(), y: import_zod2.z.number().optional() }),
|
|
547
634
|
import_zod2.z.object({ type: import_zod2.z.literal("right-click"), selector: import_zod2.z.string().optional(), x: import_zod2.z.number().optional(), y: import_zod2.z.number().optional() }),
|
|
548
635
|
import_zod2.z.object({ type: import_zod2.z.literal("human-type"), selector: import_zod2.z.string(), value: import_zod2.z.string() }),
|
|
@@ -550,8 +637,12 @@ var stepSchema = import_zod2.z.discriminatedUnion("type", [
|
|
|
550
637
|
import_zod2.z.object({ type: import_zod2.z.literal("extract"), expression: import_zod2.z.string() }),
|
|
551
638
|
import_zod2.z.object({ type: import_zod2.z.literal("wait"), ms: import_zod2.z.number() }),
|
|
552
639
|
import_zod2.z.object({ type: import_zod2.z.literal("wait-for"), selector: import_zod2.z.string(), timeout: import_zod2.z.number().optional() }),
|
|
553
|
-
import_zod2.z.object({ type: import_zod2.z.literal("scroll"), deltaY: import_zod2.z.number().optional() }),
|
|
554
|
-
import_zod2.z.object({ type: import_zod2.z.literal("keyboard"), key: import_zod2.z.string() }),
|
|
640
|
+
import_zod2.z.object({ type: import_zod2.z.literal("scroll"), deltaY: import_zod2.z.number().optional(), selector: import_zod2.z.string().optional().describe("Scroll within this element instead of the window") }),
|
|
641
|
+
import_zod2.z.object({ type: import_zod2.z.literal("keyboard"), key: import_zod2.z.string(), meta: import_zod2.z.boolean().optional(), ctrl: import_zod2.z.boolean().optional(), shift: import_zod2.z.boolean().optional(), alt: import_zod2.z.boolean().optional() }),
|
|
642
|
+
import_zod2.z.object({ type: import_zod2.z.literal("read"), selector: import_zod2.z.string().optional().describe("Element to read visible text from (CSS or @e ref). Omit for the whole page body."), maxChars: import_zod2.z.number().optional().describe("Cap the returned text length (default 6000). Raise it only when you actually need more — larger reads cost proportionally more context.") }),
|
|
643
|
+
import_zod2.z.object({ type: import_zod2.z.literal("upload"), selector: import_zod2.z.string().describe("The <input type=file> to set (CSS, @e ref, or @a anchor)."), files: import_zod2.z.array(import_zod2.z.string()).describe("Absolute local file path(s) to upload. Paths must exist on this machine (same machine as the browser).") }),
|
|
644
|
+
import_zod2.z.object({ type: import_zod2.z.literal("paste"), selector: import_zod2.z.string().optional().describe("Field to paste the OS clipboard into (CSS, @e ref, or @a anchor). Omit to insert at the currently focused element. Reliable — inserts via CDP Input.insertText, unlike a ⌘V keyboard step (the extension relay ignores modifier keys).") }),
|
|
645
|
+
import_zod2.z.object({ type: import_zod2.z.literal("download"), url: import_zod2.z.string().describe("URL of the file to download. Fetched through the browser's session (cookies included, so auth'd downloads work) and written to disk server-side — no Save-As dialog."), path: import_zod2.z.string().optional().describe("Absolute local path to save to. Omit to save into ~/.iframer/downloads/ with the URL's filename. Read the returned path to open the file.") }),
|
|
555
646
|
import_zod2.z.object({ type: import_zod2.z.literal("type-code"), value: import_zod2.z.string(), selector: import_zod2.z.string().optional() }),
|
|
556
647
|
import_zod2.z.object({ type: import_zod2.z.literal("login"), domain: import_zod2.z.string(), usernameSelector: import_zod2.z.string().optional(), passwordSelector: import_zod2.z.string().optional(), submitSelector: import_zod2.z.string().optional(), totpSelector: import_zod2.z.string().optional() }),
|
|
557
648
|
import_zod2.z.object({ type: import_zod2.z.literal("solve-captcha") }),
|
|
@@ -567,6 +658,9 @@ var stepSchema = import_zod2.z.discriminatedUnion("type", [
|
|
|
567
658
|
]);
|
|
568
659
|
|
|
569
660
|
// src/mcp/tools/execute.ts
|
|
661
|
+
function resultOf(r, _type) {
|
|
662
|
+
return r.result;
|
|
663
|
+
}
|
|
570
664
|
function registerExecuteTool(server) {
|
|
571
665
|
server.tool("execute", `Execute a pipeline of browser steps. Auto-starts a session if needed. Handles obstacles (captcha, cookie banners) automatically.
|
|
572
666
|
|
|
@@ -585,6 +679,10 @@ Key step types:
|
|
|
585
679
|
|
|
586
680
|
IMPORTANT — Element refs (@e1, @e2...): All selector fields accept @e refs from snapshot, find, or annotated screenshot. PREFER refs over CSS selectors.
|
|
587
681
|
|
|
682
|
+
IMPORTANT — Saved anchors (@a:<name>): Selector fields also accept persisted, per-domain anchors from the \`remember\` tool. Call \`remember get <domain>\` before a UI task; if an anchor exists, target it directly (e.g. selector="@a:composer") — no snapshot needed. After finding a new element that works, \`remember save\` it. Unlike @e refs (which reset every snapshot), @a: anchors persist across runs.
|
|
683
|
+
|
|
684
|
+
FILLING FORMS: Always use the \`fill\` step for text inputs/textareas — NOT \`evaluate\` to set .value. \`fill\` is framework-aware: it fires the React-safe native setter plus input/change/blur, so controlled forms (React, react-hook-form, Formik, Vue) actually register the value and mark the field "touched". This prevents the common "I filled every field but submit still says they're required/empty" failure — which is a form-framework state issue, not a real empty field. If a submit is still rejected as incomplete after filling, re-run \`fill\` on the flagged field (it re-triggers the blur/validation) rather than assuming the value didn't land.
|
|
685
|
+
|
|
588
686
|
Returns: ok, completedSteps, results, obstacles, capturedApi, and on failure: errorContext with screenshot path, URL, errorType, suggestion, retryable.`, {
|
|
589
687
|
steps: import_zod3.z.array(stepSchema).describe("Pipeline steps to execute sequentially"),
|
|
590
688
|
options: import_zod3.z.object({
|
|
@@ -593,12 +691,33 @@ Returns: ok, completedSteps, results, obstacles, capturedApi, and on failure: er
|
|
|
593
691
|
continueOnObstacle: import_zod3.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
|
|
594
692
|
continueOnError: import_zod3.z.boolean().optional().describe("Continue past failing steps (default: false)"),
|
|
595
693
|
captureApi: import_zod3.z.boolean().optional().describe("Record all API calls (XHR/fetch) the page makes."),
|
|
596
|
-
mode: import_zod3.z.enum(["headless", "binary-headful", "docker-headful"]).optional().describe("DO NOT SET THIS unless user explicitly requests a mode. iframer auto-selects and auto-escalates."),
|
|
694
|
+
mode: import_zod3.z.enum(["headless", "binary-headful", "docker-headful", "extension"]).optional().describe("DO NOT SET THIS unless user explicitly requests a mode. iframer auto-selects and auto-escalates. Use 'extension' ONLY to drive a tab in the user's real Chrome via the iframer extension — requires options.tabId (get it from the `tabs` tool)."),
|
|
597
695
|
autoEscalate: import_zod3.z.boolean().optional().describe("Auto-retry with a stronger mode if blocked (default: true)"),
|
|
598
|
-
instanceId: import_zod3.z.string().optional().describe("Run in a named parallel browser within this session (default: 'default'). Use distinct ids to drive several browsers at once, e.g. one per account — each keeps its own login/session state.")
|
|
696
|
+
instanceId: import_zod3.z.string().optional().describe("Run in a named parallel browser within this session (default: 'default'). Use distinct ids to drive several browsers at once, e.g. one per account — each keeps its own login/session state."),
|
|
697
|
+
tabId: import_zod3.z.number().optional().describe("Only with mode='extension': the id of the real Chrome tab to drive (from the `tabs` tool). Input is trusted OS-level (chrome.debugger); Chrome shows its 'is being debugged' bar while the run is in progress."),
|
|
698
|
+
clientId: import_zod3.z.string().optional().describe("Only with mode='extension', and only needed when several profiles/browsers are connected and a tab is ambiguous: the clientId of the profile that owns the tab (from the `tabs` tool)."),
|
|
699
|
+
focus: import_zod3.z.boolean().optional().describe("Only with mode='extension': bring the tab's window to the OS foreground while driving. Default false — the tab is driven in the background (activated in its window, focus-emulated) without interrupting the user. Set true only if a site ignores background input.")
|
|
599
700
|
}).optional()
|
|
600
701
|
}, async (params) => {
|
|
601
702
|
try {
|
|
703
|
+
if (params.options?.mode === "extension") {
|
|
704
|
+
const tabId = params.options?.tabId;
|
|
705
|
+
if (typeof tabId !== "number") {
|
|
706
|
+
return err("mode='extension' requires options.tabId. Call the `tabs` tool first to " + "find the id of the tab the user wants to drive.");
|
|
707
|
+
}
|
|
708
|
+
const extResult = await localApiPost("/extension/execute", {
|
|
709
|
+
tabId,
|
|
710
|
+
clientId: params.options?.clientId,
|
|
711
|
+
steps: params.steps,
|
|
712
|
+
options: params.options
|
|
713
|
+
});
|
|
714
|
+
const extLines = formatExecuteResult(extResult);
|
|
715
|
+
const content2 = [{ type: "text", text: extLines.join(`
|
|
716
|
+
`) }];
|
|
717
|
+
if (!extResult.ok)
|
|
718
|
+
return { content: content2, isError: true };
|
|
719
|
+
return { content: content2 };
|
|
720
|
+
}
|
|
602
721
|
const dockerRunning = await isDockerRunning();
|
|
603
722
|
async function runWithMode(mode) {
|
|
604
723
|
if (mode === "docker-headful") {
|
|
@@ -638,9 +757,9 @@ Returns: ok, completedSteps, results, obstacles, capturedApi, and on failure: er
|
|
|
638
757
|
execResult = await runWithMode(params.options?.mode);
|
|
639
758
|
} catch (execErr) {
|
|
640
759
|
const msg = execErr instanceof Error ? execErr.message : String(execErr);
|
|
641
|
-
const isCrash = /
|
|
760
|
+
const isCrash = /ECONNREFUSED|ECONNRESET|EPIPE|socket hang up|fetch failed/i.test(msg);
|
|
642
761
|
if (isCrash) {
|
|
643
|
-
|
|
762
|
+
log2.info(`Execute crashed (${msg.slice(0, 80)}), restarting local server and retrying...`);
|
|
644
763
|
try {
|
|
645
764
|
await localServer.restart();
|
|
646
765
|
} catch {}
|
|
@@ -663,7 +782,7 @@ Retry: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}
|
|
|
663
782
|
for (const nextMode of escalation) {
|
|
664
783
|
if (nextMode === "docker-headful" && !dockerRunning)
|
|
665
784
|
continue;
|
|
666
|
-
|
|
785
|
+
log2.info(`Auto-escalating to ${nextMode}`);
|
|
667
786
|
execResult = await runWithMode(nextMode);
|
|
668
787
|
if (execResult.ok)
|
|
669
788
|
break;
|
|
@@ -716,22 +835,44 @@ Final page: ${data.finalState.title}`);
|
|
|
716
835
|
Knowledge cache updated for ${domain}. Before the next browser run on this domain, call \`knowledge get ${domain}\` — you may be able to skip the browser entirely.`);
|
|
717
836
|
} catch {}
|
|
718
837
|
}
|
|
838
|
+
for (const r of data.results || []) {
|
|
839
|
+
if (r.tabSwitchedTo) {
|
|
840
|
+
lines.push(`
|
|
841
|
+
↳ step ${r.stepIndex} opened a new tab — pipeline is now on: ${r.tabSwitchedTo}`);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
719
844
|
const meaningful = (data.results || []).filter((r) => r.ok && r.result !== undefined && r.result !== null);
|
|
720
845
|
for (const r of meaningful) {
|
|
721
|
-
if (r.step
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
} else if (r.step
|
|
729
|
-
|
|
846
|
+
if (r.step.type === "snapshot") {
|
|
847
|
+
const res = resultOf(r, "snapshot");
|
|
848
|
+
if (res?.snapshot) {
|
|
849
|
+
lines.push(`
|
|
850
|
+
--- Snapshot (${res.elementCount} elements) ---`);
|
|
851
|
+
lines.push(res.snapshot);
|
|
852
|
+
}
|
|
853
|
+
} else if (r.step.type === "find") {
|
|
854
|
+
const res = resultOf(r, "find");
|
|
855
|
+
if (res?.ref) {
|
|
856
|
+
lines.push(`
|
|
857
|
+
Found: ${res.ref} ${res.role} "${res.name}" (${res.matchCount} match${res.matchCount > 1 ? "es" : ""})`);
|
|
858
|
+
}
|
|
859
|
+
} else if (r.step.type === "screenshot") {
|
|
860
|
+
const res = resultOf(r, "screenshot");
|
|
861
|
+
if (res?.refs) {
|
|
862
|
+
lines.push(`
|
|
730
863
|
--- Annotated screenshot refs ---`);
|
|
731
|
-
|
|
732
|
-
|
|
864
|
+
lines.push(res.refs);
|
|
865
|
+
}
|
|
866
|
+
} else if (r.step.type === "read") {
|
|
867
|
+
const res = r.result;
|
|
868
|
+
if (res?.text !== undefined) {
|
|
869
|
+
lines.push(`
|
|
870
|
+
--- Read (step ${r.stepIndex}${res.truncated ? ", truncated" : ""}) ---`);
|
|
871
|
+
lines.push(res.text);
|
|
872
|
+
}
|
|
873
|
+
} else if (r.step.type === "extract" || r.step.type === "evaluate") {
|
|
733
874
|
lines.push(`
|
|
734
|
-
step ${r.stepIndex}: ${JSON.stringify(r.result)}`);
|
|
875
|
+
step ${r.stepIndex} (${r.step.type}): ${JSON.stringify(r.result)}`);
|
|
735
876
|
}
|
|
736
877
|
}
|
|
737
878
|
if (data.obstacles && data.obstacles.length > 0) {
|
|
@@ -774,8 +915,8 @@ ${api.domain} (${api.baseUrl})`);
|
|
|
774
915
|
|
|
775
916
|
// src/mcp/tools/session.ts
|
|
776
917
|
var import_zod5 = require("zod");
|
|
777
|
-
var
|
|
778
|
-
var
|
|
918
|
+
var import_path7 = __toESM(require("path"));
|
|
919
|
+
var import_fs5 = __toESM(require("fs"));
|
|
779
920
|
|
|
780
921
|
// src/mcp/tools/reverse-engineer.ts
|
|
781
922
|
var import_zod4 = require("zod");
|
|
@@ -830,7 +971,9 @@ The outputDir defaults to ./<domain>/. Ask the user where to save if unclear.`,
|
|
|
830
971
|
staleTimeoutMs: import_zod4.z.number().optional().describe("Override the 20s stale-state timeout per step"),
|
|
831
972
|
continueOnObstacle: import_zod4.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
|
|
832
973
|
continueOnError: import_zod4.z.boolean().optional().describe("Continue past failing steps (default: false)"),
|
|
833
|
-
mode: import_zod4.z.enum(["headless", "binary-headful", "docker-headful"]).optional().describe("Browser mode override")
|
|
974
|
+
mode: import_zod4.z.enum(["headless", "binary-headful", "docker-headful", "extension"]).optional().describe("Browser mode override. Use 'extension' to capture the API of a tab already open in the user's real Chrome — requires options.tabId from the `tabs` tool. Chrome shows its 'is being debugged' bar while the capture runs."),
|
|
975
|
+
tabId: import_zod4.z.number().optional().describe("Only with mode='extension': the real Chrome tab to reverse-engineer (from the `tabs` tool)."),
|
|
976
|
+
clientId: import_zod4.z.string().optional().describe("Only with mode='extension', when multiple profiles are connected and the tab is ambiguous: the owning profile's clientId (from the `tabs` tool).")
|
|
834
977
|
}).optional()
|
|
835
978
|
}, async (params) => {
|
|
836
979
|
try {
|
|
@@ -840,7 +983,22 @@ The outputDir defaults to ./<domain>/. Ask the user where to save if unclear.`,
|
|
|
840
983
|
};
|
|
841
984
|
const mode = params.options?.mode;
|
|
842
985
|
const dockerRunning = await isDockerRunning();
|
|
843
|
-
|
|
986
|
+
let captureResult;
|
|
987
|
+
if (mode === "extension") {
|
|
988
|
+
if (typeof params.options?.tabId !== "number") {
|
|
989
|
+
return err("mode='extension' requires options.tabId. Call the `tabs` tool first to find the tab to reverse-engineer.");
|
|
990
|
+
}
|
|
991
|
+
captureResult = await localApiPost("/extension/execute", {
|
|
992
|
+
tabId: params.options.tabId,
|
|
993
|
+
clientId: params.options.clientId,
|
|
994
|
+
steps: params.steps,
|
|
995
|
+
options: { ...params.options, captureApi: true }
|
|
996
|
+
});
|
|
997
|
+
} else if (mode === "docker-headful" && dockerRunning) {
|
|
998
|
+
captureResult = await apiPost("/execute", execParams);
|
|
999
|
+
} else {
|
|
1000
|
+
captureResult = await localApiPost("/execute", execParams);
|
|
1001
|
+
}
|
|
844
1002
|
const lines = [];
|
|
845
1003
|
lines.push(`ok: ${captureResult.ok}`);
|
|
846
1004
|
lines.push(`steps: ${captureResult.completedSteps}/${captureResult.totalSteps}`);
|
|
@@ -883,11 +1041,11 @@ Screenshot saved: ${filePath}`);
|
|
|
883
1041
|
const mainDomain = captureResult.capturedApi[0]?.domain || "api";
|
|
884
1042
|
const outDir = params.outputDir || `./${mainDomain}`;
|
|
885
1043
|
try {
|
|
886
|
-
const
|
|
887
|
-
const
|
|
888
|
-
|
|
889
|
-
const jsonPath =
|
|
890
|
-
|
|
1044
|
+
const fs5 = await import("fs");
|
|
1045
|
+
const path7 = await import("path");
|
|
1046
|
+
fs5.mkdirSync(outDir, { recursive: true });
|
|
1047
|
+
const jsonPath = path7.join(outDir, "captured-api.json");
|
|
1048
|
+
fs5.writeFileSync(jsonPath, JSON.stringify(captureResult.capturedApi, null, 2));
|
|
891
1049
|
lines.push(`
|
|
892
1050
|
Full captured data saved to: ${jsonPath}`);
|
|
893
1051
|
lines.push("Read this file for complete curl commands, request/response bodies, and auth data.");
|
|
@@ -898,10 +1056,10 @@ Full captured data saved to: ${jsonPath}`);
|
|
|
898
1056
|
}
|
|
899
1057
|
let text = lines.join(`
|
|
900
1058
|
`);
|
|
901
|
-
if (text.length >
|
|
902
|
-
text = text.slice(0,
|
|
1059
|
+
if (text.length > 30000) {
|
|
1060
|
+
text = text.slice(0, 30000) + `
|
|
903
1061
|
|
|
904
|
-
[...
|
|
1062
|
+
[... index truncated — read captured-api.json for the full endpoint list and all detail]`;
|
|
905
1063
|
}
|
|
906
1064
|
const content = [{ type: "text", text }];
|
|
907
1065
|
if (!captureResult.ok)
|
|
@@ -941,18 +1099,11 @@ Auth:`);
|
|
|
941
1099
|
}
|
|
942
1100
|
const protocolSummary = Array.from(byProtocol.entries()).map(([p, eps]) => `${p}=${eps.length}`).join(", ");
|
|
943
1101
|
lines.push(`
|
|
944
|
-
Endpoints (${api.endpoints.length}) [${protocolSummary}]:`);
|
|
1102
|
+
Endpoints (${api.endpoints.length}) [${protocolSummary}] — full detail in captured-api.json:`);
|
|
945
1103
|
for (const ep of api.endpoints) {
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
lines.push(`
|
|
949
|
-
lines.push(` ${ep.method} ${ep.path} → ${ep.responseStatus}`);
|
|
950
|
-
if (ep.requestBody) {
|
|
951
|
-
const body = ep.requestBody;
|
|
952
|
-
const signalKeys = extractSignalKeys(body);
|
|
953
|
-
if (signalKeys)
|
|
954
|
-
lines.push(` Params: ${signalKeys}`);
|
|
955
|
-
}
|
|
1104
|
+
const params2 = ep.requestBody ? extractSignalKeys(ep.requestBody) : null;
|
|
1105
|
+
const tail = params2 ? ` {${params2}}` : "";
|
|
1106
|
+
lines.push(` [${ep.protocol}/${ep.verb}] ${ep.method} ${ep.path} → ${ep.functionName}${tail}`);
|
|
956
1107
|
}
|
|
957
1108
|
}
|
|
958
1109
|
const mainDomain = capturedApi[0]?.domain || "api";
|
|
@@ -1029,6 +1180,9 @@ function extractSignalKeys(body) {
|
|
|
1029
1180
|
continue;
|
|
1030
1181
|
if (typeof v === "string" && v.length > 80) {
|
|
1031
1182
|
signalEntries.push(`${k}=${v.slice(0, 40)}...`);
|
|
1183
|
+
} else if (Array.isArray(v)) {
|
|
1184
|
+
const items = v.slice(0, 8).map((x) => String(x)).join(",");
|
|
1185
|
+
signalEntries.push(`${k}=[${items}${v.length > 8 ? ",…" : ""}]`);
|
|
1032
1186
|
} else if (typeof v === "object" && v !== null) {
|
|
1033
1187
|
const keys = Object.keys(v).slice(0, 5).join(",");
|
|
1034
1188
|
signalEntries.push(`${k}={${keys}${Object.keys(v).length > 5 ? ",..." : ""}}`);
|
|
@@ -1099,11 +1253,11 @@ Sessions live in the single local SQLite database (~/.iframer/iframer.db), share
|
|
|
1099
1253
|
const lines = [result.message];
|
|
1100
1254
|
if (result.capturedApi && result.capturedApi.length > 0) {
|
|
1101
1255
|
formatCapturedApi(lines, result.capturedApi, { outputDir, typed: false });
|
|
1102
|
-
const outDir = outputDir ||
|
|
1256
|
+
const outDir = outputDir || import_path7.default.join(getDataDir(), "capture");
|
|
1103
1257
|
try {
|
|
1104
|
-
|
|
1105
|
-
const jsonPath =
|
|
1106
|
-
|
|
1258
|
+
import_fs5.default.mkdirSync(outDir, { recursive: true });
|
|
1259
|
+
const jsonPath = import_path7.default.join(outDir, "captured-api.json");
|
|
1260
|
+
import_fs5.default.writeFileSync(jsonPath, JSON.stringify(result.capturedApi, null, 2));
|
|
1107
1261
|
lines.push(`
|
|
1108
1262
|
Full data saved to: ${jsonPath}`);
|
|
1109
1263
|
} catch (writeErr) {
|
|
@@ -1173,19 +1327,19 @@ Full data saved to: ${jsonPath}`);
|
|
|
1173
1327
|
|
|
1174
1328
|
// src/mcp/tools/credentials.ts
|
|
1175
1329
|
var import_zod6 = require("zod");
|
|
1176
|
-
var
|
|
1177
|
-
var
|
|
1330
|
+
var import_fs7 = __toESM(require("fs"));
|
|
1331
|
+
var import_path9 = __toESM(require("path"));
|
|
1178
1332
|
|
|
1179
1333
|
// src/lib/knowledge.ts
|
|
1180
|
-
var
|
|
1181
|
-
var
|
|
1182
|
-
var
|
|
1334
|
+
var import_fs6 = __toESM(require("fs"));
|
|
1335
|
+
var import_path8 = __toESM(require("path"));
|
|
1336
|
+
var log4 = createLogger("knowledge");
|
|
1183
1337
|
function getKnowledgeDir() {
|
|
1184
|
-
return
|
|
1338
|
+
return import_path8.default.join(getDataDir(), "knowledge");
|
|
1185
1339
|
}
|
|
1186
1340
|
function getKnowledgePath(domain) {
|
|
1187
1341
|
const safe = sanitizeDomain(domain);
|
|
1188
|
-
return
|
|
1342
|
+
return import_path8.default.join(getKnowledgeDir(), `${safe}.md`);
|
|
1189
1343
|
}
|
|
1190
1344
|
function normalizeDomain(input) {
|
|
1191
1345
|
let d = (input || "").trim().toLowerCase();
|
|
@@ -1209,7 +1363,7 @@ function sanitizeDomain(input) {
|
|
|
1209
1363
|
function readKnowledge(domain) {
|
|
1210
1364
|
const p = getKnowledgePath(domain);
|
|
1211
1365
|
try {
|
|
1212
|
-
return
|
|
1366
|
+
return import_fs6.default.readFileSync(p, "utf8");
|
|
1213
1367
|
} catch {
|
|
1214
1368
|
return null;
|
|
1215
1369
|
}
|
|
@@ -1218,7 +1372,7 @@ function listKnowledge() {
|
|
|
1218
1372
|
const dir = getKnowledgeDir();
|
|
1219
1373
|
let entries = [];
|
|
1220
1374
|
try {
|
|
1221
|
-
entries =
|
|
1375
|
+
entries = import_fs6.default.readdirSync(dir);
|
|
1222
1376
|
} catch {
|
|
1223
1377
|
return [];
|
|
1224
1378
|
}
|
|
@@ -1226,10 +1380,10 @@ function listKnowledge() {
|
|
|
1226
1380
|
for (const file of entries) {
|
|
1227
1381
|
if (!file.endsWith(".md"))
|
|
1228
1382
|
continue;
|
|
1229
|
-
const full =
|
|
1383
|
+
const full = import_path8.default.join(dir, file);
|
|
1230
1384
|
try {
|
|
1231
|
-
const stat =
|
|
1232
|
-
const raw =
|
|
1385
|
+
const stat = import_fs6.default.statSync(full);
|
|
1386
|
+
const raw = import_fs6.default.readFileSync(full, "utf8");
|
|
1233
1387
|
const parsed = parseMarkdown(raw);
|
|
1234
1388
|
results.push({
|
|
1235
1389
|
domain: parsed?.domain ?? file.replace(/\.md$/, ""),
|
|
@@ -1247,7 +1401,7 @@ function clearKnowledge(domain) {
|
|
|
1247
1401
|
if (domain) {
|
|
1248
1402
|
const p = getKnowledgePath(domain);
|
|
1249
1403
|
try {
|
|
1250
|
-
|
|
1404
|
+
import_fs6.default.unlinkSync(p);
|
|
1251
1405
|
return { removed: 1 };
|
|
1252
1406
|
} catch {
|
|
1253
1407
|
return { removed: 0 };
|
|
@@ -1255,11 +1409,11 @@ function clearKnowledge(domain) {
|
|
|
1255
1409
|
}
|
|
1256
1410
|
let removed = 0;
|
|
1257
1411
|
try {
|
|
1258
|
-
const entries =
|
|
1412
|
+
const entries = import_fs6.default.readdirSync(dir);
|
|
1259
1413
|
for (const f of entries) {
|
|
1260
1414
|
if (f.endsWith(".md")) {
|
|
1261
1415
|
try {
|
|
1262
|
-
|
|
1416
|
+
import_fs6.default.unlinkSync(import_path8.default.join(dir, f));
|
|
1263
1417
|
removed++;
|
|
1264
1418
|
} catch {}
|
|
1265
1419
|
}
|
|
@@ -1339,8 +1493,8 @@ var ELICIT_TIMEOUT_MS = 45000;
|
|
|
1339
1493
|
function mcpLog(event, data) {
|
|
1340
1494
|
try {
|
|
1341
1495
|
const dir = getDataDir();
|
|
1342
|
-
|
|
1343
|
-
|
|
1496
|
+
import_fs7.default.mkdirSync(dir, { recursive: true });
|
|
1497
|
+
import_fs7.default.appendFileSync(import_path9.default.join(dir, "mcp.log"), JSON.stringify({ ts: new Date().toISOString(), event, ...data }) + `
|
|
1344
1498
|
`);
|
|
1345
1499
|
} catch {}
|
|
1346
1500
|
}
|
|
@@ -1540,6 +1694,452 @@ Actions:
|
|
|
1540
1694
|
});
|
|
1541
1695
|
}
|
|
1542
1696
|
|
|
1697
|
+
// src/mcp/tools/tabs.ts
|
|
1698
|
+
var import_zod8 = require("zod");
|
|
1699
|
+
function registerTabsTool(server) {
|
|
1700
|
+
server.tool("tabs", `List the tabs currently open in the user's REAL Chrome browser, through the iframer browser extension.
|
|
1701
|
+
|
|
1702
|
+
Use this when the user says something like "use my open tab", "the tab I have here", "my Gmail tab", or references a site/screenshot they already have open. Match their reference against the returned tabs (by url or title), pick the tab id, then call \`execute\` with options.mode="extension" and options.tabId=<id> to drive that exact tab on their real logged-in session (Chrome shows its "is being debugged" bar while a run is in progress).
|
|
1703
|
+
|
|
1704
|
+
Requires the iframer Chrome extension to be installed and connected (paired once with the token). Once connected, iframer can see and drive any open tab. If nothing is connected, this returns a clear message telling the user how to connect.
|
|
1705
|
+
|
|
1706
|
+
Returns: connected (bool), and tabs: [{ id, title, url, active, windowId }]. If several tabs match the user's reference, ask them which one rather than guessing.
|
|
1707
|
+
|
|
1708
|
+
action="open" opens a NEW tab in the user's real Chrome (native — not a page popup) at options.url, and returns its tab id so you can immediately drive it with \`execute\` mode="extension".
|
|
1709
|
+
|
|
1710
|
+
Tab GROUP management (native Chrome tab groups):
|
|
1711
|
+
- action="group": put tabIds into a group (new, or add to an existing groupId); optionally title/color/collapsed.
|
|
1712
|
+
- action="ungroup": remove tabIds from their group.
|
|
1713
|
+
- action="update-group": rename/recolor/collapse an EXISTING group by groupId (no tabIds needed).
|
|
1714
|
+
- action="groups": list all current groups (id, title, color, collapsed) — use to find a groupId to update.`, {
|
|
1715
|
+
action: import_zod8.z.enum(["list", "open", "group", "ungroup", "update-group", "groups"]).optional().describe("'list' (default) lists open tabs; 'open' opens a new tab; 'group'/'ungroup' add/remove tabIds to a group; 'update-group' renames/recolors an existing group by groupId; 'groups' lists all groups."),
|
|
1716
|
+
url: import_zod8.z.string().optional().describe("With action='open': the URL to open (omit for a blank tab)."),
|
|
1717
|
+
active: import_zod8.z.boolean().optional().describe("With action='open': focus the new tab (default true)."),
|
|
1718
|
+
tabIds: import_zod8.z.array(import_zod8.z.number()).optional().describe("With action='group': the tab ids to group (must be in the same window)."),
|
|
1719
|
+
title: import_zod8.z.string().optional().describe("With action='group': the group's label."),
|
|
1720
|
+
color: import_zod8.z.enum(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]).optional().describe("With action='group': the group's color."),
|
|
1721
|
+
collapsed: import_zod8.z.boolean().optional().describe("With action='group': collapse the group."),
|
|
1722
|
+
groupId: import_zod8.z.number().optional().describe("With action='group': add tabs to this existing group instead of creating a new one."),
|
|
1723
|
+
clientId: import_zod8.z.string().optional().describe("With action='open'/'group' and multiple profiles connected: which profile to act in."),
|
|
1724
|
+
filter: import_zod8.z.string().optional().describe("With action='list': case-insensitive substring to match against tab url or title (e.g. 'gmail', 'github.com'). Omit to list every open tab.")
|
|
1725
|
+
}, async ({ action, url, active, tabIds, title, color, collapsed, groupId, clientId, filter }) => {
|
|
1726
|
+
try {
|
|
1727
|
+
if (action === "open") {
|
|
1728
|
+
const res = await localApiPost("/extension/tab/create", { url, active, clientId });
|
|
1729
|
+
if (!res.ok || !res.tab)
|
|
1730
|
+
return err(res.error || "Failed to open tab.");
|
|
1731
|
+
const t = res.tab;
|
|
1732
|
+
return {
|
|
1733
|
+
content: [
|
|
1734
|
+
{
|
|
1735
|
+
type: "text",
|
|
1736
|
+
text: `Opened tab [id ${t.id}] ${t.title || t.url}
|
|
1737
|
+
${t.url}
|
|
1738
|
+
Drive it with: execute mode="extension", tabId=${t.id}.`
|
|
1739
|
+
}
|
|
1740
|
+
]
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
if (action === "group") {
|
|
1744
|
+
if (!tabIds || tabIds.length === 0)
|
|
1745
|
+
return err("action='group' requires tabIds (array of tab ids).");
|
|
1746
|
+
const res = await localApiPost("/extension/tab/group", { tabIds, title, color, collapsed, groupId, clientId });
|
|
1747
|
+
if (!res.ok || !res.group)
|
|
1748
|
+
return err(res.error || "Failed to group tabs.");
|
|
1749
|
+
const g = res.group;
|
|
1750
|
+
return {
|
|
1751
|
+
content: [
|
|
1752
|
+
{
|
|
1753
|
+
type: "text",
|
|
1754
|
+
text: `Grouped ${tabIds.length} tab(s) into group ${g.groupId}${g.title ? ` "${g.title}"` : ""}${g.color ? ` (${g.color})` : ""}${g.collapsed ? ", collapsed" : ""}.`
|
|
1755
|
+
}
|
|
1756
|
+
]
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
if (action === "ungroup") {
|
|
1760
|
+
if (!tabIds || tabIds.length === 0)
|
|
1761
|
+
return err("action='ungroup' requires tabIds.");
|
|
1762
|
+
const res = await localApiPost("/extension/tab/ungroup", { tabIds, clientId });
|
|
1763
|
+
if (!res.ok)
|
|
1764
|
+
return err(res.error || "Failed to ungroup tabs.");
|
|
1765
|
+
return { content: [{ type: "text", text: `Ungrouped ${tabIds.length} tab(s).` }] };
|
|
1766
|
+
}
|
|
1767
|
+
if (action === "update-group") {
|
|
1768
|
+
if (typeof groupId !== "number")
|
|
1769
|
+
return err("action='update-group' requires groupId (from action='groups'). Pass any of title, color, collapsed.");
|
|
1770
|
+
const res = await localApiPost("/extension/group/update", { groupId, title, color, collapsed, clientId });
|
|
1771
|
+
if (!res.ok || !res.group)
|
|
1772
|
+
return err(res.error || "Failed to update group.");
|
|
1773
|
+
const g = res.group;
|
|
1774
|
+
return { content: [{ type: "text", text: `Updated group ${g.groupId} → title "${g.title}", color ${g.color}${g.collapsed ? ", collapsed" : ""}.` }] };
|
|
1775
|
+
}
|
|
1776
|
+
if (action === "groups") {
|
|
1777
|
+
const res = await localApiPost("/extension/groups", { clientId });
|
|
1778
|
+
if (!res.ok)
|
|
1779
|
+
return err(res.error || "Failed to list groups.");
|
|
1780
|
+
const groups = res.groups || [];
|
|
1781
|
+
if (groups.length === 0)
|
|
1782
|
+
return { content: [{ type: "text", text: "No tab groups open." }] };
|
|
1783
|
+
const lines2 = [`${groups.length} tab group(s):`, ""];
|
|
1784
|
+
for (const g of groups)
|
|
1785
|
+
lines2.push(` [group ${g.groupId}] "${g.title || "(untitled)"}" — ${g.color}${g.collapsed ? ", collapsed" : ""} (window ${g.windowId})`);
|
|
1786
|
+
return { content: [{ type: "text", text: lines2.join(`
|
|
1787
|
+
`) }] };
|
|
1788
|
+
}
|
|
1789
|
+
const status = await localApiGet("/extension/status");
|
|
1790
|
+
if (!status.connected) {
|
|
1791
|
+
return err(`No iframer extension is connected.
|
|
1792
|
+
|
|
1793
|
+
` + `To use your real Chrome tabs:
|
|
1794
|
+
` + "1. Install the iframer extension (chrome://extensions → Load unpacked → the `extension/` folder).\n" + "2. Run `iframer install extension` in a terminal, then restart the browser — the extension pairs itself.\n" + " (Manual fallback: click the iframer icon and paste the token from `cat ~/.iframer/secret`.)\n" + "Once the dot is green, run `tabs` again — iframer can then see and drive any open tab.");
|
|
1795
|
+
}
|
|
1796
|
+
const data = await localApiPost("/extension/tabs", {});
|
|
1797
|
+
let tabs = data.tabs || [];
|
|
1798
|
+
const clients = data.clients || [];
|
|
1799
|
+
const multiProfile = clients.length > 1;
|
|
1800
|
+
if (filter) {
|
|
1801
|
+
const f = filter.toLowerCase();
|
|
1802
|
+
tabs = tabs.filter((t) => t.url.toLowerCase().includes(f) || (t.title || "").toLowerCase().includes(f));
|
|
1803
|
+
}
|
|
1804
|
+
if (tabs.length === 0) {
|
|
1805
|
+
return {
|
|
1806
|
+
content: [
|
|
1807
|
+
{
|
|
1808
|
+
type: "text",
|
|
1809
|
+
text: filter ? `Extension connected, but no open tab matches "${filter}".` : "Extension connected, but no tabs were reported."
|
|
1810
|
+
}
|
|
1811
|
+
]
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
1814
|
+
const profiles = clients.map((c) => c.profileName || c.clientId.slice(0, 8)).join(", ");
|
|
1815
|
+
const ver = clients[0]?.extVersion ? ` [ext v${clients[0].extVersion}]` : "";
|
|
1816
|
+
const lines = [
|
|
1817
|
+
`Connected: ${clients.length} profile${clients.length > 1 ? "s" : ""}${profiles ? ` (${profiles})` : ""}${ver}.`,
|
|
1818
|
+
`${tabs.length} tab${tabs.length > 1 ? "s" : ""}${filter ? ` matching "${filter}"` : ""}:`,
|
|
1819
|
+
""
|
|
1820
|
+
];
|
|
1821
|
+
for (const t of tabs) {
|
|
1822
|
+
const prof = multiProfile ? ` «${t.profileName || t.clientId.slice(0, 8)}»` : "";
|
|
1823
|
+
lines.push(` [id ${t.id}]${t.active ? " (active)" : ""}${prof} ${t.title}`);
|
|
1824
|
+
lines.push(` ${t.url}`);
|
|
1825
|
+
if (multiProfile)
|
|
1826
|
+
lines.push(` clientId: ${t.clientId}`);
|
|
1827
|
+
}
|
|
1828
|
+
lines.push("");
|
|
1829
|
+
lines.push('To drive one: execute with options.mode="extension", options.tabId=<id>.');
|
|
1830
|
+
if (multiProfile) {
|
|
1831
|
+
lines.push("Multiple profiles are connected — if two tabs share a title, also pass options.clientId " + "(shown as «profile» above maps to a clientId) so the right profile is driven.");
|
|
1832
|
+
}
|
|
1833
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
1834
|
+
`) }] };
|
|
1835
|
+
} catch (e) {
|
|
1836
|
+
return err(`Error listing tabs: ${getErrorMessage(e)}`);
|
|
1837
|
+
}
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// src/mcp/tools/anchors.ts
|
|
1842
|
+
var import_zod9 = require("zod");
|
|
1843
|
+
|
|
1844
|
+
// src/lib/knowledge/component-map.ts
|
|
1845
|
+
var import_fs8 = __toESM(require("fs"));
|
|
1846
|
+
var import_path10 = __toESM(require("path"));
|
|
1847
|
+
function anchorsPath(domain) {
|
|
1848
|
+
return import_path10.default.join(getKnowledgeDir(), `${sanitizeDomain(domain)}.anchors.json`);
|
|
1849
|
+
}
|
|
1850
|
+
function loadComponentMap(domain) {
|
|
1851
|
+
const norm = normalizeDomain(domain);
|
|
1852
|
+
try {
|
|
1853
|
+
const raw = import_fs8.default.readFileSync(anchorsPath(norm), "utf8");
|
|
1854
|
+
const parsed = JSON.parse(raw);
|
|
1855
|
+
return {
|
|
1856
|
+
domain: parsed.domain || norm,
|
|
1857
|
+
anchors: parsed.anchors || {},
|
|
1858
|
+
quirks: Array.isArray(parsed.quirks) ? parsed.quirks : []
|
|
1859
|
+
};
|
|
1860
|
+
} catch {
|
|
1861
|
+
return { domain: norm, anchors: {}, quirks: [] };
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
function write(cm) {
|
|
1865
|
+
import_fs8.default.mkdirSync(getKnowledgeDir(), { recursive: true });
|
|
1866
|
+
import_fs8.default.writeFileSync(anchorsPath(cm.domain), JSON.stringify(cm, null, 2), "utf8");
|
|
1867
|
+
}
|
|
1868
|
+
function saveAnchor(domain, input, now) {
|
|
1869
|
+
const cm = loadComponentMap(domain);
|
|
1870
|
+
cm.anchors[input.name] = {
|
|
1871
|
+
name: input.name,
|
|
1872
|
+
selector: input.selector,
|
|
1873
|
+
role: input.role,
|
|
1874
|
+
description: input.description,
|
|
1875
|
+
quirks: input.quirks && input.quirks.length ? input.quirks : undefined,
|
|
1876
|
+
uses: 0,
|
|
1877
|
+
fails: 0,
|
|
1878
|
+
lastVerified: now
|
|
1879
|
+
};
|
|
1880
|
+
write(cm);
|
|
1881
|
+
}
|
|
1882
|
+
function removeAnchor(domain, name) {
|
|
1883
|
+
const cm = loadComponentMap(domain);
|
|
1884
|
+
if (!(name in cm.anchors))
|
|
1885
|
+
return false;
|
|
1886
|
+
delete cm.anchors[name];
|
|
1887
|
+
write(cm);
|
|
1888
|
+
return true;
|
|
1889
|
+
}
|
|
1890
|
+
function setDomainQuirks(domain, quirks) {
|
|
1891
|
+
const cm = loadComponentMap(domain);
|
|
1892
|
+
cm.quirks = Array.from(new Set([...cm.quirks, ...quirks]));
|
|
1893
|
+
write(cm);
|
|
1894
|
+
}
|
|
1895
|
+
function listAnchorDomains() {
|
|
1896
|
+
try {
|
|
1897
|
+
return import_fs8.default.readdirSync(getKnowledgeDir()).filter((f) => f.endsWith(".anchors.json")).map((f) => f.replace(/\.anchors\.json$/, ""));
|
|
1898
|
+
} catch {
|
|
1899
|
+
return [];
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
// src/mcp/tools/anchors.ts
|
|
1904
|
+
function registerRememberTool(server) {
|
|
1905
|
+
server.tool("remember", `Persisted per-domain map of a website's UI elements ("anchors"), so the agent recalls where things are instead of re-exploring the DOM every run.
|
|
1906
|
+
|
|
1907
|
+
WORKFLOW:
|
|
1908
|
+
1. Before a UI task on a site, call \`remember get <domain>\`. If an anchor exists (e.g. "composer", "send-button"), use it directly: put \`@a:<name>\` in any selector field of an \`execute\` step (click/fill/etc.). It resolves to the saved selector — no snapshot needed.
|
|
1909
|
+
2. If the site is new or the anchor you need is missing, discover the element with a \`snapshot\` or \`find\` step, act on it, then \`remember save\` it (name + the selector that worked, plus any quirks) so future runs are instant.
|
|
1910
|
+
3. SELF-HEAL: if a step using \`@a:<name>\` FAILS, the page changed — do NOT keep retrying the same anchor. Re-discover with snapshot/find, then \`remember save\` the new selector under the same name (overwrites, resets the health counters). Prefer stable selectors (aria-label, data-qa, role+name) over brittle generated CSS.
|
|
1911
|
+
4. Record site quirks with \`remember quirk\` (e.g. "synthetic clicks ignored — use trusted/coordinate clicks", "@here triggers a confirmation modal — click Send"). \`get\` surfaces them so the agent isn't surprised.
|
|
1912
|
+
|
|
1913
|
+
Anchors live at ~/.iframer/knowledge/<domain>.anchors.json, alongside the API knowledge cache.`, {
|
|
1914
|
+
action: import_zod9.z.enum(["get", "save", "forget", "list", "quirk"]).describe("get: show a domain's anchors+quirks | save: create/overwrite an anchor | forget: delete an anchor | list: all domains with anchors | quirk: add site-wide quirk note(s)"),
|
|
1915
|
+
domain: import_zod9.z.string().optional().describe("Site domain, e.g. 'slack.com' or 'app.slack.com' (required for all actions except list)."),
|
|
1916
|
+
name: import_zod9.z.string().optional().describe("Anchor name for save/forget, e.g. 'composer', 'send-button', 'search'. Short, stable, kebab-case."),
|
|
1917
|
+
selector: import_zod9.z.string().optional().describe("With save: the CSS selector that locates the element. Prefer stable attributes: [aria-label=...], [data-qa=...], role+name. Avoid brittle generated class chains."),
|
|
1918
|
+
role: import_zod9.z.string().optional().describe("With save (optional): the element role, e.g. textbox, button, link."),
|
|
1919
|
+
description: import_zod9.z.string().optional().describe("With save (optional): a short human note about the element."),
|
|
1920
|
+
quirks: import_zod9.z.array(import_zod9.z.string()).optional().describe("With save: element-specific gotchas. With quirk: site-wide gotchas to append.")
|
|
1921
|
+
}, async ({ action, domain, name, selector, role, description, quirks }) => {
|
|
1922
|
+
try {
|
|
1923
|
+
if (action === "list") {
|
|
1924
|
+
const domains = listAnchorDomains();
|
|
1925
|
+
if (domains.length === 0) {
|
|
1926
|
+
return { content: [{ type: "text", text: "No anchors saved yet. Use `remember save` after locating an element with snapshot/find." }] };
|
|
1927
|
+
}
|
|
1928
|
+
const lines = [`Domains with saved anchors:
|
|
1929
|
+
`];
|
|
1930
|
+
for (const d of domains) {
|
|
1931
|
+
const cm = loadComponentMap(d);
|
|
1932
|
+
lines.push(` ${d} — ${Object.keys(cm.anchors).length} anchor(s)${cm.quirks.length ? `, ${cm.quirks.length} quirk(s)` : ""}`);
|
|
1933
|
+
}
|
|
1934
|
+
lines.push("\nCall `remember get <domain>` for details.");
|
|
1935
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
1936
|
+
`) }] };
|
|
1937
|
+
}
|
|
1938
|
+
if (!domain)
|
|
1939
|
+
return err(`\`${action}\` requires a domain (e.g. 'app.slack.com').`);
|
|
1940
|
+
if (action === "get") {
|
|
1941
|
+
const cm = loadComponentMap(domain);
|
|
1942
|
+
const names = Object.keys(cm.anchors);
|
|
1943
|
+
if (names.length === 0 && cm.quirks.length === 0) {
|
|
1944
|
+
return { content: [{ type: "text", text: `No anchors saved for ${domain} yet. Discover elements with snapshot/find, then \`remember save\` them.` }] };
|
|
1945
|
+
}
|
|
1946
|
+
const lines = [`Component map for ${domain}:
|
|
1947
|
+
`];
|
|
1948
|
+
if (cm.quirks.length) {
|
|
1949
|
+
lines.push("Site quirks:");
|
|
1950
|
+
for (const q of cm.quirks)
|
|
1951
|
+
lines.push(` - ${q}`);
|
|
1952
|
+
lines.push("");
|
|
1953
|
+
}
|
|
1954
|
+
lines.push(`Anchors (use as @a:<name> in any selector):`);
|
|
1955
|
+
for (const a of Object.values(cm.anchors)) {
|
|
1956
|
+
const health = a.fails > 0 ? ` [${a.uses}✓/${a.fails}✗${a.fails >= a.uses && a.fails >= 2 ? " — likely STALE, re-verify" : ""}]` : a.uses > 0 ? ` [${a.uses}✓]` : "";
|
|
1957
|
+
lines.push(` @a:${a.name}${a.role ? ` (${a.role})` : ""} → ${a.selector}${health}`);
|
|
1958
|
+
if (a.description)
|
|
1959
|
+
lines.push(` ${a.description}`);
|
|
1960
|
+
if (a.quirks?.length)
|
|
1961
|
+
for (const q of a.quirks)
|
|
1962
|
+
lines.push(` • ${q}`);
|
|
1963
|
+
}
|
|
1964
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
1965
|
+
`) }] };
|
|
1966
|
+
}
|
|
1967
|
+
if (action === "quirk") {
|
|
1968
|
+
if (!quirks || quirks.length === 0)
|
|
1969
|
+
return err("`quirk` requires a non-empty `quirks` array.");
|
|
1970
|
+
setDomainQuirks(domain, quirks);
|
|
1971
|
+
return { content: [{ type: "text", text: `Added ${quirks.length} quirk(s) to ${domain}.` }] };
|
|
1972
|
+
}
|
|
1973
|
+
if (action === "forget") {
|
|
1974
|
+
if (!name)
|
|
1975
|
+
return err("`forget` requires the anchor `name`.");
|
|
1976
|
+
const removed = removeAnchor(domain, name);
|
|
1977
|
+
return { content: [{ type: "text", text: removed ? `Removed anchor @a:${name} from ${domain}.` : `No anchor named '${name}' for ${domain}.` }] };
|
|
1978
|
+
}
|
|
1979
|
+
if (!name)
|
|
1980
|
+
return err("`save` requires an anchor `name`.");
|
|
1981
|
+
if (!selector)
|
|
1982
|
+
return err("`save` requires a `selector` (the CSS that locates the element).");
|
|
1983
|
+
saveAnchor(domain, { name, selector, role, description, quirks }, new Date().toISOString());
|
|
1984
|
+
return {
|
|
1985
|
+
content: [
|
|
1986
|
+
{
|
|
1987
|
+
type: "text",
|
|
1988
|
+
text: `Saved anchor @a:${name} for ${domain} → ${selector}
|
|
1989
|
+
Use it in any selector field: e.g. {"type":"click","selector":"@a:${name}"}.`
|
|
1990
|
+
}
|
|
1991
|
+
]
|
|
1992
|
+
};
|
|
1993
|
+
} catch (e) {
|
|
1994
|
+
return err(`remember failed: ${getErrorMessage(e)}`);
|
|
1995
|
+
}
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
// src/mcp/tools/clipboard.ts
|
|
2000
|
+
var import_zod10 = require("zod");
|
|
2001
|
+
|
|
2002
|
+
// src/lib/clipboard.ts
|
|
2003
|
+
var import_child_process2 = require("child_process");
|
|
2004
|
+
function platformTools(mode) {
|
|
2005
|
+
if (process.platform === "darwin")
|
|
2006
|
+
return [[mode === "read" ? "pbpaste" : "pbcopy"]];
|
|
2007
|
+
if (mode === "read")
|
|
2008
|
+
return [["wl-paste", "-n"], ["xclip", "-selection", "clipboard", "-o"], ["xsel", "-b", "-o"]];
|
|
2009
|
+
return [["wl-copy"], ["xclip", "-selection", "clipboard", "-i"], ["xsel", "-b", "-i"]];
|
|
2010
|
+
}
|
|
2011
|
+
function run(cmd, input) {
|
|
2012
|
+
return new Promise((resolve) => {
|
|
2013
|
+
let child;
|
|
2014
|
+
try {
|
|
2015
|
+
child = import_child_process2.spawn(cmd[0], cmd.slice(1));
|
|
2016
|
+
} catch {
|
|
2017
|
+
resolve({ ok: false, out: "", err: `spawn ${cmd[0]} failed` });
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
let out = "";
|
|
2021
|
+
let errOut = "";
|
|
2022
|
+
child.stdout?.on("data", (d) => out += d);
|
|
2023
|
+
child.stderr?.on("data", (d) => errOut += d);
|
|
2024
|
+
child.on("error", (e) => resolve({ ok: false, out: "", err: e.message }));
|
|
2025
|
+
child.on("close", (code) => resolve({ ok: code === 0, out, err: errOut }));
|
|
2026
|
+
if (input !== undefined) {
|
|
2027
|
+
child.stdin?.write(input);
|
|
2028
|
+
child.stdin?.end();
|
|
2029
|
+
}
|
|
2030
|
+
});
|
|
2031
|
+
}
|
|
2032
|
+
async function clipboardWrite(text) {
|
|
2033
|
+
const tools = platformTools("write");
|
|
2034
|
+
for (const cmd of tools) {
|
|
2035
|
+
if ((await run(cmd, text)).ok)
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
2038
|
+
throw new Error(`No working clipboard tool found (${tools.map((t) => t[0]).join(", ")}).`);
|
|
2039
|
+
}
|
|
2040
|
+
async function clipboardRead() {
|
|
2041
|
+
const tools = platformTools("read");
|
|
2042
|
+
let lastErr = "";
|
|
2043
|
+
for (const cmd of tools) {
|
|
2044
|
+
const r = await run(cmd);
|
|
2045
|
+
if (r.ok)
|
|
2046
|
+
return r.out;
|
|
2047
|
+
lastErr = r.err;
|
|
2048
|
+
}
|
|
2049
|
+
throw new Error(`No working clipboard tool found (${tools.map((t) => t[0]).join(", ")}). ${lastErr}`);
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
// src/mcp/tools/clipboard.ts
|
|
2053
|
+
function registerClipboardTool(server) {
|
|
2054
|
+
server.tool("clipboard", `Read or write the machine's clipboard (the same one the user's Chrome pastes from — iframer runs locally).
|
|
2055
|
+
|
|
2056
|
+
Use this instead of trying to grant a page clipboard permission (not possible via the extension):
|
|
2057
|
+
- To READ something a site copied (a code, a link): clipboard get.
|
|
2058
|
+
- To WRITE text to the clipboard: clipboard set <text>.
|
|
2059
|
+
- To PASTE the clipboard into a page field: use the \`paste\` execute step (do NOT rely on a ⌘V keyboard step — the extension relay ignores modifier keys).`, {
|
|
2060
|
+
action: import_zod10.z.enum(["get", "set"]).describe("get: read clipboard text | set: write text to clipboard"),
|
|
2061
|
+
text: import_zod10.z.string().optional().describe("With action='set': the text to put on the clipboard.")
|
|
2062
|
+
}, async ({ action, text }) => {
|
|
2063
|
+
try {
|
|
2064
|
+
if (action === "set") {
|
|
2065
|
+
if (text === undefined)
|
|
2066
|
+
return err("action='set' requires `text`.");
|
|
2067
|
+
await clipboardWrite(text);
|
|
2068
|
+
return { content: [{ type: "text", text: `Clipboard set (${text.length} chars). To paste into a field, use the \`paste\` execute step.` }] };
|
|
2069
|
+
}
|
|
2070
|
+
const value = await clipboardRead();
|
|
2071
|
+
return { content: [{ type: "text", text: value.length ? value : "(clipboard is empty)" }] };
|
|
2072
|
+
} catch (e) {
|
|
2073
|
+
return err(`clipboard failed: ${getErrorMessage(e)}`);
|
|
2074
|
+
}
|
|
2075
|
+
});
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
// src/mcp/telemetry.ts
|
|
2079
|
+
var import_fs9 = __toESM(require("fs"));
|
|
2080
|
+
var import_path11 = __toESM(require("path"));
|
|
2081
|
+
var CHARS_PER_TOKEN = 4;
|
|
2082
|
+
var est = (chars) => Math.round(chars / CHARS_PER_TOKEN);
|
|
2083
|
+
var enabled = process.env.IFRAMER_TELEMETRY !== "0";
|
|
2084
|
+
var sessionId = `${new Date().toISOString().slice(0, 10)}-${process.pid}`;
|
|
2085
|
+
function telemetryPath() {
|
|
2086
|
+
return import_path11.default.join(getDataDir(), "telemetry.jsonl");
|
|
2087
|
+
}
|
|
2088
|
+
var sessionCalls = 0;
|
|
2089
|
+
var sessionChars = 0;
|
|
2090
|
+
function write2(rec) {
|
|
2091
|
+
if (!enabled)
|
|
2092
|
+
return;
|
|
2093
|
+
try {
|
|
2094
|
+
import_fs9.default.appendFileSync(telemetryPath(), JSON.stringify({ ts: new Date().toISOString(), session: sessionId, ...rec }) + `
|
|
2095
|
+
`);
|
|
2096
|
+
} catch {}
|
|
2097
|
+
}
|
|
2098
|
+
function recordDefinitions(toolCount, defChars, instructionChars) {
|
|
2099
|
+
write2({
|
|
2100
|
+
kind: "definitions",
|
|
2101
|
+
toolCount,
|
|
2102
|
+
defChars,
|
|
2103
|
+
instructionChars,
|
|
2104
|
+
estTokens: est(defChars + instructionChars)
|
|
2105
|
+
});
|
|
2106
|
+
}
|
|
2107
|
+
function recordCall(tool, inChars, outChars, ms, isError) {
|
|
2108
|
+
sessionCalls++;
|
|
2109
|
+
sessionChars += inChars + outChars;
|
|
2110
|
+
write2({
|
|
2111
|
+
kind: "call",
|
|
2112
|
+
tool,
|
|
2113
|
+
inChars,
|
|
2114
|
+
outChars,
|
|
2115
|
+
estTokens: est(inChars + outChars),
|
|
2116
|
+
ms,
|
|
2117
|
+
isError,
|
|
2118
|
+
sessionCalls,
|
|
2119
|
+
sessionEstTokens: est(sessionChars)
|
|
2120
|
+
});
|
|
2121
|
+
}
|
|
2122
|
+
function contentChars(res) {
|
|
2123
|
+
const r = res;
|
|
2124
|
+
if (!r || !Array.isArray(r.content))
|
|
2125
|
+
return 0;
|
|
2126
|
+
let n = 0;
|
|
2127
|
+
for (const c of r.content) {
|
|
2128
|
+
if (typeof c?.text === "string")
|
|
2129
|
+
n += c.text.length;
|
|
2130
|
+
else if (typeof c?.data === "string")
|
|
2131
|
+
n += c.data.length;
|
|
2132
|
+
}
|
|
2133
|
+
return n;
|
|
2134
|
+
}
|
|
2135
|
+
function safeJsonLen(v) {
|
|
2136
|
+
try {
|
|
2137
|
+
return v === undefined ? 0 : JSON.stringify(v).length;
|
|
2138
|
+
} catch {
|
|
2139
|
+
return 0;
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
|
|
1543
2143
|
// src/mcp/server.ts
|
|
1544
2144
|
var IS_DEV = process.env.IFRAMER_URL?.includes("localhost") || process.env.IFRAMER_URL?.includes("127.0.0.1");
|
|
1545
2145
|
var INSTRUCTIONS = IS_DEV ? `iframer-dev — local development instance of iframer (connects to ${BASE_URL}).
|
|
@@ -1560,8 +2160,10 @@ CRITICAL RULES:
|
|
|
1560
2160
|
3. DO NOT present options when you can just act. Check status, credentials, execute.
|
|
1561
2161
|
4. NEVER re-store credentials when login fails. Login failures are browser-mode problems, not credential problems.
|
|
1562
2162
|
5. ALWAYS check "knowledge get <domain>" before launching a browser. Skip the browser if the cache has what you need.
|
|
1563
|
-
6.
|
|
2163
|
+
6. For UI tasks on a site, check "remember get <domain>" first — saved anchors let you target elements as @a:<name> (in any selector) instead of re-finding them. After you locate a new element with snapshot/find and it works, "remember save" it. If an @a: anchor fails, the page changed: re-discover and "remember save" the new selector (don't retry the stale one).
|
|
2164
|
+
7. If execute fails, read the FULL error. It tells you the step, error type, and suggestion.
|
|
1564
2165
|
7. If the browser crashes, call "session restart" and retry. Don't panic.
|
|
2166
|
+
8. ALWAYS call "session" action=stop when you are done with browser work. It saves session state and frees the browser. Idle browsers are auto-reclaimed, but don't rely on that.
|
|
1565
2167
|
|
|
1566
2168
|
BROWSER MODES: Don't specify options.mode — iframer auto-selects and auto-escalates (headless → binary-headful). Only set a mode if the user explicitly asks.
|
|
1567
2169
|
|
|
@@ -1569,6 +2171,28 @@ CAPTCHA: In binary-headful mode, ask the user to solve it in the visible window.
|
|
|
1569
2171
|
|
|
1570
2172
|
REVERSE ENGINEERING: Use the "reverse-engineer" tool when the user asks to capture/map/save API endpoints.`;
|
|
1571
2173
|
var server = new import_mcp.McpServer({ name: "iframer", version: "3.0.0" }, { instructions: INSTRUCTIONS });
|
|
2174
|
+
var defChars = 0;
|
|
2175
|
+
var defCount = 0;
|
|
2176
|
+
{
|
|
2177
|
+
const origTool = server.tool.bind(server);
|
|
2178
|
+
server.tool = (...a) => {
|
|
2179
|
+
const name = String(a[0]);
|
|
2180
|
+
defCount++;
|
|
2181
|
+
defChars += name.length + (typeof a[1] === "string" ? a[1].length : 0);
|
|
2182
|
+
const hIdx = a.length - 1;
|
|
2183
|
+
const handler = a[hIdx];
|
|
2184
|
+
if (typeof handler === "function") {
|
|
2185
|
+
a[hIdx] = async (...ha) => {
|
|
2186
|
+
const started = Date.now();
|
|
2187
|
+
const res = await handler(...ha);
|
|
2188
|
+
const r = res;
|
|
2189
|
+
recordCall(name, safeJsonLen(ha[0]), contentChars(res), Date.now() - started, !!r?.isError);
|
|
2190
|
+
return res;
|
|
2191
|
+
};
|
|
2192
|
+
}
|
|
2193
|
+
return origTool(...a);
|
|
2194
|
+
};
|
|
2195
|
+
}
|
|
1572
2196
|
registerStatusTool(server);
|
|
1573
2197
|
registerBrowseTool(server);
|
|
1574
2198
|
registerExecuteTool(server);
|
|
@@ -1576,18 +2200,20 @@ registerSessionTool(server);
|
|
|
1576
2200
|
registerCredentialsTool(server);
|
|
1577
2201
|
registerReverseEngineerTool(server);
|
|
1578
2202
|
registerKnowledgeTool(server);
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
process.on("SIGTERM", () =>
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
process.
|
|
1590
|
-
|
|
2203
|
+
registerTabsTool(server);
|
|
2204
|
+
registerRememberTool(server);
|
|
2205
|
+
registerClipboardTool(server);
|
|
2206
|
+
recordDefinitions(defCount, defChars, INSTRUCTIONS.length);
|
|
2207
|
+
process.on("SIGTERM", () => process.exit(0));
|
|
2208
|
+
process.on("SIGINT", () => process.exit(0));
|
|
2209
|
+
process.stdin.on("end", () => process.exit(0));
|
|
2210
|
+
process.stdin.on("close", () => process.exit(0));
|
|
2211
|
+
var originalPpid = process.ppid;
|
|
2212
|
+
var orphanCheck = setInterval(() => {
|
|
2213
|
+
if (process.ppid !== originalPpid || process.ppid === 1)
|
|
2214
|
+
process.exit(0);
|
|
2215
|
+
}, 30000);
|
|
2216
|
+
orphanCheck.unref?.();
|
|
1591
2217
|
process.on("uncaughtException", (err2) => {
|
|
1592
2218
|
try {
|
|
1593
2219
|
console.error(`[mcp] uncaughtException: ${err2?.message}`);
|