# agent-blindfold

Adopt this skill: https://proskills.md/skills/other/bggoranoff-agent-blindfold-skills-agent-blindfold

Page: https://proskills.md/skills/other/bggoranoff-agent-blindfold-skills-agent-blindfold
This document is the public skill bundle for that page. It contains SKILL.md and every helper file the catalog carries. No login is required.

## File: skills/agent-blindfold/SKILL.md
size: 4356
sha: 5657c1a25643480116fcf93467001fa041bd8a01
upstream: https://github.com/bggoranoff/agent-blindfold/blob/main/skills/agent-blindfold/SKILL.md

```text
---
name: agent-blindfold
description: Sign your browser in to a website with a login the user enters in a sign-in window or on a one-time page, so you never see their password, code or sign-in link. Use when a page in your browser asks for a username, password, verification or 2FA code, PIN, security answer or emailed sign-in link, when the browser shows its own login dialog, or when the user is signed out of a site you need.
---

# Agent Blindfold

You never handle the user's secrets. `signin.mjs`, next to this file, shows the user a sign-in window, or a one-time page, that their password manager can fill. It types what they enter into your browser tab itself and submits. You only learn which fields it typed. Run it as shown here; there is no need to read it or check your setup first.

It reaches your Chromium over its DevTools port: `$CDP_URL`, or `http://127.0.0.1:9222`. If you browse in the user's own Chrome through an extension, such as Claude in Chrome, add `--cdp extension`: it then goes through agent-blindfold's companion extension, which the user installs once. It works only in those browsers. If you browse with something else, such as a built-in browser, open the site with the tools that drive one of them instead, such as a Playwright MCP server connected to it, and sign in there.

## 1. Pick the command

Look at the page, then pick a row.

| The page asks for | Command |
|---|---|
| An email, username, password or verification code box | `ask`. It finds the boxes the page shows now; when the site asks for the email first, ask again on the password page. |
| Some characters of a password or memorable word, such as the 2nd, 5th and 7th | `ask password --characters 2,5,7`, or `secret` instead of `password` |
| A PIN, security answer or memorable word | click into the box, then `ask secret --focused --label "<the page's name for it>"` |
| A box `ask` misses or gets wrong, such as one inside an embedded frame | click into it, then `ask <username, password or code> --focused` |
| Clicking a link it emailed | `ask link` |
| The browser's own login dialog, not a web page | `ask --dialog <the URL you opened>` |

Add `--tab <part of the tab's URL>` when more than one tab is open, as there always is in the user's own Chrome.

## 2. Ask

The command is `node <this folder>/signin.mjs ask ...`.

Add `--tunnel`, unless `$SIGNIN_PUBLIC_URL` is set, which gives the link a public address itself. It prints `{"id", "url", ...}` at once. Send the user the `url` in one line. If you run on the user's own Mac, add that a sign-in window opens there too, and either one works. Then run `node <this folder>/signin.mjs wait <id>` with a 10-minute timeout. It returns once they answer.

If `ask` says `--tunnel needs cloudflared`, run it again without `--tunnel` and with `--wait`, and tell the user a sign-in window is opening.

## 3. Read the result

The last line is `{"status": ..., "filled": [...], ...}`.

- `filled`: look at the page.
  - Signed in: carry on.
  - It asks for something more, such as the password after the email, or a code: back to step 1.
  - Wrong password, or `"cleared": true` with no error showing: back to step 1, and tell the user what the site said.
  - A passkey or security key: in the user's own Chrome (`--cdp extension`), click the page's button for it so their device asks them, such as for Touch ID, and tell them to approve it. In a browser of yours, ask the user to do it. Then look again.
  - A CAPTCHA or an approval in another app: ask the user to do it, then look again.
- `failed`, `cancelled` or `expired`: tell the user the `reason` in one line and stop. If `ask` itself says what to do instead, such as clicking into a box, do that and ask again.

## Rules

- Never ask for a password, code or sign-in link in chat. If the user sends one anyway, don't use it; point them to the sign-in window or page.
- Don't look at the page until the command returns. Until then the page may still hold the password.
- Never read back what the script typed: no reading a field's value, no opening the details or body of a request the sign-in sent, no screenshot of a revealed password.
- If the browser offers to save the password, decline.
- The window or page shows the user the exact site it types into. If the tab moves to another site first, nothing is typed: ask again on the new page.
```

## File: skills/agent-blindfold/extension/background.js
size: 9323
sha: 4337688862172c7be6568ac56eaf9c4896d2833b
upstream: https://github.com/bggoranoff/agent-blindfold/blob/main/skills/agent-blindfold/extension/background.js

```text
// Finds and fills login boxes in a tab of this Chrome for signin.mjs, which reaches this worker through the host.
//
// It works through chrome.scripting rather than chrome.debugger: Chrome refuses a debugger, and drops one already
// attached, in any tab that holds another extension's frame, and password managers put one in most login pages.
import {
  DETECT, failed, HELD, LOAD_MS, PAUSE_MS, pickFocused, piece, PROBE, settle, sleep, SUBMIT, TARGETS, TYPE,
} from "./browser.mjs";

const HOST = "io.github.bggoranoff.agent_blindfold";
// What each find chose, kept here so a fill types only into boxes a find picked, and only once.
const asks = new Map();

// The listener is what starts this worker when Chrome starts.
chrome.runtime.onStartup.addListener(() => {});

const bare = (url) => url.split(/[?#]/)[0];

// Takes back the boxes DETECT kept in this extension's own world in the frame, if they are still this find's.
const TAKE = (handle) => {
  globalThis.wired = globalThis.kept?.[handle];
  globalThis.kept = undefined;
  return Boolean(globalThis.wired);
};
// Submits as Enter would: a form with no submit button at all, or a box outside any form with the key itself.
const ENTER = (origin) => {
  const box = globalThis.wired.at(-1);
  if (location.origin !== origin) return;
  if (box.form) {
    // A submit button the site disabled holds the form back from Enter too.
    const held = [...box.form.elements].some((e) => e.type === "submit");
    if (!held) box.form.requestSubmit();
    return;
  }
  for (const type of ["keydown", "keypress", "keyup"]) {
    box.dispatchEvent(new KeyboardEvent(type, { key: "Enter", code: "Enter", keyCode: 13, bubbles: true, cancelable: true }));
  }
};

// The tab's last step, so the next waits for it: another find must never land between a fill's own calls into the page.
const queues = new Map();
function serial(tab, step) {
  const next = (queues.get(tab) ?? Promise.resolve()).then(step, step);
  queues.set(tab, next);
  return next;
}

// Runs a page function in this extension's own world in a frame; undefined when the frame is gone or refuses it.
async function run(tab, frame, func, ...args) {
  const results = await chrome.scripting.executeScript({ target: { tabId: tab, frameIds: [frame] }, func, args }).catch(() => []);
  return results[0]?.result;
}

// Finds the boxes to fill now, in the tab or in the frame the agent clicked into.
async function wire(tab, wanted, options, handle) {
  const frame = options.focused ? await focusedFrame(tab.id) : { frameId: 0 };
  if (frame.reason) return frame;
  const how = options.characters ? "characters" : options.focused ? "focused" : "find";
  // chrome.scripting refuses an undefined argument.
  const positions = options.characters?.split(",").map(Number) ?? null;
  const found = await run(tab.id, frame.frameId, DETECT, how, wanted, positions, handle);
  if (!found) return { reason: "The page did not answer. Try again once it has loaded." };
  if (found.reason) return found;
  const fields = found.fields.map(({ kind, count, label }) => ({ kind, count, label: (kind === "secret" && options.label) || label }));
  return { frame: frame.frameId, origin: found.origin, fields };
}

async function focusedFrame(tab) {
  const results = await chrome.scripting.executeScript({ target: { tabId: tab, allFrames: true }, func: PROBE }).catch(() => []);
  return pickFocused(results.map(({ frameId, result }) => ({ frameId, ...result })));
}

// Types into the boxes the find picked, only while their frame is still on that site, then submits.
async function typeKept(config, values, handle) {
  const { target: tab, frame, origin } = config;
  const here = await run(tab, frame, () => location.origin);
  if (!here) return failed("The frame with the boxes is gone.");
  if (here !== origin) return failed(`The page left ${origin} for ${here}.`);
  const plan = config.fields.flatMap((field) => Array.from({ length: field.count }, (_, i) => ({
    kind: field.kind, text: piece(field.count, values[field.kind], i, config.characters),
  })));
  if (plan.some((box) => box.text === undefined)) return failed("The answer is shorter than the boxes it has to fill.");
  if (!(await run(tab, frame, TAKE, handle))) return failed("The page changed after you were asked, so nothing was typed.");
  const outcome = await run(tab, frame, TYPE, origin, plan.map((box) => box.text), plan.map((box) => box.kind));
  if (!outcome) return failed("The page changed after you were asked, so nothing was typed.");
  if (outcome.moved) return failed(`The page left ${origin} for ${outcome.moved}.`);
  if (outcome.reason) return failed(outcome.reason);
  // This side cannot watch the page's requests, so the submit waits a person's pause for the page to check the boxes.
  if (config.fields.length > 1) await sleep(PAUSE_MS);
  if ((await run(tab, frame, SUBMIT, origin)) === false) await run(tab, frame, ENTER, origin);
  const secrets = plan.filter((box) => box.kind !== "username").map((box) => box.text);
  const cleared = await settle((empty) => run(tab, frame, HELD, origin, secrets, empty));
  return { status: "filled", filled: config.fields.map((field) => field.kind), ...(cleared && { cleared: true }) };
}

// Opens the link the site emailed in the agent's tab.
async function openLink(config, values) {
  const url = URL.parse(values.link);
  if (!["http:", "https:"].includes(url?.protocol)) return failed("That is not a web link.");
  await chrome.tabs.update(config.target, { url: url.href });
  return { status: "filled", filled: ["link"] };
}

// Answers the browser's own login dialog (HTTP authentication) while the tab loads the page, for its origin only.
async function answerDialog(config, values) {
  let asked = 0;
  const reply = (details, callback) => {
    const ours = !details.isProxy && new URL(details.url).origin === config.origin;
    // A second challenge means the site turned the first answer down, so it is not sent again.
    const credentials = ours && asked++ === 0 && { username: values.username, password: values.password };
    callback(credentials ? { authCredentials: credentials } : { cancel: true });
  };
  let started = false;
  let done;
  const loaded = new Promise((resolve) => (done = resolve));
  const watch = (tab, change) => {
    if (tab !== config.target) return;
    started ||= change.status === "loading";
    if (started && change.status === "complete") done();
  };
  chrome.tabs.onUpdated.addListener(watch);
  chrome.webRequest.onAuthRequired.addListener(reply, { urls: ["<all_urls>"], tabId: config.target }, ["asyncBlocking"]);
  try {
    await chrome.tabs.update(config.target, { url: config.dialog });
    await Promise.race([loaded, sleep(LOAD_MS)]);
  } finally {
    chrome.tabs.onUpdated.removeListener(watch);
    chrome.webRequest.onAuthRequired.removeListener(reply);
  }
  if (asked === 1) return { status: "filled", filled: ["username", "password"] };
  return failed(asked ? "The site turned that username and password down." : `${config.dialog} did not ask for a login.`);
}

// The only steps anything that reaches the host can ask for: no page content, scripts or cookies ever come back.
const STEPS = {
  tab: async ({ text }) => {
    const matches = (await chrome.tabs.query({})).filter((tab) => tab.url?.includes(text));
    return matches.length === 1 ? { id: matches[0].id, url: bare(matches[0].url) } : { count: matches.length };
  },
  find: async ({ tab, kind, wanted, options }) => {
    const page = await chrome.tabs.get(tab).catch(() => undefined);
    if (!page) return { reason: "The browser tab is gone." };
    const handle = crypto.randomUUID();
    const found = await serial(tab, () => (kind === "boxes"
      ? wire(page, wanted, options, handle)
      : TARGETS[kind](undefined, page, wanted, options)));
    if (found.reason) return found;
    const characters = options.characters?.split(",").map(Number);
    asks.set(handle, { ...found, target: tab, dialog: options.dialog, characters });
    const fields = found.fields.map(({ kind: field, label }) => ({ kind: field, label }));
    return { handle, frame: found.frame, origin: found.origin, fields };
  },
  fill: async ({ handle, values }) => {
    const config = asks.get(handle);
    asks.delete(handle);
    if (!config) return failed("The page changed after you were asked, so nothing was typed.");
    const mode = config.dialog ? answerDialog : values.link ? openLink : typeKept;
    return serial(config.target, () => mode(config, values, handle));
  },
};

async function answer({ client, step, ...request }, from) {
  const run = STEPS[step] ?? (async () => failed(`agent-blindfold has no step ${step}.`));
  const result = await run(request).catch((error) => failed(error.message));
  from.postMessage({ client, ...result });
}

// The open port keeps this worker, and the host Chrome started for it, alive while Chrome runs.
let port;
function connect() {
  port = chrome.runtime.connectNative(HOST);
  port.onMessage.addListener(answer);
  port.onDisconnect.addListener(() => (port = undefined));
}

// A host that exited or failed to start is retried each minute; the alarm also wakes this worker if Chrome stopped it.
chrome.alarms.create("host", { periodInMinutes: 1 });
chrome.alarms.onAlarm.addListener(() => port || connect());
connect();
```

## File: skills/agent-blindfold/extension/browser.mjs
size: 18319
sha: 4d327a84834ffa1ba72601586737c84b7fdd5e18
upstream: https://github.com/bggoranoff/agent-blindfold/blob/main/skills/agent-blindfold/extension/browser.mjs

```text
// The browser half of agent-blindfold: finds the login boxes in a tab and fills them. signin.mjs runs it over a DevTools
// port, and the companion extension runs its page functions in the user's own Chrome with chrome.scripting.

export const POLL_MS = 100;
const SETTLE_MS = 10000;
export const LOAD_MS = 15000;
// Sites check a box when it loses focus and hold back a submit until that check is done, so a submit waits for the
// page's requests to go quiet, for at most as long as a person's pause.
export const PAUSE_MS = 1000;
const QUIET_MS = 150;

// Finds the boxes to ask for, when the user is asked, and keeps them in globalThis.wired for the fill to reach.
export const DETECT = (how, wanted, positions, key) => {
  // Password managers' and sites' helper fields render but are aria-hidden; no person can see or reach them.
  const shown = (e) => e.getClientRects().length && !e.disabled && !e.readOnly && !e.closest("[aria-hidden=true]")
    && getComputedStyle(e).visibility !== "hidden" && !["hidden", "submit", "button", "checkbox", "radio"].includes(e.type);
  const inputs = [...document.querySelectorAll("input")].filter(shown);
  const hint = (e) => [e.name, e.id, e.placeholder, e.getAttribute("aria-label"), e.autocomplete].join(" ").toLowerCase();
  const label = (e) => (e.labels?.[0]?.innerText || e.getAttribute("aria-label") || e.placeholder || "").trim().slice(0, 80);
  const find = () => {
    const passwords = inputs.filter((e) => e.type === "password");
    if (passwords.length > 1) return { reason: "The page shows more than one password box. Click into the right one and ask with --focused." };
    const [password] = passwords;
    const boxes = inputs.filter((e) => e.maxLength === 1);
    const code = inputs.find((e) => e.autocomplete === "one-time-code")
      || inputs.find((e) => e !== password && /otp|code|pin|verif|2fa|mfa|token/.test(hint(e)));
    const beforePassword = inputs.slice(0, Math.max(inputs.indexOf(password), 0))
      .filter((e) => ["text", "email", "tel"].includes(e.type));
    const username = inputs.find((e) => /username|email/.test(e.autocomplete)) || inputs.find((e) => e.type === "email")
      || inputs.find((e) => e !== code && e !== password && /user|email|login|account|identifier/.test(hint(e)))
      || beforePassword.pop();
    const all = { username: username && [username], password: password && [password], code: boxes.length >= 4 ? boxes : code && [code] };
    // A code box only counts on its own page, so a sign-in form with a stray "code" box asks for no code.
    const kinds = wanted.length ? wanted : username || password ? ["username", "password"] : ["code"];
    const missing = wanted.filter((kind) => !all[kind]);
    if (missing.length) return { reason: `No ${missing.join(" or ")} box shows on this page.` };
    const fields = kinds.filter((kind) => all[kind]).map((kind) => ({ kind, boxes: all[kind] }));
    return fields.length ? fields : { reason: "No login box shows on this page. Click into the box and ask with --focused." };
  };
  const focused = () => {
    const box = document.activeElement;
    if (!inputs.includes(box)) return { reason: "No box is selected. Click into the box, then ask again." };
    if (wanted[0] === "password" && box.type !== "password") return { reason: "The selected box is not a password box." };
    return [{ kind: wanted[0], boxes: [box] }];
  };
  const characters = () => {
    // A password's characters go only into boxes that hide them; a memorable word's may go into boxes that show.
    const fit = wanted[0] === "password" ? inputs.filter((e) => e.type === "password") : inputs;
    const boxes = [fit.filter((e) => e.maxLength === 1), fit.filter((e) => e.type === "password")]
      .find((list) => list.length === positions.length);
    return boxes ? [{ kind: wanted[0], boxes }] : { reason: `No ${positions.length} boxes for the characters show on this page.` };
  };
  const picked = { find, focused, characters }[how]();
  if (picked.reason) return picked;
  globalThis.wired = picked.flatMap((field) => field.boxes);
  // The companion extension keeps only its latest find's boxes, for that find's one fill.
  if (key) globalThis.kept = { [key]: globalThis.wired };
  const fields = picked.map(({ kind, boxes }) => ({ kind, count: boxes.length, label: boxes.length > 1 ? "" : label(boxes[0]) }));
  return { origin: location.origin, fields };
};
// Types into the boxes found when the user was asked, only if the frame is still on that site and every box is still in
// the page; a password goes only into a box that still hides it.
export function TYPE(origin, texts, kinds, ...boxes) {
  if (location.origin !== origin) return { moved: location.origin };
  // The companion extension cannot pass boxes in, so its own world in the frame holds the ones the find picked.
  if (!boxes.length) boxes = globalThis.wired;
  if (!boxes.every((e) => e.isConnected)) return { reason: "The page changed after you were asked, so nothing was typed." };
  if (boxes.some((e, i) => kinds[i] === "password" && e.type !== "password")) {
    return { reason: "The password box no longer hides what is typed, so nothing was typed." };
  }
  const set = (e, text) => Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(e, text);
  for (const [i, e] of boxes.entries()) {
    e.focus();
    e.select();
    // The page's own focus handlers have run by now, and could have moved the cursor or revealed the box.
    const safe = e.isConnected && document.activeElement === e && (kinds[i] !== "password" || e.type === "password");
    if (!safe) {
      boxes.slice(0, i).forEach((typed) => set(typed, ""));
      return { reason: "The page moved the cursor while typing, so what was typed was taken out again." };
    }
    if (document.execCommand("insertText", false, texts[i])) continue;
    set(e, texts[i]);
    e.dispatchEvent(new Event("input", { bubbles: true }));
    e.dispatchEvent(new Event("change", { bubbles: true }));
  }
  return {};
}
// Clicks the submit button of the box's form, as some sites ignore a synthetic Enter; false when there is none to click.
export function SUBMIT(origin, box = globalThis.wired.at(-1)) {
  if (location.origin !== origin) return true;
  const button = box.form && [...box.form.elements].find((e) => e.type === "submit" && !e.disabled);
  button?.click();
  return Boolean(button);
}
// Finds inputs still holding a typed secret, and empties them when asked.
export const HELD = (origin, secrets, empty) => {
  if (location.origin !== origin) return 0;
  const held = [...document.querySelectorAll("input")].filter((e) => secrets.includes(e.value));
  if (empty) held.forEach((e) => Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(e, ""));
  return held.length;
};
export const PROBE = () => ({ active: document.activeElement?.tagName === "INPUT", focus: document.hasFocus() });

export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export const failed = (reason) => ({ status: "failed", reason, filled: [] });

// What each kind of ask fills: the boxes the page shows, a link to open, or the browser's own login dialog.
export const TARGETS = {
  link: async (browser, tab) => ({ frame: tab.id, origin: new URL(tab.url).origin, fields: [{ kind: "link" }] }),
  dialog: async (browser, tab, wanted, options) => ({
    frame: tab.id, origin: new URL(options.dialog).origin, fields: [{ kind: "username" }, { kind: "password" }],
  }),
  boxes: wire,
};

// Finds the boxes to fill now, in the tab or in the frame the agent clicked into, and keeps DevTools references to
// them, so the fill types into these boxes and nowhere else.
async function wire(browser, tab, wanted, options) {
  const frame = options.focused ? await focusedFrame(browser) : { id: tab.id };
  if (frame.reason) return frame;
  const contextId = await world(browser, frame);
  const how = options.characters ? "characters" : options.focused ? "focused" : "find";
  const positions = options.characters?.split(",").map(Number);
  const args = [how, wanted, positions].map((arg) => JSON.stringify(arg)).join(", ");
  const found = await run(browser, frame, contextId, `(${DETECT})(${args})`);
  if (!found) return { reason: "The page did not answer. Try again once it has loaded." };
  if (found.reason) return found;
  const total = found.fields.reduce((sum, field) => sum + field.count, 0);
  const nodes = await Promise.all(Array.from({ length: total }, (_, i) => backendNode(browser, frame, contextId, i)));
  let next = 0;
  const fields = found.fields.map(({ kind, count, label }) => {
    const boxes = nodes.slice(next, next + count);
    next += count;
    return { kind, label: (kind === "secret" && options.label) || label, boxes };
  });
  return { frame: frame.id, origin: found.origin, fields };
}

// The frame where the agent left the cursor: the tab itself, or a frame inside it, even one from another site.
async function focusedFrame(browser) {
  const all = await frames(browser);
  return pickFocused(await Promise.all(all.map(async (frame) => ({ ...frame, ...(await evaluate(browser, frame, PROBE)) }))));
}

// Of each frame's answer to PROBE, the one frame with a box holding the cursor.
export function pickFocused(probes) {
  const typing = probes.filter((probe) => probe.active);
  const picked = typing.length > 1 ? typing.filter((probe) => probe.focus) : typing;
  if (picked.length !== 1) return { reason: "No box is selected. Click into the box, then ask again with --focused." };
  return picked[0];
}

// Every frame in the tab, with the DevTools session that can reach it; frames from other sites live in their own.
async function frames(browser) {
  const sessions = [undefined];
  browser.on("Target.attachedToTarget", ({ sessionId, targetInfo }) => targetInfo.type === "iframe" && sessions.push(sessionId));
  await browser.send("Target.setAutoAttach", { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
  const trees = await Promise.all(sessions.map(async (sessionId) => {
    const { frameTree } = await browser.send("Page.getFrameTree", {}, sessionId);
    return flatten(frameTree).map((frame) => [frame.id, { id: frame.id, sessionId }]);
  }));
  // A frame from another site shows up in its parent's tree too, but only its own session can run code in it.
  return [...new Map(trees.flat()).values()];
}

const flatten = (tree) => (tree ? [tree.frame, ...(tree.childFrames ?? []).flatMap(flatten)] : []);

// A fresh isolated world in the frame, where the page's own scripts cannot see or change what runs.
async function world(browser, frame) {
  const params = { frameId: frame.id, worldName: "agent-blindfold" };
  return (await browser.send("Page.createIsolatedWorld", params, frame.sessionId)).executionContextId;
}

async function run(browser, frame, contextId, expression) {
  if (!contextId) return undefined;
  const { result } = await browser.send("Runtime.evaluate", { expression, contextId, returnByValue: true }, frame.sessionId);
  return result?.value;
}

async function evaluate(browser, frame, source, ...args) {
  const contextId = await world(browser, frame);
  return run(browser, frame, contextId, `(${source})(${args.map((arg) => JSON.stringify(arg)).join(", ")})`);
}

// A reference to a wired box that stays valid across DevTools connections for as long as the box exists.
async function backendNode(browser, frame, contextId, index) {
  const { result } = await browser.send("Runtime.evaluate", { expression: `globalThis.wired[${index}]`, contextId }, frame.sessionId);
  const { node } = await browser.send("DOM.describeNode", { objectId: result.objectId }, frame.sessionId);
  return node.backendNodeId;
}

async function resolve(browser, frame, contextId, backendNodeId) {
  const { object } = await browser.send("DOM.resolveNode", { backendNodeId, executionContextId: contextId }, frame.sessionId);
  return object?.objectId;
}

// Types, opens or answers what the user entered, in the tab the ask was for.
export function fill(browser, config, values) {
  const mode = config.dialog ? answerDialog : values.link ? openLink : typeWired;
  return mode(browser, config, values);
}

// What goes into one box of a field: the whole answer, one digit of a split code, or one picked character.
export function piece(count, text, index, positions) {
  if (positions) return text[positions[index] - 1];
  return count > 1 ? text[index] : text;
}

async function typeWired(browser, config, values) {
  const frame = (await frames(browser)).find((candidate) => candidate.id === config.frame);
  if (!frame) return failed("The frame with the boxes is gone.");
  const here = await evaluate(browser, frame, "() => location.origin");
  if (here !== config.origin) return failed(`The page left ${config.origin} for ${here}.`);
  const plan = config.fields.flatMap((field) => field.boxes.map((node, i) => ({
    node, kind: field.kind, text: piece(field.boxes.length, values[field.kind], i, config.characters),
  })));
  if (plan.some((box) => box.text === undefined)) return failed("The answer is shorter than the boxes it has to fill.");
  const quiet = await requests(browser);
  const contextId = await world(browser, frame);
  const boxes = await Promise.all(plan.map((box) => resolve(browser, frame, contextId, box.node)));
  if (boxes.includes(undefined)) return failed("The page changed after you were asked, so nothing was typed.");
  const args = [config.origin, plan.map((box) => box.text), plan.map((box) => box.kind)].map((value) => ({ value }));
  const params = { functionDeclaration: String(TYPE), executionContextId: contextId, returnByValue: true };
  const { result } = await browser.send("Runtime.callFunctionOn", {
    ...params, arguments: [...args, ...boxes.map((objectId) => ({ objectId }))],
  }, frame.sessionId);
  const outcome = result?.value;
  if (!outcome) return failed("The page changed after you were asked, so nothing was typed.");
  if (outcome.moved) return failed(`The page left ${config.origin} for ${outcome.moved}.`);
  if (outcome.reason) return failed(outcome.reason);
  if (config.fields.length > 1) await quiet();
  await submit(browser, frame, config.origin, boxes.at(-1));
  const secrets = plan.filter((box) => box.kind !== "username").map((box) => box.text);
  const cleared = await settle((empty) => evaluate(browser, frame, HELD, config.origin, secrets, empty));
  return { status: "filled", filled: config.fields.map((field) => field.kind), ...(cleared && { cleared: true }) };
}

// Tracks the page's requests in flight; the function it returns waits until none has run for a moment.
async function requests(browser) {
  const open = new Set();
  browser.on("Network.requestWillBeSent", ({ requestId }) => open.add(requestId));
  browser.on("Network.loadingFinished", ({ requestId }) => open.delete(requestId));
  browser.on("Network.loadingFailed", ({ requestId }) => open.delete(requestId));
  await browser.send("Network.enable", {});
  return async () => {
    let calm = Date.now();
    for (const deadline = calm + PAUSE_MS; Date.now() < deadline && Date.now() - calm < QUIET_MS; await sleep(25)) {
      if (open.size) calm = Date.now();
    }
  };
}

// Page snapshots show what a password box holds, so nothing typed may still sit in the page when the agent looks; held
// runs HELD in the frame.
export async function settle(held) {
  for (const deadline = Date.now() + SETTLE_MS; Date.now() < deadline; await sleep(POLL_MS)) {
    if (!((await held(false)) ?? 1)) return false;
  }
  return Boolean(await held(true));
}

// Presses Enter only when the box's form has no button to click; a page that already moved on gets nothing.
async function submit(browser, frame, origin, box) {
  const args = [{ value: origin }, { objectId: box }];
  const params = { functionDeclaration: String(SUBMIT), objectId: box, arguments: args, returnByValue: true };
  const { result } = await browser.send("Runtime.callFunctionOn", params, frame.sessionId);
  if (result?.value !== false) return;
  const key = { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 };
  await browser.send("Input.dispatchKeyEvent", { type: "keyDown", text: "\r", ...key });
  await browser.send("Input.dispatchKeyEvent", { type: "keyUp", ...key });
}

// Opens the link the site emailed in the agent's tab; it signs the tab in, wherever the link's tracking redirects go.
async function openLink(browser, config, values) {
  const url = URL.parse(values.link);
  if (!["http:", "https:"].includes(url?.protocol)) return failed("That is not a web link.");
  const { frameId, errorText } = await browser.send("Page.navigate", { url: url.href });
  if (!frameId || errorText) return failed(errorText ?? "The link did not open.");
  return { status: "filled", filled: ["link"] };
}

// Answers the browser's own login dialog (HTTP authentication), only for the origin the page named.
async function answerDialog(browser, config, values) {
  let asked = 0;
  const ignore = () => {};
  browser.on("Fetch.requestPaused", ({ requestId }) => browser.send("Fetch.continueRequest", { requestId }).catch(ignore));
  browser.on("Fetch.authRequired", ({ requestId, request, authChallenge }) => {
    const ours = authChallenge.source === "Server" && new URL(request.url).origin === config.origin;
    // A second challenge means the site turned the first answer down, so it is not sent again.
    const answer = ours && asked++ === 0;
    const credentials = { response: "ProvideCredentials", username: values.username, password: values.password };
    const authChallengeResponse = answer ? credentials : { response: "CancelAuth" };
    browser.send("Fetch.continueWithAuth", { requestId, authChallengeResponse }).catch(ignore);
  });
  await browser.send("Fetch.enable", { handleAuthRequests: true });
  await browser.send("Page.enable");
  const loaded = new Promise((resolve) => browser.on("Page.loadEventFired", resolve));
  await browser.send("Page.navigate", { url: config.dialog });
  await Promise.race([loaded, sleep(LOAD_MS)]);
  await browser.send("Fetch.disable");
  if (asked === 1) return { status: "filled", filled: ["username", "password"] };
  return failed(asked ? "The site turned that username and password down." : `${config.dialog} did not ask for a login.`);
}
```

## File: skills/agent-blindfold/extension/host.mjs
size: 2348
sha: c19827d2a87fc481dc87364ec67a4dcb41173609
upstream: https://github.com/bggoranoff/agent-blindfold/blob/main/skills/agent-blindfold/extension/host.mjs

```text
// Chrome starts this for the agent-blindfold extension; it carries messages between the extension and signin.mjs.
import { mkdirSync, rmSync } from "node:fs";
import { createConnection, createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createInterface } from "node:readline";

const DIRECTORY = join(tmpdir(), "agent-blindfold");
const SOCKET = join(DIRECTORY, "extension.sock");
const RETRY_MS = 5000;
const clients = new Map();
let next = 0;
let input = Buffer.alloc(0);

// Native messaging frames each message with a 4-byte length in native byte order, little-endian wherever Chrome runs.
function post(message) {
  const body = Buffer.from(JSON.stringify(message));
  const length = Buffer.alloc(4);
  length.writeUInt32LE(body.length);
  process.stdout.write(Buffer.concat([length, body]));
}

process.stdin.on("data", (chunk) => {
  input = Buffer.concat([input, chunk]);
  while (input.length >= 4 && input.length >= 4 + input.readUInt32LE(0)) {
    const end = 4 + input.readUInt32LE(0);
    const { client, ...message } = JSON.parse(input.toString("utf8", 4, end));
    input = input.subarray(end);
    clients.get(client)?.write(`${JSON.stringify(message)}\n`);
  }
});
// Chrome closes the pipe when the extension lets go of the host.
process.stdin.on("end", () => process.exit());

const server = createServer((socket) => {
  const client = ++next;
  clients.set(client, socket);
  createInterface({ input: socket }).on("line", (line) => post({ client, ...JSON.parse(line) }));
  // A signin.mjs that exits mid-message must not take the host down with it.
  socket.on("error", () => {});
  socket.on("close", () => clients.delete(client));
});

// Another Chrome profile's host may already serve the socket: this one waits for it to go, and only clears a dead one.
function claim() {
  server.once("error", async (error) => {
    if (error.code !== "EADDRINUSE") throw error;
    const live = await new Promise((resolve) => {
      createConnection(SOCKET).on("connect", function () {
        this.destroy();
        resolve(true);
      }).on("error", () => resolve(false));
    });
    if (!live) rmSync(SOCKET, { force: true });
    setTimeout(claim, live ? RETRY_MS : 0);
  });
  server.listen(SOCKET);
}

mkdirSync(DIRECTORY, { recursive: true, mode: 0o700 });
claim();
```

## File: skills/agent-blindfold/extension/icons/128.png
size: 9423
sha: d539746246ccec38190db5dc6a26fd691ff6a0ac
upstream: https://github.com/bggoranoff/agent-blindfold/blob/main/skills/agent-blindfold/extension/icons/128.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/bggoranoff/agent-blindfold/blob/main/skills/agent-blindfold/extension/icons/128.png

## File: skills/agent-blindfold/extension/icons/16.png
size: 1131
sha: bb6bce5143e944e77356a99935acaf10e5735874
upstream: https://github.com/bggoranoff/agent-blindfold/blob/main/skills/agent-blindfold/extension/icons/16.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/bggoranoff/agent-blindfold/blob/main/skills/agent-blindfold/extension/icons/16.png

## File: skills/agent-blindfold/extension/icons/32.png
size: 2012
sha: c22fac803dc4d348ba3467271910633a21570d02
upstream: https://github.com/bggoranoff/agent-blindfold/blob/main/skills/agent-blindfold/extension/icons/32.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/bggoranoff/agent-blindfold/blob/main/skills/agent-blindfold/extension/icons/32.png

## File: skills/agent-blindfold/extension/icons/48.png
size: 3210
sha: bbf05b9116252cfaf931244df4608df165bbe5c1
upstream: https://github.com/bggoranoff/agent-blindfold/blob/main/skills/agent-blindfold/extension/icons/48.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/bggoranoff/agent-blindfold/blob/main/skills/agent-blindfold/extension/icons/48.png

## File: skills/agent-blindfold/extension/manifest.json
size: 971
sha: 30886e56aa1d17918d892704ec0d4944d59831a6
upstream: https://github.com/bggoranoff/agent-blindfold/blob/main/skills/agent-blindfold/extension/manifest.json

```text
{
  "manifest_version": 3,
  "name": "Agent Blindfold",
  "version": "0.1.0",
  "description": "Lets Agent Blindfold find and fill login boxes in a tab of this Chrome with what you entered on its one-time page.",
  "minimum_chrome_version": "126",
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtDxrZkrNki7vB1x7ljYrJvqLpJSdbGlozLKSiz54VHtmG3J/mAzKDzw8Re3KWFCQpat2yaZq1a6bKmYwJwQD0BwYiChOTS2l5gSMXWS70t0wty7Kd8cpgGZhAItGegSCCrjjmrHLJD4TaOGOOeAEcKbto6v0if/MEl5nI5Q0fY5m4sMC7ot3UTaFDDr8G7DznfRbNU+0pRfbkAAEl1l24u8aNl+HXovXHJNPufbXrhh0HWXaCRJjImq8djN8pkbH9fVWwX6jq84TGkesgI9T6buPJxZUxtktSf4ldVlw9KIK79NCqTYwbUb1jzpeZf5DPZJYFs1uh4x+9yoAxc9JTQIDAQAB",
  "icons": { "16": "icons/16.png", "32": "icons/32.png", "48": "icons/48.png", "128": "icons/128.png" },
  "permissions": ["alarms", "nativeMessaging", "scripting", "webRequest", "webRequestAuthProvider"],
  "host_permissions": ["<all_urls>"],
  "background": { "service_worker": "background.js", "type": "module" }
}
```

## File: skills/agent-blindfold/signin.mjs
size: 27626
sha: 9c6027faf581e38fed3afef6ba97d81e9f523e4f
upstream: https://github.com/bggoranoff/agent-blindfold/blob/main/skills/agent-blindfold/signin.mjs

```text
#!/usr/bin/env node
// Hand a login from the user to the agent's browser on a one-time page, without the agent ever seeing it.
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import dns from "node:dns/promises";
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync, writeSync } from "node:fs";
import { createServer } from "node:http";
import { createConnection } from "node:net";
import { homedir, tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";
import { createInterface } from "node:readline";
import { json } from "node:stream/consumers";
import { setTimeout as sleep } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
import { failed, fill as fillTab, POLL_MS, TARGETS } from "./extension/browser.mjs";

const USAGE = `Usage:
  signin.mjs ask [<fields>] [--tab <text>] [--focused] [--characters <n,n,...>] [--label <text>] [--wait]
                 [--cdp <url> | --cdp extension] [--public <https url> | --tunnel] [--port <n>] [--minutes <n>]
  signin.mjs ask link [--tab <text>] [--wait] ...
  signin.mjs ask --dialog <url> [--tab <text>] [--wait] ...
  signin.mjs wait <id>
  signin.mjs install [<browser profile folder>]
  signin.mjs setup

Without <fields>, ask finds the login boxes the page shows now. <fields> is a comma list of username, password,
code and secret, to ask for only those.`;
const SELF = fileURLToPath(import.meta.url);
const FIELDS = ["username", "password", "code", "secret", "link"];
// Each rule is a combination of fields and options that cannot work, and what to do instead.
const RULES = [
  [(fields) => fields.includes("link") && fields.length > 1, "Ask for link on its own."],
  [(fields, o) => (o.focused || o.characters) && fields.length !== 1, "--focused and --characters take one field, such as ask password --focused."],
  [(fields, o) => o.characters && !/^\d+(,\d+)*$/.test(o.characters), "--characters takes positions, such as 2,5,7."],
  [(fields, o) => o.characters && !["password", "secret"].includes(fields[0]), "--characters works with password or secret."],
  [(fields, o) => fields.includes("secret") && !o.focused && !o.characters, "Click into the box, then ask for secret with --focused."],
  [(fields, o) => o.dialog && fields.length && fields.join() !== "username,password", "--dialog asks for the username and password itself."],
];
const LABELS = { username: "Email or username", password: "Password", code: "Verification code", secret: "Answer", link: "Sign-in link from your email" };
const STATE = join(tmpdir(), "agent-blindfold");
// Where the companion extension's native host, extension/host.mjs, listens for the user's own Chrome.
const EXTENSION = join(STATE, "extension.sock");
const HOST = "io.github.bggoranoff.agent_blindfold";
const EXTENSION_ID = "ahgehaammfeiemempinnlmodhpjdaohm";
// Google Chrome's profile folder, under the home folder, where Chrome looks for native messaging hosts.
const CHROME_PROFILES = { darwin: "Library/Application Support/Google/Chrome", linux: ".config/google-chrome" };
const PUBLIC_PORT = 8769;
const COMMAND_MS = 10000;
const TUNNEL_MS = 30000;

// Written synchronously: the process often exits right after, and a pending write to an agent's pipe would be lost.
const say = (fd, text) => writeSync(fd, `${text}\n`);

function fail(message) {
  say(2, message);
  process.exit(2);
}

function readState(id) {
  const path = join(STATE, `${id}.json`);
  return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null;
}

function writeState(id, state) {
  mkdirSync(STATE, { recursive: true, mode: 0o700 });
  const path = join(STATE, `${id}.json`);
  writeFileSync(`${path}.partial`, JSON.stringify(state), { mode: 0o600 });
  renameSync(`${path}.partial`, path);
}

async function pages(cdp) {
  const response = await fetch(`${cdp}/json/list`).catch(() => {
    throw new Error(`No browser DevTools endpoint at ${cdp}.`);
  });
  return (await response.json()).filter((target) => target.type === "page");
}

// A tab's address without its query or fragment, where sign-in links and OAuth codes keep their tokens.
const bare = (url) => url.split(/[?#]/)[0];

async function pickTab(cdp, text = "") {
  if (cdp === "extension") return extensionTab(text);
  const all = await pages(cdp);
  const matches = all.filter((tab) => tab.url.includes(text));
  if (matches.length !== 1) {
    fail(`${matches.length} tabs match ${JSON.stringify(text)}. Pass --tab with part of one URL:\n`
      + all.map((tab) => bare(tab.url)).join("\n"));
  }
  return matches[0];
}

// The user's own Chrome holds all their tabs, so the extension names only the one tab that matches.
async function extensionTab(text) {
  const tab = await extension({ step: "tab", text });
  if (!tab.id) fail(`${tab.count} tabs match ${JSON.stringify(text)}. Pass --tab with part of one URL.`);
  return tab;
}

async function ask(fields, options) {
  const wanted = fields?.split(",") ?? [];
  if (!wanted.every((field) => FIELDS.includes(field))) fail(USAGE);
  const broken = RULES.find(([breaks]) => breaks(wanted, options));
  if (broken) fail(broken[1]);
  if (options.public && !options.public.startsWith("https://")) fail("--public must be an https:// URL.");
  const tab = await pickTab(options.cdp, options.tab);
  const top = new URL(options.dialog ?? tab.url).origin;
  if (!/^https?:/.test(top)) fail(`The tab is on ${bare(tab.url)}, not a website.`);
  const kind = wanted.includes("link") ? "link" : options.dialog ? "dialog" : "boxes";
  const target = await prepare(tab, kind, wanted, options);
  const id = randomBytes(6).toString("hex");
  const local = !options.public && !options.tunnel;
  const native = process.platform === "darwin" && !process.env.BROWSER;
  const config = {
    id, top, ...target, label: options.label, characters: options.characters?.split(",").map(Number),
    dialog: options.dialog, cdp: options.cdp, target: tab.id, token: randomBytes(32).toString("base64url"),
    public: options.public, tunnel: options.tunnel, minutes: Number(options.minutes), native,
    port: Number(options.port ?? (options.public ? PUBLIC_PORT : 0)),
  };
  // The token goes over stdin rather than argv, where `ps` would show it.
  const server = spawn(process.execPath, [SELF, "serve"], { detached: true, stdio: ["pipe", "ignore", "ignore"] });
  server.stdin.end(JSON.stringify(config));
  server.unref();
  // A tunnel can take one wait for its URL and another for its DNS record.
  const state = await settled(id, (s) => s.url || s.status !== "waiting", Date.now() + 2 * TUNNEL_MS + 5000);
  say(1, JSON.stringify({ id, ...state }));
  if (state.status !== "waiting") process.exit(1);
  if (local && !native) openPage(state.url);
  if (options.wait) await wait(id);
}

// What the fill will need: over a DevTools port, the boxes themselves; in the user's own Chrome, the extension keeps them
// and hands back a handle.
async function prepare(tab, kind, wanted, options) {
  const { focused, characters, label, dialog } = options;
  const found = options.cdp === "extension"
    ? await extension({ step: "find", tab: tab.id, kind, wanted, options: { focused, characters, label, dialog } })
    : await port(tab, (browser) => TARGETS[kind](browser, tab, wanted, options));
  if (found.reason) fail(found.reason);
  return found;
}

function openPage(url) {
  const opener = process.env.BROWSER ?? (process.platform === "win32" ? "explorer" : "xdg-open");
  spawn(opener, [url], { detached: true, stdio: "ignore" }).on("error", () => {}).unref();
}

async function settled(id, done, deadline) {
  for (;;) {
    const state = readState(id);
    if (state && done(state)) return state;
    if (Date.now() > (state?.expires ?? deadline) + 5000) return { ...state, status: "expired" };
    await sleep(POLL_MS);
  }
}

async function wait(id) {
  const state = await settled(id, (s) => s.status !== "waiting", Date.now() + 30000);
  rmSync(join(STATE, `${id}.json`), { force: true });
  say(1, JSON.stringify(state));
  process.exit(state.status === "filled" ? 0 : 1);
}

// Lets the browser start the companion extension's host: a manifest in its NativeMessagingHosts folder, and a wrapper
// beside it that pins this node, since the browser starts hosts without the user's PATH.
async function install(profile) {
  if (!profile && !CHROME_PROFILES[process.platform]) fail("Pass the browser's profile folder: install works on macOS and Linux.");
  const hosts = join(profile ?? join(homedir(), CHROME_PROFILES[process.platform]), "NativeMessagingHosts");
  const wrapper = join(hosts, `${HOST}.sh`);
  const manifest = join(hosts, `${HOST}.json`);
  const unpacked = fileURLToPath(new URL("./extension", import.meta.url));
  mkdirSync(hosts, { recursive: true });
  writeFileSync(wrapper, `#!/bin/sh\nexec "${process.execPath}" "${join(unpacked, "host.mjs")}"\n`, { mode: 0o755 });
  const allowed = [`chrome-extension://${EXTENSION_ID}/`];
  writeFileSync(manifest, JSON.stringify({ name: HOST, description: "agent-blindfold", type: "stdio", path: wrapper, allowed_origins: allowed }));
  say(1, `Chrome can now start the agent-blindfold extension's helper (${manifest}).
Now load the extension once: open chrome://extensions, turn on Developer mode, click Load unpacked and pick ${unpacked}
To remove it later, remove the extension and delete ${manifest} and ${wrapper}.`);
}

// Copies the package out of npm's cache to ~/.agent-blindfold, where the README's own-Chrome install clones it, since agents and the browser keep paths into it.
async function setup() {
  const home = join(homedir(), ".agent-blindfold");
  const root = join(dirname(SELF), "..", "..");
  const skill = basename(dirname(SELF));
  if (existsSync(join(home, ".git"))) fail(`${home} is a git clone: update it with git -C ${home} pull instead.`);
  if (root !== home) cpSync(root, home, { recursive: true });
  for (const skills of [join(homedir(), ".claude", "skills"), join(homedir(), ".agents", "skills")]) {
    mkdirSync(skills, { recursive: true });
    const link = join(skills, skill);
    if (lstatSync(link, { throwIfNoEntry: false })?.isSymbolicLink()) rmSync(link);
    symlinkSync(join(home, "skills", skill), link, "junction");
  }
  say(1, `Installed to ${home} and linked the ${skill} skill for Claude Code and Codex.
For your own Chrome, also run: node ${join(home, "skills", skill, "signin.mjs")} install`);
}

async function serve() {
  const config = JSON.parse(readFileSync(0, "utf8"));
  const kinds = config.fields.map((field) => field.kind);
  const save = (state) => writeState(config.id, { origin: config.origin, fields: kinds, ...state });
  const job = { busy: false };
  let tunnel, dialog, over;
  const finish = (state) => {
    if (over) return;
    over = true;
    job.busy = true;
    save(state);
    tunnel?.kill();
    dialog?.kill();
    // A beat for the answer to reach the page, which may already have been closed mid-fill.
    setTimeout(() => process.exit(0), 500);
  };
  try {
    const server = createServer(handler(config, finish, job));
    await new Promise((resolve, reject) => server.once("error", reject).listen(config.port, "127.0.0.1", resolve));
    const local = `http://127.0.0.1:${server.address().port}`;
    if (config.tunnel) [tunnel, config.public] = await openTunnel(local);
    const expires = Date.now() + config.minutes * 60000;
    save({ status: "waiting", url: `${(config.public ?? local).replace(/\/$/, "")}/${config.token}`, expires });
    if (config.native) dialog = prompt(config, finish, job);
    // A fill under way finishes, cleanup included, even when the link expires meanwhile.
    setTimeout(() => job.busy || finish({ status: "expired" }), expires - Date.now());
  } catch (error) {
    finish({ status: "failed", reason: error.message });
  }
}

// Asks in a window on this Mac as well as through the link, and the first answer wins; the window's answer comes back
// over a pipe to this process alone.
function prompt(config, finish, job) {
  const dialog = spawn("osascript", ["-l", "JavaScript", "-e", DIALOG, JSON.stringify(form(config))],
    { stdio: ["ignore", "pipe", "ignore"] });
  const answered = async (values) => {
    if (job.busy) return;
    if (!values) return finish({ status: "cancelled" });
    job.busy = true;
    finish(await fill(config, values).catch(() => failed("The browser stopped answering.")));
  };
  // A Mac with no screen to show the window on can still be answered through a public link.
  const unopened = () => config.public || finish(failed("The sign-in window did not open."));
  json(dialog.stdout).then(answered, unopened);
  return dialog;
}

async function openTunnel(local) {
  const tunnel = spawn("cloudflared", ["tunnel", "--no-autoupdate", "--url", local], { stdio: ["ignore", "ignore", "pipe"] });
  let log = "";
  const url = await new Promise((resolve, reject) => {
    tunnel.on("error", () => reject(new Error("--tunnel needs cloudflared on the PATH.")));
    tunnel.on("exit", () => reject(new Error("cloudflared exited before it had a URL.")));
    setTimeout(() => {
      tunnel.kill();
      reject(new Error("cloudflared had no URL after 30 seconds."));
    }, TUNNEL_MS);
    tunnel.stderr.on("data", (chunk) => {
      log += chunk;
      const found = log.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
      if (found) resolve(found[0]);
    });
  });
  await published(new URL(url).hostname);
  return [tunnel, url];
}

// The record shows up seconds after cloudflared prints it, and a resolver that looks too early caches the miss for
// 60 s, so this asks Cloudflare's authoritative server, which caches nothing, before anyone else looks.
async function published(host) {
  const [server] = await dns.resolveNs("trycloudflare.com");
  const resolver = new dns.Resolver();
  resolver.setServers([(await dns.lookup(server, { family: 4 })).address]);
  for (const deadline = Date.now() + TUNNEL_MS; Date.now() < deadline; await sleep(1000)) {
    if (await resolver.resolve4(host).catch(() => null)) return;
  }
}

function handler(config, finish, job) {
  const reply = (response, status, text, result) => {
    response.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
    response.end(JSON.stringify({ message: text, close: Boolean(result) }));
    if (result) finish(result);
  };
  return async (request, response) => {
    // The token may sit under any prefix a reverse proxy adds.
    const [, rest] = new URL(request.url, "http://localhost").pathname.split(`/${config.token}`);
    const route = `${request.method} ${rest}`;
    if (route === "GET ") return page(response, config);
    if (route === "POST /cancel" && !job.busy) return reply(response, 200, "Cancelled. Nothing was typed.", { status: "cancelled" });
    if (route !== "POST " || job.busy) return reply(response, 404, "This link is no longer open.");
    const sent = await json(request).catch(() => ({}));
    const values = Object.fromEntries(config.fields.filter((f) => sent[f.kind]).map((f) => [f.kind, String(sent[f.kind])]));
    if (Object.keys(values).length < config.fields.length) return reply(response, 400, "Fill in every box first.");
    // Checked again after the await and set before the next one, so two POSTs can never both fill.
    if (job.busy) return reply(response, 404, "This link is no longer open.");
    job.busy = true;
    const result = await fill(config, values).catch(() => failed("The browser stopped answering."));
    reply(response, 200, message(config, result), result);
  };
}

function message(config, result) {
  if (result.status !== "filled") return `Stopped. ${result.reason}`;
  if (result.filled.includes("link")) return "Opened the link in your agent's browser.";
  const names = result.filled.map((field) => (field === "secret" ? "answer" : field));
  return `Typed your ${names.join(" and ")} into ${new URL(config.origin).host}.`;
}

async function fill(config, values) {
  if (config.cdp === "extension") return extension({ step: "fill", handle: config.handle, values });
  const tab = (await pages(config.cdp)).find((target) => target.id === config.target);
  if (!tab) return failed("The browser tab is gone.");
  return port(tab, (browser) => fillTab(browser, config, values));
}

// One step over the tab's own DevTools WebSocket.
async function port(tab, step) {
  const browser = await connect(new WebSocket(tab.webSocketDebuggerUrl));
  return step(browser).finally(browser.close);
}

// One step in a tab of the user's own Chrome, asked of the companion extension through its host's socket.
function extension(request) {
  return new Promise((resolve, reject) => {
    const socket = createConnection(EXTENSION);
    socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
    socket.on("close", () => reject(new Error("The browser stopped answering.")));
    // The line reader passes the socket's errors on, and an error nobody hears crashes the process.
    createInterface({ input: socket }).on("error", () => {
      reject(new Error("No agent-blindfold extension answers in Chrome. Install it as the README says."));
    }).once("line", (line) => {
      resolve(JSON.parse(line));
      socket.end();
    });
  });
}

async function connect(socket) {
  await new Promise((resolve, reject) => {
    socket.onopen = resolve;
    socket.onerror = reject;
  });
  const pending = new Map();
  const listeners = {};
  let next = 0;
  const done = (id, result, error) => {
    const call = pending.get(id);
    pending.delete(id);
    clearTimeout(call?.timer);
    if (error) return call?.reject(error);
    call?.resolve(result);
  };
  socket.onmessage = ({ data }) => {
    const message = JSON.parse(data);
    if (message.method) return listeners[message.method]?.(message.params);
    done(message.id, message.result ?? {});
  };
  socket.onclose = () => [...pending.keys()].forEach((id) => done(id, null, new Error("The browser closed the connection.")));
  // A browser that stops answering fails the step instead of leaving it waiting forever.
  const send = (method, params, sessionId) => new Promise((resolve, reject) => {
    const id = ++next;
    const timer = setTimeout(() => done(id, null, new Error("The browser stopped answering.")), COMMAND_MS);
    pending.set(id, { resolve, reject, timer });
    socket.send(JSON.stringify({ id, method, params, sessionId }));
  });
  const on = (method, listener) => {
    listeners[method] = listener;
  };
  return { send, on, close: () => socket.close() };
}

// What the page and the window both tell the user: the site that gets the answer, and what happens to it.
function wording(config) {
  const host = new URL(config.origin).host;
  const top = new URL(config.top).host;
  const link = config.fields[0].kind === "link";
  return {
    host,
    sub: link ? "Your agent's browser will open it, signing in to" : "Your agent's browser will type this into",
    inside: top === host ? "" : `inside ${top}`,
    hint: config.characters ? `Only characters ${config.characters.join(", ")} are typed.` : "",
    button: link ? "Open link" : "Sign in",
  };
}

function form(config) {
  const { host, sub, inside, hint, button } = wording(config);
  const where = [host, inside].filter(Boolean).join(", ");
  return {
    title: `Sign in to ${host}`,
    text: [`${sub} ${where}.`, hint].filter(Boolean).join(" "),
    button,
    fields: config.fields.map((field) => ({
      kind: field.kind, label: field.label || LABELS[field.kind], secret: ["password", "secret"].includes(field.kind),
    })),
  };
}

function page(response, config) {
  const nonce = randomBytes(16).toString("base64");
  const { host, sub, inside, hint, button } = wording(config);
  const parts = {
    nonce,
    host,
    sub,
    inside: inside && `<p class="sub">${inside}</p>`,
    inputs: config.fields.map((field) => INPUTS[field.kind](escapeHtml(field.label || LABELS[field.kind]))).join("")
      + (hint && `<p class="sub">${hint}</p>`),
    button,
  };
  response.writeHead(200, {
    "Content-Type": "text/html; charset=utf-8",
    "Content-Security-Policy": `default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}'; `
      + "connect-src 'self'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'",
    "Referrer-Policy": "no-referrer",
    "Cache-Control": "no-store",
    "X-Content-Type-Options": "nosniff",
  });
  response.end(PAGE.replace(/\{(\w+)\}/g, (match, key) => parts[key] ?? match));
}

const escapeHtml = (text) => text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);

const EYE = `<button type="button" class="eye" aria-label="Show" aria-pressed="false"><svg viewBox="0 0 24 24" width="18"
  height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
  <path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/><path class="slash" d="M4 4l16 16"/>
  </svg></button>`;

const hidden = (name, label, autocomplete) => `<label for="${name}">${label}</label><div class="secret"><input id="${name}"
  name="${name}" type="password" autocomplete="${autocomplete}" required>${EYE}</div>`;

const INPUTS = {
  username: (label) => `<label for="username">${label}</label><input id="username" name="username" type="text"
    autocomplete="username" autocapitalize="none" autocorrect="off" spellcheck="false" required>`,
  password: (label) => hidden("password", label, "current-password"),
  code: (label) => `<label for="code">${label}</label><input id="code" name="code" type="text" inputmode="numeric"
    autocomplete="one-time-code" required>`,
  secret: (label) => hidden("secret", label, "off"),
  link: (label) => `<label for="link">${label}</label><input id="link" name="link" type="url"
    autocapitalize="none" autocorrect="off" spellcheck="false" required>`,
};

// A native window with a labelled box per field; prints the answers as JSON, or null when cancelled.
const DIALOG = `ObjC.import("Cocoa");
function run([input]) {
  const form = JSON.parse(input);
  const app = $.NSApplication.sharedApplication;
  app.setActivationPolicy($.NSApplicationActivationPolicyAccessory);
  // Paste reaches a box only through an Edit menu's shortcut.
  const menu = $.NSMenu.alloc.init, edit = $.NSMenu.alloc.init, item = $.NSMenuItem.alloc.init;
  [["Cut", "cut:", "x"], ["Copy", "copy:", "c"], ["Paste", "paste:", "v"], ["Select All", "selectAll:", "a"]]
    .forEach(([title, action, key]) => edit.addItemWithTitleActionKeyEquivalent(title, action, key));
  item.submenu = edit;
  menu.addItem(item);
  app.mainMenu = menu;
  const alert = $.NSAlert.alloc.init;
  alert.messageText = form.title;
  alert.informativeText = form.text;
  alert.addButtonWithTitle(form.button);
  alert.addButtonWithTitle("Cancel");
  const rows = form.fields.length;
  const view = $.NSView.alloc.initWithFrame($.NSMakeRect(0, 0, 260, rows * 50));
  const boxes = form.fields.map((field, i) => {
    const y = (rows - 1 - i) * 50;
    const label = $.NSTextField.labelWithString(field.label);
    label.frame = $.NSMakeRect(0, y + 26, 260, 17);
    const box = (field.secret ? $.NSSecureTextField : $.NSTextField).alloc.initWithFrame($.NSMakeRect(0, y + 2, 260, 22));
    view.addSubview(label);
    view.addSubview(box);
    return box;
  });
  alert.accessoryView = view;
  alert.window.initialFirstResponder = boxes[0];
  alert.window.autorecalculatesKeyViewLoop = true;
  app.activateIgnoringOtherApps(true);
  for (;;) {
    if (Number(alert.runModal) !== Number($.NSAlertFirstButtonReturn)) return "null";
    const values = boxes.map((box) => box.stringValue.js);
    if (values.every(Boolean)) return JSON.stringify(Object.fromEntries(form.fields.map((field, i) => [field.kind, values[i]])));
    alert.informativeText = form.text + " Fill in every box first.";
  }
}`;

const PAGE = `<!doctype html><html lang="en"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>Sign in</title>
<style>
:root{--bg:#faf9f5;--fg:#1f1e1d;--muted:#6b6a66;--card:#fff;--line:#e3e1da}
@media (prefers-color-scheme:dark){:root{--bg:#1f1e1d;--fg:#f0eee6;--muted:#a3a19a;--card:#2a2927;--line:#3d3c39}}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.4 -apple-system,system-ui,sans-serif}
main{max-width:340px;margin:0 auto;padding:18px 16px 10px}h1{font-size:19px;margin:0;text-align:center}
p{text-align:center;margin:2px 0}.sub{color:var(--muted);font-size:13px}.host{font-weight:700;font-size:16px;overflow-wrap:anywhere}
form{margin-top:8px}label{display:block;font-size:13px;color:var(--muted);margin:10px 0 4px}
input{width:100%;font:inherit;font-size:16px;padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--fg)}
button{display:block;width:100%;font:inherit;font-weight:600;padding:9px;margin-top:14px;border:0;border-radius:8px;background:var(--fg);color:var(--bg)}
button.quiet{background:none;color:var(--muted);margin-top:2px;padding:6px}[hidden]{display:none}
.secret{position:relative}.secret input{padding-right:40px}
.eye{position:absolute;right:2px;top:50%;transform:translateY(-50%);display:flex;width:36px;margin:0;padding:7px;background:none;color:var(--muted)}
.eye:not([aria-pressed=true]) .slash{display:none}
</style>
<main><h1>Sign in</h1><p class="sub">{sub}</p><p class="host">{host}</p>{inside}
<form>{inputs}<button>{button}</button></form><button class="quiet" id="cancel">Cancel</button><p id="status" role="status"></p></main>
<script nonce="{nonce}">
const form = document.forms[0], cancel = document.getElementById("cancel"), status = document.getElementById("status");
async function send(path, body) {
  form.hidden = cancel.hidden = true;
  status.textContent = "Signing in…";
  const response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body }).catch(() => null);
  const answer = response?.ok ? await response.json() : { message: "This link is no longer open." };
  status.textContent = answer.message;
  if (answer.close) setTimeout(() => window.close(), 1200);
}
form.onsubmit = (event) => {
  event.preventDefault();
  const body = JSON.stringify(Object.fromEntries(new FormData(form)));
  form.reset();
  send(location.pathname, body);
};
cancel.onclick = () => send(location.pathname + "/cancel", "{}");
for (const eye of document.querySelectorAll(".eye")) eye.onclick = () => {
  const input = eye.previousElementSibling, show = input.type === "password";
  input.type = show ? "text" : "password";
  eye.setAttribute("aria-pressed", show);
  eye.setAttribute("aria-label", show ? "Hide" : "Show");
};
</script>`;

const { values: options, positionals: [command, argument] } = parseArgs({
  allowPositionals: true,
  options: {
    tab: { type: "string" },
    focused: { type: "boolean" },
    characters: { type: "string" },
    label: { type: "string" },
    dialog: { type: "string" },
    wait: { type: "boolean" },
    cdp: { type: "string", default: process.env.CDP_URL ?? "http://127.0.0.1:9222" },
    public: { type: "string", default: process.env.SIGNIN_PUBLIC_URL },
    tunnel: { type: "boolean" },
    port: { type: "string" },
    minutes: { type: "string", default: "10" },
  },
});
const COMMANDS = { ask, wait, serve, install, setup };
if (!COMMANDS[command] || (command === "wait" && !argument)) fail(USAGE);
await COMMANDS[command](argument, options).catch((error) => fail(error.message));
```

## File: README.md
upstream: https://github.com/bggoranoff/agent-blindfold/blob/main/README.md

````text
<p align="center">
  <picture>
    <source media="(prefers-color-scheme: dark)" srcset="docs/banner-dark.svg">
    <img src="docs/banner-light.svg" alt="agent-blindfold: your agent signs in, it never sees the password" width="100%">
  </picture>
</p>

<p align="center">
  <a href="#install"><img src="https://img.shields.io/badge/works_with-Claude_Code_%7C_Codex-d97757?style=flat-square" alt="Works with Claude Code and Codex"></a>
  <a href="https://agentskills.io"><img src="https://img.shields.io/badge/Agent_Skill-compatible-3d3c39?style=flat-square" alt="Agent Skill compatible"></a>
  <img src="https://img.shields.io/badge/dependencies-zero-3d3c39?style=flat-square" alt="Zero dependencies">
  <a href="https://socket.dev/npm/package/agent-blindfold"><img src="https://socket.dev/api/badge/npm/package/agent-blindfold" alt="Socket score"></a>
  <img src="https://img.shields.io/badge/node-%E2%89%A522-3d3c39?style=flat-square" alt="Node 22 or later">
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3d3c39?style=flat-square" alt="MIT license"></a>
  <a href="https://github.com/bggoranoff/agent-blindfold/actions/workflows/test.yml"><img src="https://img.shields.io/github/actions/workflow/status/bggoranoff/agent-blindfold/test.yml?branch=main&label=tests&style=flat-square" alt="Tests"></a>
</p>

<p align="center">
  <a href="#install"><b>Install</b></a> · <a href="#how-it-works"><b>How it works</b></a> · <a href="#security"><b>Security</b></a>
</p>

Let your browser agent sign in to websites, 2FA included, without your password, code or sign-in link ever passing through it.

![A remote agent sends a one-time link to the user's phone, where they sign in and enter the 2FA code](docs/demo-phone.gif)

<sub>A fake site, with the agent and its browser standing in for a server and an emulated iPhone-size browser for the phone. The links are real Cloudflare quick tunnels, with their start-up sped up 4×. The agent's lines are scripted; the commands and their output are real. [MP4](docs/demo-phone.mp4)</sub>

## Why

A browser agent that hits a login today either stops and hands the job back to you, or asks you to paste the password into the chat. A pasted password goes to the model and stays in the transcript, and so does the 2FA code after it. agent-blindfold gets the agent signed in without it ever holding the secret.

## How it works

1. **The agent hits a login** and runs one command, instead of asking you for the password in chat.
2. **You get a one-time link** on your phone, plus a small window if you are at the Mac the agent runs on. Fill either from your password manager.
3. **The script types your answer into the agent's tab itself**, on the site the window names, and submits. The agent only learns which fields were filled.

It is one Node script with no dependencies, packaged as an [Agent Skill](https://agentskills.io) for Claude Code, Codex and other skill-aware agents.

## Install

For an agent that browses in a Chromium with a DevTools port, the [skills](https://skills.sh) CLI installs it in one line:

```sh
npx skills add bggoranoff/agent-blindfold
```

Or paste the prompt for your setup into your agent: Claude Code, Codex or another one with a shell. It installs everything, then runs a test sign-in with you on a public test site. For your own Chrome, use the prompt, since it also sets up the companion extension.

### Agent in your own Chrome

For an agent on your computer that browses in your everyday Chrome, such as Claude Code with Claude in Chrome. It adds a small companion extension.

<details>
<summary><b>Copy the install prompt</b></summary>

```text
Install agent-blindfold from https://github.com/bggoranoff/agent-blindfold, so you can sign in to websites in my own Chrome without ever seeing my passwords or codes.

1. Check that `node -v` is 22 or later. If it isn't, stop and tell me.
2. Clone the repo to ~/.agent-blindfold, or run `git -C ~/.agent-blindfold pull` if it is already there.
3. Link the skill for Claude Code and Codex: run `mkdir -p ~/.claude/skills ~/.agents/skills`, then `ln -sfn ~/.agent-blindfold/skills/agent-blindfold ~/.claude/skills/agent-blindfold` and the same for ~/.agents/skills.
4. If you are Codex, write this rule to ~/.codex/rules/agent-blindfold.rules, with my real home folder in the path: prefix_rule(pattern = ["node", "<home>/.agent-blindfold/skills/agent-blindfold/signin.mjs"], decision = "allow")
5. Run `node ~/.agent-blindfold/skills/agent-blindfold/signin.mjs install`. Assume my browser is Google Chrome unless I have said otherwise. For another Chromium browser, pass its profile folder in quotes as the argument, such as `install "$HOME/Library/Application Support/BraveSoftware/Brave-Browser"`. Show me the step it prints for loading the extension, and wait until I say I have done it.
6. Test it with me. Open https://the-internet.herokuapp.com/login in my own everyday Chrome, not in a separate browser of yours. Use your Claude in Chrome tool if you have one, or else `open -a "Google Chrome" <url>` on a Mac or `google-chrome <url>` on Linux, with my browser's name if it is another one. Tell me a sign-in window is opening, and that the site's public test login is tomsmith / SuperSecretPassword!. Run `node ~/.agent-blindfold/skills/agent-blindfold/signin.mjs ask --cdp extension --tab the-internet.herokuapp.com`, and show me the `url` it prints in case no window shows up. Then run `node ~/.agent-blindfold/skills/agent-blindfold/signin.mjs wait <id>` with a 10-minute timeout. Its last line must have `"status":"filled"`, and the page must then say "You logged into a secure area!". Tell me whether it worked, and if the status is anything else, the `reason` it gives.
```

</details>

### Agent on a server

For an agent that runs elsewhere and browses in a Chromium with a DevTools port, while you answer from your phone or laptop.

<details>
<summary><b>Copy the install prompt</b></summary>

```text
Install agent-blindfold from https://github.com/bggoranoff/agent-blindfold on this machine, so you can sign in to websites in your browser with logins I enter on my phone, without ever seeing them.

1. Check that `node -v` is 22 or later. If it isn't, install Node 22 or later, or stop and tell me if you can't.
2. Clone the repo to ~/agent-blindfold, or run `git -C ~/agent-blindfold pull` if it is already there. Then link the skill. For Claude Code run `mkdir -p ~/.claude/skills && ln -sfn ~/agent-blindfold/skills/agent-blindfold ~/.claude/skills/agent-blindfold`, and for Codex the same with ~/.agents/skills.
3. If you are Codex, write this rule to ~/.codex/rules/agent-blindfold.rules, with the real home folder in the path: prefix_rule(pattern = ["node", "<home>/agent-blindfold/skills/agent-blindfold/signin.mjs"], decision = "allow")
4. Find your browser's DevTools endpoint: an http:// address where `curl -s <address>/json/version` answers with JSON. Try http://127.0.0.1:9222 first. Otherwise, find the port in the `--remote-debugging-port` flag of the browser's process, or in your browser tool's settings. If several browsers have one, use the one you drive. If it is not 127.0.0.1:9222, set CDP_URL to that http:// address for future sessions, for Claude Code under "env" in ~/.claude/settings.json, creating the file or keeping what is already in it. Pass it on your commands in this session too. If all you have is a wss:// address from a hosted browser service, stop and tell me: that is not supported.
5. Unless SIGNIN_PUBLIC_URL is set, make sure `cloudflared` is installed, from your package manager or https://github.com/cloudflare/cloudflared/releases.
6. Test it with me. Open https://the-internet.herokuapp.com/login in your browser. Run `node ~/agent-blindfold/skills/agent-blindfold/signin.mjs ask --tab the-internet.herokuapp.com`, adding `--tunnel` unless SIGNIN_PUBLIC_URL is set. Send me the `url` it prints, and tell me the site's public test login is tomsmith / SuperSecretPassword!. Then run `node ~/agent
````
