iframer-toolkit 0.10.6 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -65
- package/dist/cli.js +2935 -1776
- package/dist/local-server.cjs +1503 -1407
- package/dist/mcp-server.cjs +59 -115
- package/extension/README.md +10 -8
- package/extension/background.js +135 -84
- package/extension/manifest.json +1 -1
- package/extension/native-host.cjs +105 -29
- package/extension/popup.js +13 -4
- package/package.json +3 -6
- package/skills/iframer.md +103 -31
package/dist/mcp-server.cjs
CHANGED
|
@@ -112,8 +112,8 @@ function isPidAlive(pid) {
|
|
|
112
112
|
try {
|
|
113
113
|
process.kill(pid, 0);
|
|
114
114
|
return true;
|
|
115
|
-
} catch {
|
|
116
|
-
return
|
|
115
|
+
} catch (e) {
|
|
116
|
+
return e.code === "EPERM";
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
function readServerInfo() {
|
|
@@ -208,7 +208,6 @@ var LocalServerManager = class {
|
|
|
208
208
|
const env = {
|
|
209
209
|
...process.env,
|
|
210
210
|
PORT: String(port),
|
|
211
|
-
IFRAMER_MODE: "local",
|
|
212
211
|
IFRAMER_DATA_DIR: dataDir
|
|
213
212
|
};
|
|
214
213
|
if (process.env.IFRAMER_SECRET) {
|
|
@@ -223,8 +222,12 @@ var LocalServerManager = class {
|
|
|
223
222
|
const child = (0, import_child_process.spawn)(command, args, {
|
|
224
223
|
env,
|
|
225
224
|
stdio: ["ignore", logFd, logFd],
|
|
226
|
-
detached: true
|
|
225
|
+
detached: true,
|
|
226
|
+
// On Windows a detached child otherwise gets its own console window,
|
|
227
|
+
// and closing that window kills the server.
|
|
228
|
+
windowsHide: true
|
|
227
229
|
});
|
|
230
|
+
child.on("error", (e) => console.error(`[iframer] could not start the local server: ${e.message}`));
|
|
228
231
|
child.unref();
|
|
229
232
|
import_fs3.default.closeSync(logFd);
|
|
230
233
|
const deadline = Date.now() + STARTUP_TIMEOUT_MS;
|
|
@@ -374,9 +377,7 @@ function getErrorMessage(err2) {
|
|
|
374
377
|
|
|
375
378
|
// src/mcp/helpers.ts
|
|
376
379
|
var log2 = createLogger("mcp");
|
|
377
|
-
var BASE_URL = process.env.IFRAMER_URL || "http://localhost:3021";
|
|
378
380
|
var IFRAMER_SECRET = process.env.IFRAMER_SECRET;
|
|
379
|
-
var IFRAMER_MODE = process.env.IFRAMER_MODE;
|
|
380
381
|
var LOCAL_TOKEN = getLocalToken();
|
|
381
382
|
var localServer = new LocalServerManager();
|
|
382
383
|
async function ensureLocalServer() {
|
|
@@ -388,19 +389,6 @@ function authHeaders(secret) {
|
|
|
388
389
|
if (key) headers["x-api-key"] = key;
|
|
389
390
|
return headers;
|
|
390
391
|
}
|
|
391
|
-
async function apiPost(endpoint, body) {
|
|
392
|
-
const res = await fetch(`${BASE_URL}${endpoint}`, {
|
|
393
|
-
method: "POST",
|
|
394
|
-
headers: authHeaders(),
|
|
395
|
-
body: body ? JSON.stringify(body) : void 0,
|
|
396
|
-
signal: AbortSignal.timeout(18e4)
|
|
397
|
-
});
|
|
398
|
-
return res.json();
|
|
399
|
-
}
|
|
400
|
-
async function apiGet(endpoint) {
|
|
401
|
-
const res = await fetch(`${BASE_URL}${endpoint}`, { headers: authHeaders(), signal: AbortSignal.timeout(3e4) });
|
|
402
|
-
return res.json();
|
|
403
|
-
}
|
|
404
392
|
async function localApiPost(endpoint, body, timeoutMs = 18e4) {
|
|
405
393
|
await ensureLocalServer();
|
|
406
394
|
const url = localServer.getBaseUrl();
|
|
@@ -424,15 +412,6 @@ async function localApiDelete(endpoint) {
|
|
|
424
412
|
const res = await fetch(`${url}${endpoint}`, { method: "DELETE", headers: authHeaders(LOCAL_TOKEN), signal: AbortSignal.timeout(3e4) });
|
|
425
413
|
return res.json();
|
|
426
414
|
}
|
|
427
|
-
async function isDockerRunning() {
|
|
428
|
-
try {
|
|
429
|
-
const res = await fetch(`${BASE_URL}/health`, { signal: AbortSignal.timeout(3e3) });
|
|
430
|
-
const data = await res.json();
|
|
431
|
-
return data.ok === true;
|
|
432
|
-
} catch {
|
|
433
|
-
return false;
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
415
|
async function resolveScreenshotPath(url) {
|
|
437
416
|
try {
|
|
438
417
|
if (url.startsWith("file://")) {
|
|
@@ -448,7 +427,7 @@ async function resolveScreenshotPath(url) {
|
|
|
448
427
|
const fs10 = await import("fs");
|
|
449
428
|
const dir = import_path5.default.join(import_os3.default.tmpdir(), "iframer-screenshots", "screenshots");
|
|
450
429
|
fs10.mkdirSync(dir, { recursive: true });
|
|
451
|
-
const filePath = import_path5.default.join(dir, `
|
|
430
|
+
const filePath = import_path5.default.join(dir, `screenshot-${Date.now()}.jpg`);
|
|
452
431
|
fs10.writeFileSync(filePath, buf);
|
|
453
432
|
return filePath;
|
|
454
433
|
} catch {
|
|
@@ -467,7 +446,7 @@ function defaultFile() {
|
|
|
467
446
|
return import_path6.default.join(getDataDir(), "domain-modes.json");
|
|
468
447
|
}
|
|
469
448
|
var TTL_DAYS = 14;
|
|
470
|
-
var ESCALATION_LADDER = ["headless", "
|
|
449
|
+
var ESCALATION_LADDER = ["headless", "binary-headful"];
|
|
471
450
|
var DomainModeStore = class {
|
|
472
451
|
data = {};
|
|
473
452
|
filePath;
|
|
@@ -572,8 +551,6 @@ function registerStatusTool(server2) {
|
|
|
572
551
|
async () => {
|
|
573
552
|
try {
|
|
574
553
|
const status = {};
|
|
575
|
-
const dockerRunning = await isDockerRunning();
|
|
576
|
-
status.dockerApi = dockerRunning;
|
|
577
554
|
try {
|
|
578
555
|
const localHealth = await localApiGet("/health");
|
|
579
556
|
status.localServer = localHealth.ok;
|
|
@@ -591,13 +568,6 @@ function registerStatusTool(server2) {
|
|
|
591
568
|
if (inst.instances && inst.instances.length) status.liveInstances = inst.instances;
|
|
592
569
|
} catch {
|
|
593
570
|
}
|
|
594
|
-
if (dockerRunning) {
|
|
595
|
-
try {
|
|
596
|
-
const sessionData = await apiGet("/interactive/status");
|
|
597
|
-
status.dockerSession = sessionData.active ? { active: true, noVncUrl: sessionData.noVncUrl } : { active: false };
|
|
598
|
-
} catch {
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
571
|
try {
|
|
602
572
|
const credData = await localApiGet("/credentials");
|
|
603
573
|
status.credentials = credData.domains || [];
|
|
@@ -659,18 +629,18 @@ var import_zod3 = require("zod");
|
|
|
659
629
|
var import_zod2 = require("zod");
|
|
660
630
|
var stepSchema = import_zod2.z.discriminatedUnion("type", [
|
|
661
631
|
import_zod2.z.object({ type: import_zod2.z.literal("navigate"), url: import_zod2.z.string(), waitUntil: import_zod2.z.string().optional() }),
|
|
662
|
-
import_zod2.z.object({ type: import_zod2.z.literal("click"), selector: import_zod2.z.string() }),
|
|
663
|
-
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' \u2014 always use fill for form fields, not evaluate.") }),
|
|
632
|
+
import_zod2.z.object({ type: import_zod2.z.literal("click"), selector: import_zod2.z.string().describe("An @e ref from snapshot/find, an @a: anchor, or plain CSS. Text selectors (text=, :has-text(), :text-is()) are rejected: use a find step for text, then click its @e ref.") }),
|
|
633
|
+
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' \u2014 always use fill for form fields, not evaluate."), force: import_zod2.z.boolean().optional().describe("Bypass the honeypot guard. fill refuses fields no human can reach (offscreen/clipped/transparent) \u2014 only set this when you are certain the field is real.") }),
|
|
664
634
|
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() }),
|
|
665
635
|
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() }),
|
|
666
|
-
import_zod2.z.object({ type: import_zod2.z.literal("human-type"), selector: import_zod2.z.string(), value: import_zod2.z.string(), skipClick: import_zod2.z.boolean().optional().describe("Skip the click-to-focus and type into the already-focused element. Use for editors that blur on a synthetic click (e.g. Draft.js). Either way, typing aborts safely if the target isn't actually focused."), speed: import_zod2.z.enum(["slow", "normal", "fast"]).optional().describe("Typing speed. 'normal' (~130ms/char, realistic, default), 'fast' (~45ms/char) for long non-sensitive text, 'slow' for extra realism.") }),
|
|
636
|
+
import_zod2.z.object({ type: import_zod2.z.literal("human-type"), selector: import_zod2.z.string(), value: import_zod2.z.string(), skipClick: import_zod2.z.boolean().optional().describe("Skip the click-to-focus and type into the already-focused element. Use for editors that blur on a synthetic click (e.g. Draft.js). Either way, typing aborts safely if the target isn't actually focused."), speed: import_zod2.z.enum(["slow", "normal", "fast"]).optional().describe("Typing speed. 'normal' (~130ms/char, realistic, default), 'fast' (~45ms/char) for long non-sensitive text, 'slow' for extra realism."), clear: import_zod2.z.boolean().optional().describe("Replace the field's existing text instead of appending to it.") }),
|
|
667
637
|
import_zod2.z.object({ type: import_zod2.z.literal("evaluate"), expression: import_zod2.z.string() }),
|
|
668
638
|
import_zod2.z.object({ type: import_zod2.z.literal("extract"), expression: import_zod2.z.string() }),
|
|
669
639
|
import_zod2.z.object({ type: import_zod2.z.literal("wait"), ms: import_zod2.z.number() }),
|
|
670
640
|
import_zod2.z.object({ type: import_zod2.z.literal("wait-for"), selector: import_zod2.z.string(), timeout: import_zod2.z.number().optional() }),
|
|
671
641
|
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"), human: import_zod2.z.boolean().optional().describe("Real eased wheel events instead of an instant jump (slower, less bot-obvious)") }),
|
|
672
642
|
import_zod2.z.object({ type: import_zod2.z.literal("select"), selector: import_zod2.z.string(), value: import_zod2.z.string().describe("Option value to pick in a <select> \u2014 native selection, real change events (never set .value via evaluate)") }),
|
|
673
|
-
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() }),
|
|
643
|
+
import_zod2.z.object({ type: import_zod2.z.literal("keyboard"), key: import_zod2.z.string(), meta: import_zod2.z.boolean().optional().describe("Cmd/Ctrl+A selects all in every mode. Other Cmd/Ctrl shortcuts are rejected in extension mode (the relay drops them)."), ctrl: import_zod2.z.boolean().optional(), shift: import_zod2.z.boolean().optional(), alt: import_zod2.z.boolean().optional() }),
|
|
674
644
|
import_zod2.z.object({ type: import_zod2.z.literal("read"), selector: import_zod2.z.string().optional().describe("Element to read text from (CSS or @e ref); omit for the whole body"), maxChars: import_zod2.z.number().optional().describe("Cap returned text length (default 6000)") }),
|
|
675
645
|
import_zod2.z.object({ type: import_zod2.z.literal("upload"), selector: import_zod2.z.string().describe("The <input type=file> (CSS, @e ref, or @a anchor)"), files: import_zod2.z.array(import_zod2.z.string()).describe("Absolute local file path(s) on this machine") }),
|
|
676
646
|
import_zod2.z.object({ type: import_zod2.z.literal("paste"), selector: import_zod2.z.string().optional().describe("Field to paste the OS clipboard into; omit for the focused element. Reliable via CDP insertText where a \u2318V keyboard step isn't.") }),
|
|
@@ -706,8 +676,8 @@ A captcha is blocking this run. Use the "recaptcha" step with an action:
|
|
|
706
676
|
{type:"recaptcha", action:"click"} \u2192 click the "I'm not a robot" checkbox
|
|
707
677
|
{type:"recaptcha", action:"answer", tiles:[...]} \u2192 select tiles + verify + re-check (handles refreshing grids)
|
|
708
678
|
{type:"recaptcha", action:"select"|"verify"} \u2192 manual tile-select / submit, if you need finer control
|
|
709
|
-
{type:"recaptcha", action:"solve"} \u2192 automatic vision solve (
|
|
710
|
-
Or {type:"solve-captcha"} for one-shot auto-detect + solve.
|
|
679
|
+
{type:"recaptcha", action:"solve"} \u2192 automatic vision solve (needs ANTHROPIC_API_KEY)
|
|
680
|
+
Or {type:"solve-captcha"} for one-shot auto-detect + solve (needs ANTHROPIC_API_KEY).
|
|
711
681
|
In binary-headful mode, prefer asking the user to solve it in the visible window.`;
|
|
712
682
|
function captchaBlocked(data) {
|
|
713
683
|
if (data.error?.errorType === "captcha-unsolvable") return true;
|
|
@@ -783,6 +753,13 @@ ${api.domain} (${api.baseUrl})`);
|
|
|
783
753
|
}
|
|
784
754
|
}
|
|
785
755
|
}
|
|
756
|
+
const serverErrors = (data.results || []).flatMap((r) => r.serverErrors || []);
|
|
757
|
+
if (serverErrors.length > 0) {
|
|
758
|
+
lines.push("\n--- Server errors (HTTP >= 400) ---");
|
|
759
|
+
for (const se of serverErrors) {
|
|
760
|
+
lines.push(` [step ${se.stepIndex}] ${se.method} ${se.url} \u2192 ${se.status}${se.body ? `: ${se.body}` : ""}`);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
786
763
|
if (data.error) {
|
|
787
764
|
lines.push("\n--- Failure ---");
|
|
788
765
|
if (typeof data.error === "string") {
|
|
@@ -793,6 +770,10 @@ ${api.domain} (${api.baseUrl})`);
|
|
|
793
770
|
lines.push(`Message: ${data.error.message}`);
|
|
794
771
|
lines.push(`Retryable: ${data.error.retryable}`);
|
|
795
772
|
if (data.error.suggestion) lines.push(`Suggestion: ${data.error.suggestion}`);
|
|
773
|
+
const atFailure = serverErrors.filter((se) => se.stepIndex === data.error?.failedAtStep);
|
|
774
|
+
for (const se of atFailure) {
|
|
775
|
+
lines.push(`Server responded: ${se.method} ${se.url} \u2192 ${se.status}${se.body ? `: ${se.body}` : ""}`);
|
|
776
|
+
}
|
|
796
777
|
if (data.error.pageState?.url) lines.push(`URL at failure: ${data.error.pageState.url}`);
|
|
797
778
|
}
|
|
798
779
|
}
|
|
@@ -828,8 +809,8 @@ Returns: ok, completedSteps, output for snapshot/find/read/extract steps, obstac
|
|
|
828
809
|
screenshotAfterEach: import_zod3.z.boolean().optional().describe("Take a screenshot after every step (expensive)"),
|
|
829
810
|
continueOnObstacle: import_zod3.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
|
|
830
811
|
continueOnError: import_zod3.z.boolean().optional().describe("Continue past failing steps (default: false)"),
|
|
831
|
-
captureApi: import_zod3.z.boolean().optional().describe("Record all API calls (XHR/fetch) the page makes."),
|
|
832
|
-
mode: import_zod3.z.enum(["headless", "binary-headful", "
|
|
812
|
+
captureApi: import_zod3.z.boolean().optional().describe("Record all API calls (XHR/fetch) the page makes. Also surfaces every HTTP >= 400 response per step \u2014 turn it on when a form submit fails with only a vague on-page message, to read the server's real answer."),
|
|
813
|
+
mode: import_zod3.z.enum(["headless", "binary-headful", "extension"]).optional().describe("DO NOT SET unless the user explicitly requests a mode \u2014 iframer auto-selects and auto-escalates. 'extension' drives a real-Chrome tab (requires options.tabId from the `tabs` tool)."),
|
|
833
814
|
autoEscalate: import_zod3.z.boolean().optional().describe("Auto-retry with a stronger mode if blocked (default: true)"),
|
|
834
815
|
instanceId: import_zod3.z.string().optional().describe("Named parallel browser (default 'default') \u2014 distinct ids drive several browsers at once, each with its own session state."),
|
|
835
816
|
tabId: import_zod3.z.number().optional().describe("mode='extension': the real-Chrome tab id to drive (from the `tabs` tool)."),
|
|
@@ -839,6 +820,13 @@ Returns: ok, completedSteps, output for snapshot/find/read/extract steps, obstac
|
|
|
839
820
|
},
|
|
840
821
|
async (params) => {
|
|
841
822
|
try {
|
|
823
|
+
let runWithMode2 = function(mode) {
|
|
824
|
+
return localApiPost("/execute", {
|
|
825
|
+
steps,
|
|
826
|
+
options: { ...params.options, mode: mode || void 0 }
|
|
827
|
+
});
|
|
828
|
+
};
|
|
829
|
+
var runWithMode = runWithMode2;
|
|
842
830
|
const steps = normalizeSteps(params.steps);
|
|
843
831
|
if (params.options?.mode === "extension") {
|
|
844
832
|
const tabId = params.options?.tabId;
|
|
@@ -862,43 +850,9 @@ Returns: ok, completedSteps, output for snapshot/find/read/extract steps, obstac
|
|
|
862
850
|
if (!extResult.ok) return { content: content2, isError: true };
|
|
863
851
|
return { content: content2 };
|
|
864
852
|
}
|
|
865
|
-
const dockerRunning = await isDockerRunning();
|
|
866
|
-
async function runWithMode(mode) {
|
|
867
|
-
if (mode === "docker-headful") {
|
|
868
|
-
if (!dockerRunning) {
|
|
869
|
-
return {
|
|
870
|
-
ok: false,
|
|
871
|
-
completedSteps: 0,
|
|
872
|
-
totalSteps: steps.length,
|
|
873
|
-
results: [],
|
|
874
|
-
finalState: { url: "", title: "" },
|
|
875
|
-
obstacles: [],
|
|
876
|
-
durationMs: 0,
|
|
877
|
-
modeUsed: "docker-headful",
|
|
878
|
-
error: {
|
|
879
|
-
failedAtStep: 0,
|
|
880
|
-
failedStep: steps[0],
|
|
881
|
-
errorType: "action-failed",
|
|
882
|
-
message: "docker-headful mode was requested but the Docker API is not reachable.",
|
|
883
|
-
pageState: { url: "", title: "" },
|
|
884
|
-
suggestion: "Start Docker with `bun run start:docker`, or omit options.mode.",
|
|
885
|
-
retryable: false
|
|
886
|
-
}
|
|
887
|
-
};
|
|
888
|
-
}
|
|
889
|
-
return apiPost("/execute", {
|
|
890
|
-
steps,
|
|
891
|
-
options: { ...params.options, mode: "docker-headful", autoEscalate: false }
|
|
892
|
-
});
|
|
893
|
-
}
|
|
894
|
-
return localApiPost("/execute", {
|
|
895
|
-
steps,
|
|
896
|
-
options: { ...params.options, mode: mode || void 0 }
|
|
897
|
-
});
|
|
898
|
-
}
|
|
899
853
|
let execResult;
|
|
900
854
|
try {
|
|
901
|
-
execResult = await
|
|
855
|
+
execResult = await runWithMode2(params.options?.mode);
|
|
902
856
|
} catch (execErr) {
|
|
903
857
|
const msg = execErr instanceof Error ? execErr.message : String(execErr);
|
|
904
858
|
const isCrash = /ECONNREFUSED|ECONNRESET|EPIPE|socket hang up|fetch failed/i.test(msg);
|
|
@@ -909,7 +863,7 @@ Returns: ok, completedSteps, output for snapshot/find/read/extract steps, obstac
|
|
|
909
863
|
} catch {
|
|
910
864
|
}
|
|
911
865
|
try {
|
|
912
|
-
execResult = await
|
|
866
|
+
execResult = await runWithMode2(params.options?.mode);
|
|
913
867
|
} catch (retryErr) {
|
|
914
868
|
return err(
|
|
915
869
|
`Browser server crashed and retry also failed.
|
|
@@ -925,14 +879,8 @@ Call \`session restart\` to reset, then retry.`
|
|
|
925
879
|
}
|
|
926
880
|
const requestedMode = params.options?.mode;
|
|
927
881
|
if (!execResult.ok && execResult.error?.errorType === "bot-blocked" && params.options?.autoEscalate !== false && !requestedMode) {
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
if (nextMode === "docker-headful" && !dockerRunning) continue;
|
|
931
|
-
log2.info(`Auto-escalating to ${nextMode}`);
|
|
932
|
-
execResult = await runWithMode(nextMode);
|
|
933
|
-
if (execResult.ok) break;
|
|
934
|
-
if (execResult.error?.errorType !== "bot-blocked") break;
|
|
935
|
-
}
|
|
882
|
+
log2.info("Auto-escalating to binary-headful");
|
|
883
|
+
execResult = await runWithMode2("binary-headful");
|
|
936
884
|
}
|
|
937
885
|
const lines = formatExecuteResult(execResult);
|
|
938
886
|
let screenshotUrl = null;
|
|
@@ -980,7 +928,7 @@ During capture, drive the site like a user (open the inbox, send a message\u2026
|
|
|
980
928
|
staleTimeoutMs: import_zod4.z.number().optional().describe("Override the 20s stale-state timeout per step"),
|
|
981
929
|
continueOnObstacle: import_zod4.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
|
|
982
930
|
continueOnError: import_zod4.z.boolean().optional().describe("Continue past failing steps (default: false)"),
|
|
983
|
-
mode: import_zod4.z.enum(["headless", "binary-headful", "
|
|
931
|
+
mode: import_zod4.z.enum(["headless", "binary-headful", "extension"]).optional().describe("Mode override. 'extension' captures a tab already open in the user's real Chrome (requires options.tabId from `tabs`)."),
|
|
984
932
|
tabId: import_zod4.z.number().optional().describe("mode='extension': the real-Chrome tab id (from `tabs`)."),
|
|
985
933
|
clientId: import_zod4.z.string().optional().describe("mode='extension': owning profile's clientId when the tab is ambiguous.")
|
|
986
934
|
}).optional()
|
|
@@ -993,7 +941,6 @@ During capture, drive the site like a user (open the inbox, send a message\u2026
|
|
|
993
941
|
options: { ...params.options, captureApi: true }
|
|
994
942
|
};
|
|
995
943
|
const mode = params.options?.mode;
|
|
996
|
-
const dockerRunning = await isDockerRunning();
|
|
997
944
|
let captureResult;
|
|
998
945
|
if (mode === "extension") {
|
|
999
946
|
if (typeof params.options?.tabId !== "number") {
|
|
@@ -1005,8 +952,6 @@ During capture, drive the site like a user (open the inbox, send a message\u2026
|
|
|
1005
952
|
steps,
|
|
1006
953
|
options: { ...params.options, captureApi: true }
|
|
1007
954
|
});
|
|
1008
|
-
} else if (mode === "docker-headful" && dockerRunning) {
|
|
1009
|
-
captureResult = await apiPost("/execute", execParams);
|
|
1010
955
|
} else {
|
|
1011
956
|
captureResult = await localApiPost("/execute", execParams);
|
|
1012
957
|
}
|
|
@@ -1203,6 +1148,23 @@ stop: save cookies/localStorage and close the browser \u2014 ALWAYS call when br
|
|
|
1203
1148
|
},
|
|
1204
1149
|
async ({ action, mode, urls, instanceId, outputDir }) => {
|
|
1205
1150
|
try {
|
|
1151
|
+
const SPAWNS_BROWSER = ["capture-start", "capture-stop", "get-cookies", "get-auth"];
|
|
1152
|
+
let resolvedMode = mode;
|
|
1153
|
+
if (SPAWNS_BROWSER.includes(action) && !mode) {
|
|
1154
|
+
const live = await localApiGet("/instances").catch(() => ({ instances: [] }));
|
|
1155
|
+
const match = (live.instances || []).find((i) => i.instanceId === (instanceId || "default"));
|
|
1156
|
+
if (match) {
|
|
1157
|
+
if (match.mode === "headless" || match.mode === "binary-headful") resolvedMode = match.mode;
|
|
1158
|
+
} else {
|
|
1159
|
+
const ext = await localApiGet("/extension/status").catch(() => ({ connected: false }));
|
|
1160
|
+
if (ext.connected) {
|
|
1161
|
+
return err(
|
|
1162
|
+
`'${action}' would launch a new browser window, but the iframer extension is connected \u2014 you are probably driving a real Chrome tab. Read that tab instead: execute with options.mode="extension" and options.tabId (from the \`tabs\` tool). If you really want a separate browser, pass mode explicitly. Nothing was launched.`
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
mode = resolvedMode;
|
|
1206
1168
|
if (action === "stop") {
|
|
1207
1169
|
const result = await localApiPost("/interactive/stop").catch(() => ({ ok: true, sessionSaved: false }));
|
|
1208
1170
|
return { content: [{ type: "text", text: `Session stopped. State saved: ${result.sessionSaved ?? false}` }] };
|
|
@@ -1225,13 +1187,6 @@ stop: save cookies/localStorage and close the browser \u2014 ALWAYS call when br
|
|
|
1225
1187
|
parts.push(`Local restart failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1226
1188
|
}
|
|
1227
1189
|
}
|
|
1228
|
-
try {
|
|
1229
|
-
if (await isDockerRunning()) {
|
|
1230
|
-
await apiPost("/browser/restart");
|
|
1231
|
-
parts.push("Docker browser restarted.");
|
|
1232
|
-
}
|
|
1233
|
-
} catch {
|
|
1234
|
-
}
|
|
1235
1190
|
parts.push("Credentials and knowledge cache are untouched. Next execute launches a fresh browser.");
|
|
1236
1191
|
return { content: [{ type: "text", text: parts.join(" ") }] };
|
|
1237
1192
|
}
|
|
@@ -2063,18 +2018,7 @@ function safeJsonLen(v) {
|
|
|
2063
2018
|
}
|
|
2064
2019
|
|
|
2065
2020
|
// src/mcp/server.ts
|
|
2066
|
-
var
|
|
2067
|
-
var INSTRUCTIONS = IS_DEV ? `iframer-dev \u2014 local development instance of iframer (connects to ${BASE_URL}).
|
|
2068
|
-
|
|
2069
|
-
CRITICAL RULES:
|
|
2070
|
-
1. NEVER ask the user "do you have credentials?". Call credentials action=list and read the response.
|
|
2071
|
-
2. NEVER ask the user for passwords in chat. Use credentials action=store (secure form) or the CLI fallback.
|
|
2072
|
-
3. DO NOT present options when you can just act. Check credentials, execute, show results.
|
|
2073
|
-
4. If execute fails, read the error. It tells you exactly what happened and where.
|
|
2074
|
-
5. Call "session" action=stop when done to save session state.
|
|
2075
|
-
|
|
2076
|
-
CAPTCHA: Use the "solve-captcha" step \u2014 auto-detects reCAPTCHA/hCaptcha and solves with vision AI.
|
|
2077
|
-
REVERSE ENGINEERING: Use the "reverse-engineer" tool when the user asks to capture/map APIs.` : `iframer \u2014 browser access for AI agents when normal methods fail.
|
|
2021
|
+
var INSTRUCTIONS = `iframer \u2014 browser access for AI agents when normal methods fail.
|
|
2078
2022
|
|
|
2079
2023
|
CRITICAL RULES:
|
|
2080
2024
|
1. NEVER ask "do you have credentials?". Call credentials action=list and read the response.
|
|
@@ -2091,7 +2035,7 @@ If headless detects Imperva or Akamai, immediately stop headless and pivot to he
|
|
|
2091
2035
|
|
|
2092
2036
|
BROWSER MODES: Don't specify options.mode \u2014 iframer auto-selects and auto-escalates (headless \u2192 binary-headful). Only set a mode if the user explicitly asks.
|
|
2093
2037
|
|
|
2094
|
-
CAPTCHA: In binary-headful mode, ask the user to solve it in the visible window.
|
|
2038
|
+
CAPTCHA: In binary-headful mode, ask the user to solve it in the visible window. Otherwise try the "solve-captcha" step (best effort, needs ANTHROPIC_API_KEY).
|
|
2095
2039
|
|
|
2096
2040
|
REVERSE ENGINEERING: Use the "reverse-engineer" tool when the user asks to capture/map/save API endpoints.`;
|
|
2097
2041
|
var server = new import_mcp.McpServer(
|
package/extension/README.md
CHANGED
|
@@ -81,22 +81,24 @@ build` instead; `iframer update` detects that and says so.)
|
|
|
81
81
|
## Pair it (one time, zero copying)
|
|
82
82
|
|
|
83
83
|
```sh
|
|
84
|
-
iframer install extension
|
|
84
|
+
iframer install extension chrome
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
on macOS, Linux or Windows. That installs a Chrome **native-messaging host**
|
|
88
88
|
(locked to this extension's pinned ID) that hands the extension the pairing
|
|
89
89
|
token straight from `~/.iframer/secret` — every profile and every Chromium-family
|
|
90
90
|
browser (Chrome, Brave, Edge, …) pairs itself, and re-pairs automatically after
|
|
91
|
-
a reinstall or a rotated secret. No copying, ever.
|
|
91
|
+
a reinstall or a rotated secret. No copying, ever. The command also tests the
|
|
92
|
+
host and starts the local server, and tells you if either one fails.
|
|
92
93
|
|
|
93
94
|
Manual fallback (host not installed): click the iframer toolbar icon → paste the
|
|
94
|
-
|
|
95
|
+
output of `iframer extension token` → **Save & connect**.
|
|
95
96
|
|
|
96
|
-
Either way the dot turns green
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
Either way the dot turns green only once the running iframer server has accepted
|
|
98
|
+
the token (the host locates the server; without a host the extension scans ports
|
|
99
|
+
3022–3042 on `127.0.0.1`, the window the server allocates its port from). The
|
|
100
|
+
token is the same machine-local secret the iframer CLI/MCP already use. It is
|
|
101
|
+
only ever sent to `127.0.0.1`.
|
|
100
102
|
|
|
101
103
|
## Use it
|
|
102
104
|
|