diff --git a/README.md b/README.md index cb4eb4208..9229df0d2 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ When the site you need is not yet covered, use the `opencli-adapter-author` skil | `OPENCLI_DAEMON_PORT` | `19825` | HTTP port for the daemon-extension bridge | | `OPENCLI_PROFILE` | — | Browser Bridge profile alias/contextId to use when multiple Chrome profiles are connected | | `OPENCLI_WINDOW` | command default | Set to `foreground` or `background` to override Browser Bridge window placement. Browser-backed commands also accept `--window `. | +| `OPENCLI_TAB_PLACEMENT` | `owned-container` | Set to `existing-window` to create regular tabs in an already-open normal Chrome profile window instead of OpenCLI-managed windows/tab groups. | | `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `30` | Seconds to wait for browser connection | | `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | Seconds to wait for a single browser command | | `OPENCLI_CDP_ENDPOINT` | — | Chrome DevTools Protocol endpoint for remote browser or Electron apps | @@ -169,7 +170,7 @@ When the site you need is not yet covered, use the `opencli-adapter-author` skil | `OPENCLI_VERBOSE` | `false` | Enable verbose logging (`-v` flag also works) | | `DEBUG_SNAPSHOT` | — | Set to `1` for DOM snapshot debug output | -`opencli browser *` requires an explicit `` positional, uses a foreground browser window by default, and keeps that session's tab lease until `opencli browser close` or idle cleanup. Browser-backed adapters use a background adapter window and release one-shot tab leases by default. Interactive adapters can declare `siteSession: 'persistent'` to keep a stable site tab for continuity; pass `--site-session ephemeral` for a one-shot tab. +`opencli browser *` requires an explicit `` positional, uses a foreground browser window by default, and keeps that session's tab lease until `opencli browser close` or idle cleanup. Browser-backed adapters use a background adapter window and release one-shot tab leases by default. Interactive adapters can declare `siteSession: 'persistent'` to keep a stable site tab for continuity; pass `--site-session ephemeral` for a one-shot tab. Set `OPENCLI_TAB_PLACEMENT=existing-window` to keep owned tabs in already-open Chrome profile windows without creating OpenCLI windows or tab groups. ## Built-in Commands diff --git a/README.zh-CN.md b/README.zh-CN.md index 6b6ee17c6..0c385531e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -148,6 +148,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自 |------|--------|------| | `OPENCLI_DAEMON_PORT` | `19825` | daemon-extension 通信端口 | | `OPENCLI_WINDOW` | 命令默认值 | 设为 `foreground` 或 `background` 来覆盖 Browser Bridge 窗口位置。浏览器型命令也支持 `--window ` | +| `OPENCLI_TAB_PLACEMENT` | `owned-container` | 设为 `existing-window` 时,在已经打开的 Chrome Profile 普通窗口里创建普通标签页,不创建 OpenCLI 管理的窗口或 tab group | | `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `30` | 浏览器连接超时(秒) | | `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | 单个浏览器命令超时(秒) | | `OPENCLI_CDP_ENDPOINT` | — | Chrome DevTools Protocol 端点,用于远程浏览器或 Electron 应用 | @@ -155,7 +156,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自 | `OPENCLI_VERBOSE` | `false` | 启用详细日志(`-v` 也可以) | | `DEBUG_SNAPSHOT` | — | 设为 `1` 输出 DOM 快照调试信息 | -`opencli browser *` 必须紧跟一个 `` 位置参数,默认使用前台窗口,并保留该 session 的 tab lease,直到你手动执行 `opencli browser close` 或等空闲超时。浏览器型 adapter 默认使用后台 adapter 窗口并在命令结束后释放一次性 tab lease;如果需要调试最终页面,可以传 `--window foreground --keep-tab true`。 +`opencli browser *` 必须紧跟一个 `` 位置参数,默认使用前台窗口,并保留该 session 的 tab lease,直到你手动执行 `opencli browser close` 或等空闲超时。浏览器型 adapter 默认使用后台 adapter 窗口并在命令结束后释放一次性 tab lease;如果需要调试最终页面,可以传 `--window foreground --keep-tab true`。如果希望只在已打开的 Chrome Profile 窗口里创建普通标签页,可以设置 `OPENCLI_TAB_PLACEMENT=existing-window`。 ## 内置命令 diff --git a/docs/guide/browser-bridge.md b/docs/guide/browser-bridge.md index 7c8c5419b..e2dfe78ac 100644 --- a/docs/guide/browser-bridge.md +++ b/docs/guide/browser-bridge.md @@ -68,6 +68,8 @@ Use `opencli browser bind` when you want to attach OpenCLI to a Chrome The `OpenCLI Browser` and `OpenCLI Adapter` tab groups are extension-managed automation containers; avoid putting your own long-lived tabs in them or renaming them. +Set `OPENCLI_TAB_PLACEMENT=existing-window` when you want owned Browser Bridge sessions to create regular tabs in an already-open normal Chrome profile window. This mode does not create Chrome windows and does not create, move, or rename OpenCLI tab groups. If the selected profile has no normal window open, the command fails and asks you to open that profile window first. + ## How It Works ``` diff --git a/docs/zh/guide/browser-bridge.md b/docs/zh/guide/browser-bridge.md index 68595ff3d..87bbc2dbf 100644 --- a/docs/zh/guide/browser-bridge.md +++ b/docs/zh/guide/browser-bridge.md @@ -66,6 +66,8 @@ opencli browser my-session close `OpenCLI Browser` 和 `OpenCLI Adapter` tab group 是扩展管理的自动化容器;请不要把自己的长期 tab 放进去,也不要重命名。 +如果希望 OpenCLI 拥有的 Browser Bridge session 只在已经打开的 Chrome Profile 普通窗口里创建普通标签页,可以设置 `OPENCLI_TAB_PLACEMENT=existing-window`。这个模式不会创建 Chrome 窗口,也不会创建、移动或重命名 OpenCLI tab group;如果所选 Profile 没有打开的普通窗口,命令会失败并提示你先打开该 Profile 窗口。 + ## Daemon 生命周期 Daemon 在首次运行浏览器命令时自动启动,之后保持常驻运行。 diff --git a/extension/README.md b/extension/README.md index 4073eba11..7f86b28df 100644 --- a/extension/README.md +++ b/extension/README.md @@ -7,8 +7,11 @@ commands. ## Permission Notes - `debugger`: sends CDP commands to OpenCLI-controlled or bound tabs. -- `tabs` / `tabGroups`: manages the dedicated OpenCLI automation container and - reports selected tab metadata back to the CLI. +- `tabs` / `tabGroups`: manages the dedicated OpenCLI automation container by + default and reports selected tab metadata back to the CLI. When + `OPENCLI_TAB_PLACEMENT=existing-window` is set, owned sessions create regular + tabs in an already-open Chrome profile window and do not create or manage tab + groups. - `cookies`: reads cookies for browser-backed adapters that need authenticated fetches. - `downloads`: surfaces download lifecycle to `opencli browser wait download`. @@ -24,3 +27,11 @@ Suggested Chrome Web Store justification for `downloads`: > so agents can wait for downloads triggered during an automation workflow. The > command filters by a user-provided filename or URL pattern and timeout. We do > not modify, redirect, or persist user download history. + +## Tab Placement + +By default, owned Browser Bridge sessions use OpenCLI-managed windows and tab +groups. Set `OPENCLI_TAB_PLACEMENT=existing-window` to create regular tabs in an +already-open normal Chrome profile window instead. This opt-in mode never +creates Chrome windows or OpenCLI tab groups; open the target Chrome profile +window before running the command. diff --git a/extension/dist/autofill-content.js b/extension/dist/autofill-content.js new file mode 100644 index 000000000..e6e733101 --- /dev/null +++ b/extension/dist/autofill-content.js @@ -0,0 +1,13 @@ +const MESSAGE_TYPE = "autofill-page-ready"; +const NOTIFY_DELAYS_MS = [0, 500, 1500]; +function notifyBackground() { + try { + chrome.runtime.sendMessage({ type: MESSAGE_TYPE, url: window.location.href }, () => { + void chrome.runtime.lastError; + }); + } catch { + } +} +for (const delay of NOTIFY_DELAYS_MS) { + window.setTimeout(notifyBackground, delay); +} diff --git a/extension/dist/background.js b/extension/dist/background.js index cf4947c6c..84811541b 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -1,5 +1,5 @@ const DAEMON_PORT = 19825; -const DAEMON_HOST = "localhost"; +const DAEMON_HOST = "127.0.0.1"; const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; const WS_RECONNECT_BASE_DELAY = 2e3; @@ -13,6 +13,7 @@ let frameTargetCleanupRegistered = false; const CDP_RESPONSE_BODY_CAPTURE_LIMIT = 8 * 1024 * 1024; const CDP_REQUEST_BODY_CAPTURE_LIMIT = 1 * 1024 * 1024; const networkCaptures = /* @__PURE__ */ new Map(); +const autoHandledDialogs = /* @__PURE__ */ new Map(); function isDebuggableUrl$1(url) { if (!url) return true; return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); @@ -87,6 +88,19 @@ async function ensureAttached(tabId, aggressiveRetry = false) { await chrome.debugger.sendCommand({ tabId }, "Runtime.enable"); } catch { } + try { + await chrome.debugger.sendCommand({ tabId }, "Page.enable"); + } catch { + } +} +function rememberAutoHandledDialog(tabId, type, message) { + const existing = autoHandledDialogs.get(tabId); + autoHandledDialogs.set(tabId, { + count: (existing?.count ?? 0) + 1, + lastType: type, + lastMessage: message, + lastAt: Date.now() + }); } async function evaluate(tabId, expression, aggressiveRetry = false) { const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2; @@ -495,6 +509,7 @@ function registerListeners() { attached.delete(tabId); networkCaptures.delete(tabId); tabFrameContexts.delete(tabId); + autoHandledDialogs.delete(tabId); clearFrameTargetsForTab(tabId); }); chrome.debugger.onDetach.addListener((source) => { @@ -502,6 +517,7 @@ function registerListeners() { attached.delete(source.tabId); networkCaptures.delete(source.tabId); tabFrameContexts.delete(source.tabId); + autoHandledDialogs.delete(source.tabId); clearFrameTargetsForTab(source.tabId); return; } @@ -515,9 +531,23 @@ function registerListeners() { chrome.debugger.onEvent.addListener(async (source, method, params) => { const tabId = source.tabId; if (!tabId) return; + const eventParams = params; + if (method === "Page.javascriptDialogOpening") { + const type = String(eventParams?.type || ""); + const message = String(eventParams?.message || ""); + if (type === "beforeunload") { + try { + await chrome.debugger.sendCommand({ tabId }, "Page.handleJavaScriptDialog", { accept: true }); + rememberAutoHandledDialog(tabId, type, message); + console.warn(`[opencli] auto-accepted beforeunload dialog for tab ${tabId}: ${message || "Leave site?"}`); + } catch (err) { + console.warn(`[opencli] failed to auto-accept beforeunload dialog for tab ${tabId}: ${err instanceof Error ? err.message : String(err)}`); + } + } + return; + } const state = networkCaptures.get(tabId); if (!state) return; - const eventParams = params; if (method === "Network.requestWillBeSent") { const requestId = String(eventParams?.requestId || ""); const request = eventParams?.request; @@ -627,6 +657,17 @@ const CONTEXT_ID_KEY = "opencli_context_id_v1"; let currentContextId = "default"; let contextIdPromise = null; let connectInFlight = null; +const AUTOFILL_CANDIDATE_HOSTS = [ + "login.taobao.com", + "loginmyseller.taobao.com", + "havanalogin.taobao.com" +]; +const AUTOFILL_REQUEST_TIMEOUT_MS = 2500; +const AUTOFILL_ATTEMPT_TTL_MS = 2e4; +const AUTOFILL_ATTEMPT_DELAYS_MS = [0, 400, 1200, 2500]; +let autofillRequestCounter = 0; +const pendingAutofillRequests = /* @__PURE__ */ new Map(); +const recentAutofillAttempts = /* @__PURE__ */ new Map(); async function getCurrentContextId() { if (contextIdPromise) return contextIdPromise; contextIdPromise = (async () => { @@ -748,7 +789,12 @@ async function connectAttempt() { thisWs.onmessage = async (event) => { if (ws !== thisWs) return; try { - const command = JSON.parse(event.data); + const payload = JSON.parse(event.data); + if (isAutofillResponseMessage(payload)) { + handleAutofillResponse(payload); + return; + } + const command = payload; const result = await handleCommand(command); if (ws !== thisWs) return; safeSend(thisWs, result); @@ -777,6 +823,132 @@ function scheduleReconnect() { void connect(); }, delay); } +function isAutofillResponseMessage(value) { + if (!value || typeof value !== "object") return false; + const record = value; + return record.type === "autofill-response" && typeof record.requestId === "string" && typeof record.ok === "boolean"; +} +function handleAutofillResponse(message) { + const pending = pendingAutofillRequests.get(message.requestId); + if (!pending) return; + clearTimeout(pending.timer); + pendingAutofillRequests.delete(message.requestId); + pending.resolve(message.ok && message.credential ? message.credential : null); +} +function isAutofillCandidateUrl(rawUrl) { + if (!rawUrl) return false; + try { + const url = new URL(rawUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") return false; + const normalizedHost = url.hostname.trim().toLowerCase(); + return AUTOFILL_CANDIDATE_HOSTS.some((host) => normalizedHost === host || normalizedHost.endsWith(`.${host}`)); + } catch { + return false; + } +} +function cleanupRecentAutofillAttempts(now) { + for (const [key, timestamp] of recentAutofillAttempts.entries()) { + if (now - timestamp > AUTOFILL_ATTEMPT_TTL_MS * 3) recentAutofillAttempts.delete(key); + } +} +async function triggerAutofillForTab(tabId, rawUrl, options = {}) { + if (!isAutofillCandidateUrl(rawUrl)) return; + console.log(`[opencli] Passive credential autofill candidate tab=${tabId} url=${rawUrl}`); + const now = Date.now(); + cleanupRecentAutofillAttempts(now); + const key = `${tabId} +${rawUrl}`; + const lastAttempt = recentAutofillAttempts.get(key) ?? 0; + if (!options.bypassRecent && now - lastAttempt < AUTOFILL_ATTEMPT_TTL_MS) return; + recentAutofillAttempts.set(key, now); + await passiveAutofillTab(tabId, rawUrl); +} +function scheduleAutofillForTab(tabId, rawUrl) { + void triggerAutofillForTab(tabId, rawUrl); +} +async function requestAutofillCredential(rawUrl) { + const connected = await waitForOpenDaemonSocket(AUTOFILL_REQUEST_TIMEOUT_MS); + if (!connected || !ws || ws.readyState !== WebSocket.OPEN) { + console.log("[opencli] Passive credential autofill skipped: daemon socket not ready"); + return null; + } + const requestId = `autofill_${Date.now()}_${++autofillRequestCounter}`; + const contextId = await getCurrentContextId(); + return new Promise((resolve) => { + const timer = setTimeout(() => { + pendingAutofillRequests.delete(requestId); + console.log("[opencli] Passive credential autofill skipped: credential request timed out"); + resolve(null); + }, AUTOFILL_REQUEST_TIMEOUT_MS); + pendingAutofillRequests.set(requestId, { resolve, timer }); + const sent = safeSend(ws, { + type: "autofill-request", + requestId, + contextId, + url: rawUrl + }); + if (!sent) { + clearTimeout(timer); + pendingAutofillRequests.delete(requestId); + console.log("[opencli] Passive credential autofill skipped: credential request not sent"); + resolve(null); + } + }); +} +async function runCredentialFillOnTab(tabId, request) { + const results = await chrome.scripting.executeScript({ + target: { tabId, allFrames: true }, + world: "MAIN", + func: credentialFillInFrame, + args: [request] + }); + const frameResults = results.map((entry) => entry.result).filter((entry) => isCredentialFillFrameResult(entry)); + return frameResults.find((entry) => entry.ok) ?? frameResults.find((entry) => entry.reason !== "host_not_allowed") ?? frameResults[0] ?? null; +} +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} +async function waitForOpenDaemonSocket(timeoutMs) { + await connect(); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (ws?.readyState === WebSocket.OPEN) return true; + await sleep(100); + } + return ws?.readyState === WebSocket.OPEN; +} +async function passiveAutofillTab(tabId, rawUrl) { + const credential = await requestAutofillCredential(rawUrl); + if (!credential) { + console.log("[opencli] Passive credential autofill skipped: no matching credential"); + return; + } + const request = { + ...credential, + submit: false, + allowedHosts: cleanStringArray(credential.allowedHosts, []), + usernameSelectors: cleanStringArray(credential.usernameSelectors, DEFAULT_USERNAME_SELECTORS), + passwordSelectors: cleanStringArray(credential.passwordSelectors, DEFAULT_PASSWORD_SELECTORS), + activateTextPatterns: cleanStringArray(credential.activateTextPatterns, DEFAULT_LOGIN_ACTIVATION_TEXT), + submitSelectors: cleanStringArray(credential.submitSelectors, DEFAULT_SUBMIT_SELECTORS) + }; + if (request.allowedHosts.length === 0) return; + for (const delayMs of AUTOFILL_ATTEMPT_DELAYS_MS) { + if (delayMs > 0) await sleep(delayMs); + try { + const result = await runCredentialFillOnTab(tabId, request); + if (result?.ok) { + console.log(`[opencli] Passive credential autofill completed on ${result.host}`); + return; + } + if (result) console.log(`[opencli] Passive credential autofill attempt did not fill: ${result.reason || "unknown"}`); + if (result && result.reason !== "login_inputs_not_found" && result.reason !== "host_not_allowed") return; + } catch { + console.log("[opencli] Passive credential autofill attempt failed during scripting"); + return; + } + } +} const automationSessions = /* @__PURE__ */ new Map(); const IDLE_TIMEOUT_DEFAULT = 3e4; const IDLE_TIMEOUT_INTERACTIVE = 6e5; @@ -785,10 +957,11 @@ const REGISTRY_KEY = "opencli_target_lease_registry_v2"; const LEASE_IDLE_ALARM_PREFIX = "opencli:lease-idle:"; const CONTAINER_TAB_GROUP_TITLE = { interactive: "OpenCLI Browser", + // Retained for registry/type compatibility. Adapter automation no longer + // creates or discovers a visible tab group. automation: "OpenCLI Adapter" }; -const LEGACY_AUTOMATION_TAB_GROUP_TITLE = "OpenCLI"; -const AUTOMATION_TAB_GROUP_COLOR = "orange"; +const OWNED_TAB_GROUP_COLOR = "orange"; let leaseMutationQueue = Promise.resolve(); const ownedContainers = { interactive: { windowId: null, groupId: null, promise: null, groupPromise: null }, @@ -804,6 +977,7 @@ class CommandFailure extends Error { } const sessionTimeoutOverrides = /* @__PURE__ */ new Map(); const sessionWindowModeOverrides = /* @__PURE__ */ new Map(); +const sessionTabPlacementOverrides = /* @__PURE__ */ new Map(); const sessionLifecycleOverrides = /* @__PURE__ */ new Map(); const LEASE_KEY_SEPARATOR = "\0"; function getLeaseKey(session, surface) { @@ -857,6 +1031,12 @@ function getWindowRole(key, ownership) { function getWindowMode(key) { return sessionWindowModeOverrides.get(key) ?? (getOwnedWindowRole(key) === "interactive" ? "foreground" : "background"); } +function getTabPlacement(key) { + return sessionTabPlacementOverrides.get(key) ?? automationSessions.get(key)?.tabPlacement ?? "owned-container"; +} +function usesOwnedContainer(key) { + return getTabPlacement(key) === "owned-container"; +} function makeAlarmName(leaseKey) { return `${LEASE_IDLE_ALARM_PREFIX}${encodeURIComponent(leaseKey)}`; } @@ -880,7 +1060,8 @@ function makeSession(key, session) { contextId: currentContextId, ownership, lifecycle: getLeaseLifecycle(key, session.kind), - windowRole: getWindowRole(key, ownership) + windowRole: getWindowRole(key, ownership), + tabPlacement: session.tabPlacement ?? getTabPlacement(key) }; } function emptyRegistry() { @@ -894,7 +1075,7 @@ function emptyRegistry() { }, automation: { windowId: ownedContainers.automation.windowId, - groupId: ownedContainers.automation.groupId + groupId: null } }, leases: {} @@ -918,7 +1099,7 @@ async function readRegistry() { }, automation: { windowId: typeof storedContainers.automation?.windowId === "number" ? storedContainers.automation.windowId : null, - groupId: typeof storedContainers.automation?.groupId === "number" ? storedContainers.automation.groupId : null + groupId: null } }, leases: stored.leases @@ -947,6 +1128,7 @@ async function persistRuntimeState() { ownership: session.ownership, lifecycle: session.lifecycle, windowRole: session.windowRole, + tabPlacement: session.tabPlacement, idleDeadlineAt: session.idleDeadlineAt, updatedAt: Date.now() }; @@ -961,7 +1143,7 @@ async function persistRuntimeState() { }, automation: { windowId: ownedContainers.automation.windowId, - groupId: ownedContainers.automation.groupId + groupId: null } }, leases @@ -991,6 +1173,7 @@ async function removeLeaseSession(leaseKey) { automationSessions.delete(leaseKey); sessionTimeoutOverrides.delete(leaseKey); sessionWindowModeOverrides.delete(leaseKey); + sessionTabPlacementOverrides.delete(leaseKey); sessionLifecycleOverrides.delete(leaseKey); scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); await persistRuntimeState(); @@ -1014,7 +1197,7 @@ function resetWindowIdleTimer(leaseKey) { }, timeout); } function getOwnedContainerGroupTitles(role) { - return role === "automation" ? [CONTAINER_TAB_GROUP_TITLE.automation, LEGACY_AUTOMATION_TAB_GROUP_TITLE] : [CONTAINER_TAB_GROUP_TITLE.interactive]; + return role === "automation" ? [] : [CONTAINER_TAB_GROUP_TITLE.interactive]; } async function focusOwnedWindowIfRequested(windowId, mode) { if (mode !== "foreground") return; @@ -1047,6 +1230,7 @@ function selectOwnedContainerGroupCandidate(candidates) { })[0]; } async function collectOwnedGroupCandidates(role) { + if (role === "automation") return []; const container = ownedContainers[role]; const groupsById = /* @__PURE__ */ new Map(); if (container.groupId !== null) { @@ -1122,7 +1306,7 @@ async function ensureCanonicalGroupTitle(role, group) { if (group.title === canonicalTitle) return group; const updated = await chrome.tabGroups.update(group.id, { title: canonicalTitle, - color: AUTOMATION_TAB_GROUP_COLOR + color: OWNED_TAB_GROUP_COLOR }); return { id: updated.id, windowId: updated.windowId, title: updated.title }; } @@ -1155,7 +1339,7 @@ async function createOwnedGroup(role, windowId, ids) { ownedContainers[role].windowId = windowId; await persistRuntimeState(); const group = await chrome.tabGroups.update(groupId, { - color: AUTOMATION_TAB_GROUP_COLOR, + color: OWNED_TAB_GROUP_COLOR, title: CONTAINER_TAB_GROUP_TITLE[role], collapsed: false }); @@ -1163,6 +1347,7 @@ async function createOwnedGroup(role, windowId, ids) { return { id: group.id, windowId: group.windowId, title: group.title }; } async function ensureOwnedContainerGroup(role, fallbackWindowId, tabIds) { + if (role === "automation") return null; const ids = [...new Set(tabIds.filter((id) => id !== void 0))]; const container = ownedContainers[role]; const previousGroupPromise = container.groupPromise ?? Promise.resolve(null); @@ -1294,6 +1479,52 @@ async function findReusableOwnedContainerTab(windowId, ownedGroupId) { return void 0; } } +function isNormalChromeWindow(win) { + return typeof win?.id === "number" && (win.type === void 0 || win.type === "normal"); +} +async function selectExistingNormalWindow(mode) { + const windowsApi = chrome.windows; + try { + const focused = await windowsApi.getLastFocused?.({ windowTypes: ["normal"] }); + if (isNormalChromeWindow(focused)) { + await focusOwnedWindowIfRequested(focused.id, mode); + return focused.id; + } + } catch { + } + const windows = typeof windowsApi.query === "function" ? await windowsApi.query({ windowTypes: ["normal"] }).catch(() => []) : []; + const selected = windows.filter(isNormalChromeWindow).sort((a, b) => { + if (!!a.focused !== !!b.focused) return a.focused ? -1 : 1; + return a.id - b.id; + })[0]; + if (!selected) { + throw new CommandFailure( + "existing_window_required", + "No normal Chrome window is open for this Browser Bridge profile.", + "Open the target Chrome profile window first, then retry the command." + ); + } + await focusOwnedWindowIfRequested(selected.id, mode); + return selected.id; +} +async function createExistingWindowTabLease(leaseKey, initialUrl) { + const targetUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE; + const windowId = await selectExistingNormalWindow(getWindowMode(leaseKey)); + const tab = await chrome.tabs.create({ windowId, url: targetUrl, active: true }); + const tabId = tab.id; + if (!tabId) throw new Error("Failed to create tab in existing Chrome window"); + setLeaseSession(leaseKey, { + session: getSessionFromKey(leaseKey), + surface: getSurfaceFromKey(leaseKey), + kind: "owned", + windowId, + owned: true, + preferredTabId: tabId, + tabPlacement: "existing-window" + }); + resetWindowIdleTimer(leaseKey); + return { tabId, tab }; +} function initialTabIsAvailable(tabId) { if (tabId === void 0) return false; for (const session of automationSessions.values()) { @@ -1305,6 +1536,9 @@ async function createOwnedTabLease(leaseKey, initialUrl) { return withLeaseMutation(() => createOwnedTabLeaseUnlocked(leaseKey, initialUrl)); } async function createOwnedTabLeaseUnlocked(leaseKey, initialUrl) { + if (getTabPlacement(leaseKey) === "existing-window") { + return createExistingWindowTabLease(leaseKey, initialUrl); + } const targetUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE; const role = getOwnedWindowRole(leaseKey); const { windowId, initialTabId } = await ensureOwnedContainerWindow(role, targetUrl, getWindowMode(leaseKey)); @@ -1358,6 +1592,9 @@ async function getAutomationWindow(leaseKey, initialUrl) { } } const role = getOwnedWindowRole(leaseKey); + if (!usesOwnedContainer(leaseKey)) { + return selectExistingNormalWindow(getWindowMode(leaseKey)); + } return (await ensureOwnedContainerWindow(role, initialUrl, getWindowMode(leaseKey))).windowId; } chrome.windows.onRemoved.addListener(async (windowId) => { @@ -1374,6 +1611,7 @@ chrome.windows.onRemoved.addListener(async (windowId) => { automationSessions.delete(leaseKey); sessionTimeoutOverrides.delete(leaseKey); sessionWindowModeOverrides.delete(leaseKey); + sessionTabPlacementOverrides.delete(leaseKey); sessionLifecycleOverrides.delete(leaseKey); scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); } @@ -1388,6 +1626,7 @@ chrome.tabs.onRemoved.addListener(async (tabId) => { automationSessions.delete(leaseKey); sessionTimeoutOverrides.delete(leaseKey); sessionWindowModeOverrides.delete(leaseKey); + sessionTabPlacementOverrides.delete(leaseKey); sessionLifecycleOverrides.delete(leaseKey); scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); console.log(`[opencli] Session ${session.session} detached from tab ${tabId} (tab closed)`); @@ -1400,6 +1639,26 @@ function initialize() { if (initialized) return; initialized = true; chrome.alarms.create("keepalive", { periodInMinutes: 0.4 }); + chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + const rawUrl = changeInfo.url ?? tab.url; + if (changeInfo.status === "complete" || changeInfo.url) { + scheduleAutofillForTab(tabId, rawUrl); + } + }); + if (chrome.webNavigation?.onCommitted) { + chrome.webNavigation.onCommitted.addListener((details) => { + scheduleAutofillForTab(details.tabId, details.url); + }, { + url: AUTOFILL_CANDIDATE_HOSTS.map((hostEquals) => ({ hostEquals })) + }); + } + if (chrome.webNavigation?.onCompleted) { + chrome.webNavigation.onCompleted.addListener((details) => { + scheduleAutofillForTab(details.tabId, details.url); + }, { + url: AUTOFILL_CANDIDATE_HOSTS.map((hostEquals) => ({ hostEquals })) + }); + } registerListeners(); try { const registerFrameTracking$1 = registerFrameTracking; @@ -1410,8 +1669,12 @@ function initialize() { await getCurrentContextId(); await reconcileTargetLeaseRegistry(); await connect(); + const tabs = await chrome.tabs.query({}); + for (const tab of tabs) { + if (typeof tab.id === "number") scheduleAutofillForTab(tab.id, tab.url); + } })(); - console.log("[opencli] OpenCLI extension initialized"); + console.log("[opencli] OpenCLI extension initialized; passive credential autofill listener active"); } chrome.runtime.onInstalled.addListener(() => { initialize(); @@ -1425,7 +1688,14 @@ chrome.alarms.onAlarm.addListener(async (alarm) => { const leaseKey = leaseKeyFromAlarmName(alarm.name); if (leaseKey) await releaseLease(leaseKey, "idle alarm"); }); -chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { +chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + if (msg?.type === "autofill-page-ready") { + const tabId = sender.tab?.id; + const rawUrl = typeof msg.url === "string" ? msg.url : sender.url; + if (typeof tabId === "number") scheduleAutofillForTab(tabId, rawUrl); + sendResponse({ ok: true }); + return false; + } if (msg?.type === "getStatus") { void (async () => { const contextId = await getCurrentContextId(); @@ -1462,9 +1732,17 @@ async function handleCommand(cmd) { const session = getSessionName(cmd.session); const surface = getCommandSurface(cmd); const leaseKey = getLeaseKey(session, surface); + const requestedExistingWindowPlacement = cmd.tabPlacement === "existing-window"; if (cmd.windowMode === "foreground" || cmd.windowMode === "background") { sessionWindowModeOverrides.set(leaseKey, cmd.windowMode); } + if (cmd.tabPlacement === "owned-container" || cmd.tabPlacement === "existing-window") { + sessionTabPlacementOverrides.set(leaseKey, cmd.tabPlacement); + } + if (requestedExistingWindowPlacement) { + await releaseOwnedContainerSessionsForSurface(surface, "tab placement changed"); + sessionTabPlacementOverrides.set(leaseKey, "existing-window"); + } if (surface === "adapter" && (cmd.siteSession === "persistent" || cmd.siteSession === "ephemeral")) { sessionLifecycleOverrides.set(leaseKey, cmd.siteSession); } @@ -1492,6 +1770,10 @@ async function handleCommand(cmd) { return await handleSetFileInput(cmd, leaseKey); case "insert-text": return await handleInsertText(cmd, leaseKey); + case "credential-fill": + return await handleCredentialFill(cmd, leaseKey); + case "credential-autofill": + return await handleCredentialAutofill(cmd, leaseKey); case "bind": return await handleBind(cmd, leaseKey); case "network-capture-start": @@ -1588,6 +1870,10 @@ async function resolveCommandTabId(cmd) { } async function resolveTab(tabId, leaseKey, initialUrl) { const existingSession = automationSessions.get(leaseKey); + if (existingSession?.owned && existingSession.tabPlacement !== getTabPlacement(leaseKey)) { + await releaseLease(leaseKey, "tab placement changed"); + return createOwnedTabLease(leaseKey, initialUrl); + } if (tabId !== void 0) { try { const tab = await chrome.tabs.get(tabId); @@ -1677,11 +1963,18 @@ async function resolveTab(tabId, leaseKey, initialUrl) { } const newTab = await chrome.tabs.create({ windowId: scopedWindowId, url: BLANK_PAGE, active: true }); if (!newTab.id) throw new Error("Failed to create tab in automation container"); - await ensureOwnedContainerGroup(role, scopedWindowId, [newTab.id]); + if (usesOwnedContainer(leaseKey)) { + await ensureOwnedContainerGroup(role, scopedWindowId, [newTab.id]); + } return { tabId: newTab.id, tab: await chrome.tabs.get(newTab.id) }; } async function pageScopedResult(id, tabId, data) { const page = await resolveTargetId(tabId); + try { + const tab = await chrome.tabs.get(tabId); + await triggerAutofillForTab(tabId, tab.url, { bypassRecent: true }); + } catch { + } return { id, ok: true, data, page }; } async function resolveTabId(tabId, leaseKey, initialUrl) { @@ -1753,6 +2046,7 @@ async function handleNavigate(cmd, leaseKey) { const beforeNormalized = normalizeUrlForComparison(beforeTab.url); const targetUrl = cmd.url; if (beforeTab.status === "complete" && isTargetUrl(beforeTab.url, targetUrl)) { + await triggerAutofillForTab(tabId, beforeTab.url ?? targetUrl, { bypassRecent: true }); return pageScopedResult(cmd.id, tabId, { title: beforeTab.title, url: beforeTab.url, timedOut: false }); } if (!hasActiveNetworkCapture(tabId)) { @@ -1808,6 +2102,7 @@ async function handleNavigate(cmd, leaseKey) { console.warn(`[opencli] Failed to recover drifted tab: ${moveErr}`); } } + await triggerAutofillForTab(tabId, tab.url ?? targetUrl, { bypassRecent: true }); return pageScopedResult(cmd.id, tabId, { title: tab.title, url: tab.url, timedOut }); } async function handleTabs(cmd, leaseKey) { @@ -1846,7 +2141,7 @@ async function handleTabs(cmd, leaseKey) { let tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true }); const tabId = tab.id; if (!tabId) return { id: cmd.id, ok: false, error: "Failed to create tab" }; - const group = await ensureOwnedContainerGroup(getOwnedWindowRole(leaseKey), windowId, [tabId]); + const group = usesOwnedContainer(leaseKey) ? await ensureOwnedContainerGroup(getOwnedWindowRole(leaseKey), windowId, [tabId]) : null; const sessionWindowId = group?.windowId ?? tab.windowId; if (tab.windowId !== sessionWindowId) tab = await chrome.tabs.get(tabId); setLeaseSession(leaseKey, { @@ -1855,7 +2150,8 @@ async function handleTabs(cmd, leaseKey) { kind: "owned", windowId: sessionWindowId, owned: true, - preferredTabId: tabId + preferredTabId: tabId, + tabPlacement: getTabPlacement(leaseKey) }); resetWindowIdleTimer(leaseKey); return pageScopedResult(cmd.id, tabId, { url: tab.url }); @@ -2037,6 +2333,255 @@ async function handleInsertText(cmd, leaseKey) { return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; } } +const DEFAULT_USERNAME_SELECTORS = [ + "#fm-login-id", + 'input[name="fm-login-id"]', + 'input[autocomplete="username"]', + 'input[type="email"]', + 'input[type="text"]' +]; +const DEFAULT_PASSWORD_SELECTORS = [ + "#fm-login-password", + 'input[name="fm-login-password"]', + 'input[autocomplete="current-password"]', + 'input[type="password"]' +]; +const DEFAULT_LOGIN_ACTIVATION_TEXT = ["账号密码登录", "密码登录", "账号登录"]; +const DEFAULT_SUBMIT_SELECTORS = [ + 'button[type="submit"]', + 'input[type="submit"]', + "button", + "a" +]; +function cleanStringArray(value, fallback) { + if (!Array.isArray(value)) return fallback; + const cleaned = value.filter((entry) => typeof entry === "string").map((entry) => entry.trim()).filter((entry) => entry.length > 0); + return cleaned.length > 0 ? cleaned : fallback; +} +async function handleCredentialFill(cmd, leaseKey) { + if (typeof cmd.username !== "string" || typeof cmd.password !== "string") { + return { id: cmd.id, ok: false, error: "Missing credential payload" }; + } + if (!Array.isArray(cmd.allowedHosts) || cmd.allowedHosts.length === 0) { + return { id: cmd.id, ok: false, error: "Missing allowedHosts payload" }; + } + const request = { + username: cmd.username, + password: cmd.password, + allowedHosts: cleanStringArray(cmd.allowedHosts, []), + usernameSelectors: cleanStringArray(cmd.usernameSelectors, DEFAULT_USERNAME_SELECTORS), + passwordSelectors: cleanStringArray(cmd.passwordSelectors, DEFAULT_PASSWORD_SELECTORS), + activateTextPatterns: cleanStringArray(cmd.activateTextPatterns, DEFAULT_LOGIN_ACTIVATION_TEXT), + submitSelectors: cleanStringArray(cmd.submitSelectors, DEFAULT_SUBMIT_SELECTORS), + submit: cmd.submit !== false + }; + if (request.allowedHosts.length === 0) { + return { id: cmd.id, ok: false, error: "Missing allowedHosts payload" }; + } + const cmdTabId = await resolveCommandTabId(cmd); + const tabId = await resolveTabId(cmdTabId, leaseKey); + try { + const results = await chrome.scripting.executeScript({ + target: { tabId, allFrames: true }, + world: "MAIN", + func: credentialFillInFrame, + args: [request] + }); + const frameResults = results.map((entry) => ({ + frameId: entry.frameId, + result: entry.result + })).filter( + (entry) => isCredentialFillFrameResult(entry.result) + ); + const success = frameResults.find((entry) => entry.result.ok); + const selected = success ?? frameResults.find((entry) => entry.result.reason !== "host_not_allowed") ?? frameResults[0]; + const data = selected ? { ...selected.result, frameId: selected.frameId } : { + ok: false, + host: "", + username_filled: false, + password_filled: false, + submitted: false, + reason: "no_frame_result" + }; + return pageScopedResult(cmd.id, tabId, data); + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} +async function handleCredentialAutofill(cmd, leaseKey) { + const cmdTabId = await resolveCommandTabId(cmd); + const tabId = await resolveTabId(cmdTabId, leaseKey); + try { + const tab = await chrome.tabs.get(tabId); + const rawUrl = cmd.url ?? tab.url; + await triggerAutofillForTab(tabId, rawUrl, { bypassRecent: true }); + return pageScopedResult(cmd.id, tabId, { + attempted: isAutofillCandidateUrl(rawUrl), + url: rawUrl + }); + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} +function isCredentialFillFrameResult(value) { + if (!value || typeof value !== "object") return false; + const record = value; + return typeof record.ok === "boolean" && typeof record.host === "string" && typeof record.username_filled === "boolean" && typeof record.password_filled === "boolean" && typeof record.submitted === "boolean"; +} +async function credentialFillInFrame(request) { + const host = window.location.hostname; + const normalizeHost = (value) => value.trim().toLowerCase().replace(/^\.+/, ""); + const normalizedHost = normalizeHost(host); + const allowed = request.allowedHosts.some((entry) => { + const allowedHost = normalizeHost(entry); + return allowedHost.length > 0 && (normalizedHost === allowedHost || normalizedHost.endsWith(`.${allowedHost}`)); + }); + if (!allowed) { + return { + ok: false, + host, + username_filled: false, + password_filled: false, + submitted: false, + reason: "host_not_allowed" + }; + } + const sleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const clean = (value) => String(value ?? "").replace(/\s+/g, " ").trim(); + const isVisible = (element) => { + if (!(element instanceof HTMLElement)) return false; + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0 && !element.hasAttribute("disabled"); + }; + const queryFirstVisibleInput = (selectors) => { + for (const selector of selectors) { + let elements = []; + try { + elements = Array.from(document.querySelectorAll(selector)); + } catch { + continue; + } + const found = elements.find( + (element) => (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) && isVisible(element) && !element.readOnly + ); + if (found) return found; + } + return null; + }; + const textOf = (element) => clean([ + element.innerText, + element.getAttribute("aria-label"), + element.getAttribute("title"), + element.getAttribute("placeholder"), + element instanceof HTMLInputElement ? element.value : "" + ].filter(Boolean).join(" ")); + const matchesActivationText = (text) => request.activateTextPatterns.some((pattern) => { + try { + return new RegExp(pattern).test(text); + } catch { + return text === pattern || text.includes(pattern); + } + }); + const activateLoginMode = () => { + const candidates = Array.from(document.querySelectorAll("button, a, input, div, span")).filter(isVisible); + const target = candidates.find((element) => matchesActivationText(textOf(element))); + if (!target) return false; + target.click(); + return true; + }; + const makeInputEvent = (type, value) => { + try { + return new InputEvent(type, { bubbles: true, cancelable: false, inputType: "insertText", data: value }); + } catch { + return new Event(type, { bubbles: true, cancelable: false }); + } + }; + const dispatchLegacyEvent = (field, eventName) => { + const event = field.ownerDocument.createEvent("Event"); + event.initEvent(eventName, true, false); + field.dispatchEvent(event); + }; + const setValue = async (field, value) => { + field.focus(); + await Promise.resolve(); + field.dispatchEvent(new FocusEvent("focus", { bubbles: false, cancelable: false })); + field.dispatchEvent(new FocusEvent("focusin", { bubbles: true, cancelable: false })); + field.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: false, key: value })); + field.dispatchEvent(makeInputEvent("beforeinput", value)); + field.dispatchEvent(new KeyboardEvent("keypress", { bubbles: true, cancelable: false, key: value })); + const descriptor = Object.getOwnPropertyDescriptor( + field instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype, + "value" + ); + if (descriptor?.set) { + descriptor.set.call(field, value); + } else { + field.value = value; + } + field.dispatchEvent(new Event("input", { bubbles: true, cancelable: false })); + field.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, cancelable: false, key: value })); + field.dispatchEvent(new Event("change", { bubbles: true, cancelable: false })); + dispatchLegacyEvent(field, "input"); + dispatchLegacyEvent(field, "change"); + }; + const findSubmitTarget = (usernameInput2, passwordInput2) => { + for (const selector of request.submitSelectors) { + let elements = []; + try { + elements = Array.from(document.querySelectorAll(selector)); + } catch { + continue; + } + const found = elements.find((element) => { + if (!isVisible(element)) return false; + const text = textOf(element); + return /^(登录|登 录|提交|确认|继续)$/.test(text) || element.getAttribute("type") === "submit"; + }); + if (found) return found; + } + const form = passwordInput2?.form ?? usernameInput2?.form ?? null; + return form?.querySelector('button[type="submit"], input[type="submit"]') ?? null; + }; + let usernameInput = queryFirstVisibleInput(request.usernameSelectors); + let passwordInput = queryFirstVisibleInput(request.passwordSelectors); + if ((!usernameInput || !passwordInput) && activateLoginMode()) { + await sleep2(500); + usernameInput = queryFirstVisibleInput(request.usernameSelectors); + passwordInput = queryFirstVisibleInput(request.passwordSelectors); + } + if (!usernameInput || !passwordInput) { + return { + ok: false, + host, + username_filled: false, + password_filled: false, + submitted: false, + reason: "login_inputs_not_found" + }; + } + await setValue(usernameInput, request.username); + await setValue(passwordInput, request.password); + let submitted = false; + if (request.submit) { + const submitTarget = findSubmitTarget(usernameInput, passwordInput); + if (submitTarget instanceof HTMLElement) { + submitTarget.click(); + submitted = true; + } else if (passwordInput.form && typeof passwordInput.form.requestSubmit === "function") { + passwordInput.form.requestSubmit(); + submitted = true; + } + } + return { + ok: true, + host, + username_filled: clean(usernameInput.value).length > 0, + password_filled: clean(passwordInput.value).length > 0, + submitted, + reason: "" + }; +} async function handleNetworkCaptureStart(cmd, leaseKey) { const cmdTabId = await resolveCommandTabId(cmd); const tabId = await resolveTabId(cmdTabId, leaseKey); @@ -2065,11 +2610,43 @@ async function handleWaitDownload(cmd) { return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; } } +async function releaseOwnedContainerSessionsForSurface(surface, reason) { + const staleLeaseKeys = [...automationSessions.entries()].filter(([, session]) => session.surface === surface && session.owned && session.tabPlacement === "owned-container").map(([leaseKey]) => leaseKey); + for (const staleLeaseKey of staleLeaseKeys) { + sessionTabPlacementOverrides.set(staleLeaseKey, "existing-window"); + await releaseLease(staleLeaseKey, reason); + } + await closeEmptyOwnedContainerWindowForSurface(surface); +} +async function closeEmptyOwnedContainerWindowForSurface(surface) { + const role = surface === "browser" ? "interactive" : "automation"; + const container = ownedContainers[role]; + const windowId = container.windowId; + if (windowId === null) return; + try { + const tabs = await chrome.tabs.query({ windowId }); + const hasNonBlankTab = tabs.some((tab) => { + const url = tab.url ?? ""; + return url !== "" && url !== BLANK_PAGE; + }); + if (hasNonBlankTab) return; + await chrome.windows.remove(windowId); + container.windowId = null; + container.groupId = null; + await persistRuntimeState(); + console.log(`[opencli] Closed empty ${role} owned-container window ${windowId} before existing-window command`); + } catch { + container.windowId = null; + container.groupId = null; + await persistRuntimeState(); + } +} async function releaseLease(leaseKey, reason = "released") { const session = automationSessions.get(leaseKey); if (!session) { sessionTimeoutOverrides.delete(leaseKey); sessionWindowModeOverrides.delete(leaseKey); + sessionTabPlacementOverrides.delete(leaseKey); sessionLifecycleOverrides.delete(leaseKey); scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); await persistRuntimeState(); @@ -2077,6 +2654,8 @@ async function releaseLease(leaseKey, reason = "released") { } if (session.idleTimer) clearTimeout(session.idleTimer); scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); + const releaseTabPlacement = sessionTabPlacementOverrides.get(leaseKey) ?? session.tabPlacement; + const shouldRemoveOwnedTab = session.tabPlacement === "existing-window" || releaseTabPlacement === "existing-window" || reason === "tab placement changed"; if (session.owned) { const tabId = session.preferredTabId; if (tabId !== null) { @@ -2089,10 +2668,14 @@ async function releaseLease(leaseKey, reason = "released") { await chrome.tabs.remove(tabId).catch(() => { }); console.log(`[opencli] Released owned tab lease ${tabId} (session=${session.session}, surface=${session.surface}, ${reason})`); + } else if (shouldRemoveOwnedTab) { + await chrome.tabs.remove(tabId).catch(() => { + }); + console.log(`[opencli] Released existing-window tab lease ${tabId} (session=${session.session}, surface=${session.surface}, ${reason})`); } else { try { const tab = await chrome.tabs.update(tabId, { url: BLANK_PAGE, active: true }); - const group = await ensureOwnedContainerGroup(getOwnedWindowRole(leaseKey), session.windowId, [tab.id ?? tabId]); + const group = releaseTabPlacement === "owned-container" ? await ensureOwnedContainerGroup(getOwnedWindowRole(leaseKey), session.windowId, [tab.id ?? tabId]) : null; if (group) session.windowId = group.windowId; console.log(`[opencli] Released owned tab lease ${tabId} as reusable placeholder (session=${session.session}, surface=${session.surface}, ${reason})`); } catch { @@ -2111,6 +2694,7 @@ async function releaseLease(leaseKey, reason = "released") { automationSessions.delete(leaseKey); sessionTimeoutOverrides.delete(leaseKey); sessionWindowModeOverrides.delete(leaseKey); + sessionTabPlacementOverrides.delete(leaseKey); sessionLifecycleOverrides.delete(leaseKey); await persistRuntimeState(); } @@ -2145,7 +2729,8 @@ async function reconcileTargetLeaseRegistry() { kind: stored.kind === "bound" || stored.owned === false ? "bound" : "owned", windowId: tab.windowId, owned: stored.owned, - preferredTabId: tabId + preferredTabId: tabId, + tabPlacement: stored.tabPlacement === "existing-window" ? "existing-window" : "owned-container" }); const timeout = getIdleTimeout(leaseKey); automationSessions.set(leaseKey, { @@ -2153,7 +2738,7 @@ async function reconcileTargetLeaseRegistry() { idleTimer: null, idleDeadlineAt: stored.idleDeadlineAt }); - if (session.owned) { + if (session.owned && session.tabPlacement === "owned-container") { const role = getOwnedWindowRole(leaseKey); if (ownedContainers[role].windowId === null) ownedContainers[role].windowId = tab.windowId; const group = await ensureOwnedContainerGroup(role, tab.windowId, [tabId]); diff --git a/extension/manifest.json b/extension/manifest.json index e787624ad..062d3cbb1 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,17 +1,19 @@ { "manifest_version": 3, "name": "OpenCLI", - "version": "1.0.20", + "version": "1.0.23", "description": "Browser automation bridge for the OpenCLI CLI tool. Executes commands in Chrome tab leases via a local daemon.", "permissions": [ "debugger", "tabs", "cookies", "activeTab", + "scripting", "alarms", "storage", "tabGroups", - "downloads" + "downloads", + "webNavigation" ], "host_permissions": [ "" @@ -20,6 +22,20 @@ "service_worker": "dist/background.js", "type": "module" }, + "content_scripts": [ + { + "matches": [ + "https://login.taobao.com/*", + "https://loginmyseller.taobao.com/*", + "https://havanalogin.taobao.com/*" + ], + "js": [ + "dist/autofill-content.js" + ], + "run_at": "document_idle", + "all_frames": true + } + ], "icons": { "16": "icons/icon-16.png", "32": "icons/icon-32.png", diff --git a/extension/package-lock.json b/extension/package-lock.json index 91789eaca..5e5c8cd5e 100644 --- a/extension/package-lock.json +++ b/extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencli-extension", - "version": "1.0.20", + "version": "1.0.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencli-extension", - "version": "1.0.20", + "version": "1.0.23", "devDependencies": { "@types/chrome": "^0.0.287", "typescript": "^5.7.0", @@ -954,7 +954,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/extension/package.json b/extension/package.json index ee3c91341..60a366737 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,6 +1,6 @@ { "name": "opencli-extension", - "version": "1.0.20", + "version": "1.0.23", "private": true, "opencli": { "compatRange": ">=1.7.0" diff --git a/extension/src/autofill-content.ts b/extension/src/autofill-content.ts new file mode 100644 index 000000000..07a12756a --- /dev/null +++ b/extension/src/autofill-content.ts @@ -0,0 +1,16 @@ +const MESSAGE_TYPE = 'autofill-page-ready'; +const NOTIFY_DELAYS_MS = [0, 500, 1500]; + +function notifyBackground(): void { + try { + chrome.runtime.sendMessage({ type: MESSAGE_TYPE, url: window.location.href }, () => { + void chrome.runtime.lastError; + }); + } catch { + // Content scripts can be orphaned while an unpacked extension reloads. + } +} + +for (const delay of NOTIFY_DELAYS_MS) { + window.setTimeout(notifyBackground, delay); +} diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts index 588e2f957..9759b6975 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -23,6 +23,14 @@ type MockTabGroup = { collapsed?: boolean; }; +type MockWindowType = 'normal' | 'popup' | 'panel' | 'app' | 'devtools'; + +type MockWindow = { + id: number; + focused?: boolean; + type?: MockWindowType; +}; + const leaseKey = (surface: 'browser' | 'adapter', session: string): string => `${surface}\u0000${encodeURIComponent(session)}`; const browserKey = (session: string): string => leaseKey('browser', session); @@ -72,6 +80,10 @@ function createChromeMock() { { id: 3, windowId: 1, url: 'chrome://extensions', title: 'chrome', active: false, status: 'complete', groupId: -1 }, ]; const groups: MockTabGroup[] = []; + let windows: MockWindow[] = [ + { id: 1, focused: false, type: 'normal' }, + { id: 2, focused: true, type: 'normal' }, + ]; let lastFocusedWindowId = 2; const removeEmptyGroups = () => { @@ -198,7 +210,34 @@ function createChromeMock() { onEvent: { addListener: vi.fn() } as Listener<(source: any, method: string, params: any) => void>, }, windows: { - get: vi.fn(async (windowId: number) => ({ id: windowId, focused: windowId === lastFocusedWindowId })), + get: vi.fn(async (windowId: number) => { + const win = windows.find((entry) => entry.id === windowId) + ?? (tabs.some((tab) => tab.windowId === windowId) ? { id: windowId, type: 'normal' as MockWindowType } : undefined); + if (!win) throw new Error(`Unknown window ${windowId}`); + return { ...win, focused: windowId === lastFocusedWindowId }; + }), + getLastFocused: vi.fn(async (queryOptions?: { windowTypes?: MockWindowType[] }) => { + const win = windows.find((entry) => entry.id === lastFocusedWindowId); + if (!win) throw new Error('No focused window'); + if (queryOptions?.windowTypes && !queryOptions.windowTypes.includes(win.type ?? 'normal')) { + throw new Error('No focused matching window'); + } + return { ...win, focused: true }; + }), + query: vi.fn(async (queryOptions?: { windowTypes?: MockWindowType[] }) => { + return windows + .filter((win) => !queryOptions?.windowTypes || queryOptions.windowTypes.includes(win.type ?? 'normal')) + .map((win) => ({ ...win, focused: win.id === lastFocusedWindowId })); + }), + update: vi.fn(async (windowId: number, updateInfo: { focused?: boolean }) => { + const win = windows.find((entry) => entry.id === windowId); + if (!win) throw new Error(`Unknown window ${windowId}`); + if (updateInfo.focused) { + lastFocusedWindowId = windowId; + windows = windows.map((entry) => ({ ...entry, focused: entry.id === windowId })); + } + return { ...win, focused: windowId === lastFocusedWindowId }; + }), create: vi.fn(async ({ url, focused, width, height, type }: any) => ({ id: 1, url, focused, width, height, type })), remove: vi.fn(async (_windowId: number) => {}), onRemoved: { addListener: vi.fn() } as Listener<(windowId: number) => void>, @@ -225,6 +264,19 @@ function createChromeMock() { cookies: { getAll: vi.fn(async () => []), }, + scripting: { + executeScript: vi.fn(async () => [{ + frameId: 0, + result: { + ok: true, + host: 'login.taobao.com', + username_filled: true, + password_filled: true, + submitted: true, + reason: '', + }, + }]), + }, }; return { @@ -235,6 +287,11 @@ function createChromeMock() { create, update, setLastFocusedWindowId: (windowId: number) => { lastFocusedWindowId = windowId; }, + setWindows: (nextWindows: MockWindow[]) => { + windows = nextWindows; + const focused = nextWindows.find((win) => win.focused); + lastFocusedWindowId = focused?.id ?? nextWindows[0]?.id ?? -1; + }, }; } @@ -251,6 +308,39 @@ describe('background tab isolation', () => { vi.unstubAllGlobals(); }); + it('fills credentials through extension scripting without returning the password', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const result = await mod.__test__.handleCommand({ + id: 'credential-fill', + action: 'credential-fill', + session: 'qn', + surface: 'adapter', + username: 'seller', + password: 'secret-password', + allowedHosts: ['taobao.com'], + }); + + expect(result.ok).toBe(true); + expect(result.data).toEqual({ + ok: true, + host: 'login.taobao.com', + frameId: 0, + username_filled: true, + password_filled: true, + submitted: true, + reason: '', + }); + expect(JSON.stringify(result.data)).not.toContain('secret-password'); + expect(chrome.scripting.executeScript).toHaveBeenCalledTimes(1); + expect(chrome.scripting.executeScript).toHaveBeenCalledWith(expect.objectContaining({ + target: expect.objectContaining({ allFrames: true }), + world: 'MAIN', + })); + }); + it('lists only automation-window web tabs', async () => { const { chrome } = createChromeMock(); vi.stubGlobal('chrome', chrome); @@ -1155,6 +1245,229 @@ describe('background tab isolation', () => { expect(chrome.windows.create).toHaveBeenCalledWith(expect.objectContaining({ focused: true })); }); + it('creates ordinary tabs in the focused existing Chrome window when tabPlacement is existing-window', async () => { + const { chrome, create, tabs } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const result = await mod.__test__.handleCommand({ + id: 'existing-window-new', + action: 'tabs', + op: 'new', + session: 'twitter', + surface: 'adapter', + url: 'https://new.example', + tabPlacement: 'existing-window', + } as any); + + expect(result).toEqual(expect.objectContaining({ ok: true })); + expect(create).toHaveBeenCalledWith({ windowId: 2, url: 'https://new.example', active: true }); + expect(chrome.windows.create).not.toHaveBeenCalled(); + expect(chrome.tabs.group).not.toHaveBeenCalled(); + expect(chrome.tabGroups.update).not.toHaveBeenCalled(); + expect(tabs.find((tab) => tab.id === 10)).toEqual(expect.objectContaining({ + windowId: 2, + groupId: -1, + url: 'https://new.example', + })); + expect(mod.__test__.getSession(adapterKey('twitter'))).toEqual(expect.objectContaining({ + windowId: 2, + preferredTabId: 10, + tabPlacement: 'existing-window', + })); + }); + + it('falls back to any normal window when the focused Chrome window is not normal', async () => { + const { chrome, create, setWindows } = createChromeMock(); + setWindows([ + { id: 30, focused: true, type: 'popup' }, + { id: 31, focused: false, type: 'normal' }, + ]); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const result = await mod.__test__.handleCommand({ + id: 'existing-window-fallback', + action: 'tabs', + op: 'new', + session: 'twitter', + surface: 'adapter', + url: 'https://fallback.example', + tabPlacement: 'existing-window', + } as any); + + expect(result).toEqual(expect.objectContaining({ ok: true })); + expect(create).toHaveBeenCalledWith({ windowId: 31, url: 'https://fallback.example', active: true }); + expect(chrome.windows.create).not.toHaveBeenCalled(); + expect(chrome.tabs.group).not.toHaveBeenCalled(); + expect(chrome.tabGroups.update).not.toHaveBeenCalled(); + }); + + it('fails clearly in existing-window placement when no normal Chrome window is open', async () => { + const { chrome, setWindows } = createChromeMock(); + setWindows([]); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const result = await mod.__test__.handleCommand({ + id: 'existing-window-missing', + action: 'tabs', + op: 'new', + session: 'twitter', + surface: 'adapter', + url: 'https://new.example', + tabPlacement: 'existing-window', + } as any); + + expect(result).toEqual(expect.objectContaining({ + ok: false, + errorCode: 'existing_window_required', + error: expect.stringContaining('No normal Chrome window is open'), + errorHint: expect.stringContaining('Open the target Chrome profile window'), + })); + expect(chrome.windows.create).not.toHaveBeenCalled(); + expect(chrome.tabs.create).not.toHaveBeenCalled(); + expect(chrome.tabs.group).not.toHaveBeenCalled(); + expect(chrome.tabGroups.update).not.toHaveBeenCalled(); + }); + + it('closes existing-window tabs on release instead of leaving blank placeholders', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + await mod.__test__.handleCommand({ + id: 'existing-window-new', + action: 'tabs', + op: 'new', + session: 'twitter', + surface: 'adapter', + url: 'https://close.example', + tabPlacement: 'existing-window', + } as any); + + const result = await mod.__test__.handleCommand({ + id: 'existing-window-close', + action: 'close-window', + session: 'twitter', + surface: 'adapter', + tabPlacement: 'existing-window', + } as any); + + expect(result).toEqual(expect.objectContaining({ ok: true })); + expect(chrome.tabs.remove).toHaveBeenCalledWith(10); + expect(chrome.tabs.update).not.toHaveBeenCalledWith(10, { url: 'about:blank', active: true }); + expect(chrome.windows.remove).not.toHaveBeenCalled(); + expect(chrome.tabs.group).not.toHaveBeenCalled(); + expect(chrome.tabGroups.update).not.toHaveBeenCalled(); + expect(mod.__test__.getSession(adapterKey('twitter'))).toBeNull(); + }); + + it('removes stale owned-container tabs when commands switch to existing-window placement', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const tabId = await mod.__test__.resolveTabId(undefined, adapterKey('twitter'), 'https://old.example'); + expect(mod.__test__.getSession(adapterKey('twitter'))).toEqual(expect.objectContaining({ + tabPlacement: 'owned-container', + })); + + vi.clearAllMocks(); + const result = await mod.__test__.handleCommand({ + id: 'existing-window-close-stale', + action: 'close-window', + session: 'twitter', + surface: 'adapter', + tabPlacement: 'existing-window', + } as any); + + expect(result).toEqual(expect.objectContaining({ ok: true })); + expect(chrome.tabs.remove).toHaveBeenCalledWith(tabId); + expect(chrome.tabs.update).not.toHaveBeenCalledWith(tabId, { url: 'about:blank', active: true }); + expect(chrome.tabs.group).not.toHaveBeenCalled(); + expect(chrome.tabGroups.update).not.toHaveBeenCalled(); + expect(mod.__test__.getSession(adapterKey('twitter'))).toBeNull(); + }); + + it('purges stale adapter owned-container placeholders before existing-window commands', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const firstTabId = await mod.__test__.resolveTabId(undefined, adapterKey('stale-first'), 'https://first.example'); + const secondTabId = await mod.__test__.resolveTabId(undefined, adapterKey('stale-second'), 'https://second.example'); + expect(mod.__test__.getSession(adapterKey('stale-first'))).toEqual(expect.objectContaining({ + tabPlacement: 'owned-container', + })); + expect(mod.__test__.getSession(adapterKey('stale-second'))).toEqual(expect.objectContaining({ + tabPlacement: 'owned-container', + })); + + vi.clearAllMocks(); + const result = await mod.__test__.handleCommand({ + id: 'existing-window-new', + action: 'tabs', + op: 'new', + session: 'twitter', + surface: 'adapter', + url: 'https://new.example', + tabPlacement: 'existing-window', + } as any); + + expect(result).toEqual(expect.objectContaining({ ok: true })); + expect(chrome.tabs.remove).toHaveBeenCalledWith(firstTabId); + expect(chrome.tabs.remove).toHaveBeenCalledWith(secondTabId); + expect(chrome.tabs.update).not.toHaveBeenCalledWith(firstTabId, { url: 'about:blank', active: true }); + expect(chrome.tabs.update).not.toHaveBeenCalledWith(secondTabId, { url: 'about:blank', active: true }); + expect(chrome.tabs.group).not.toHaveBeenCalled(); + expect(chrome.tabGroups.update).not.toHaveBeenCalled(); + expect(mod.__test__.getSession(adapterKey('stale-first'))).toBeNull(); + expect(mod.__test__.getSession(adapterKey('stale-second'))).toBeNull(); + expect(mod.__test__.getSession(adapterKey('twitter'))).toEqual(expect.objectContaining({ + tabPlacement: 'existing-window', + })); + }); + + it('closes empty owned-container windows before existing-window commands', async () => { + const { chrome, tabs } = createChromeMock(); + tabs.push({ id: 77, windowId: 77, url: 'about:blank', title: 'blank', active: true, status: 'complete', groupId: -1 }); + vi.stubGlobal('chrome', chrome); + await chrome.storage.local.set({ + opencli_target_lease_registry_v2: { + version: 2, + contextId: 'user-default', + ownedContainers: { + interactive: { windowId: null, groupId: null }, + automation: { windowId: 77, groupId: null }, + }, + leases: {}, + }, + }); + + const mod = await import('./background'); + await mod.__test__.reconcileTargetLeaseRegistry(); + + vi.clearAllMocks(); + const result = await mod.__test__.handleCommand({ + id: 'existing-window-new', + action: 'tabs', + op: 'new', + session: 'twitter', + surface: 'adapter', + url: 'https://new.example', + tabPlacement: 'existing-window', + } as any); + + expect(result).toEqual(expect.objectContaining({ ok: true })); + expect(chrome.windows.remove).toHaveBeenCalledWith(77); + expect(chrome.tabs.group).not.toHaveBeenCalled(); + expect(chrome.tabGroups.update).not.toHaveBeenCalled(); + expect(mod.__test__.getSession(adapterKey('twitter'))).toEqual(expect.objectContaining({ + tabPlacement: 'existing-window', + })); + }); + it('creates additional adapter lease tabs in the owned window without grouping them', async () => { const { chrome, tabs, groups } = createChromeMock(); vi.stubGlobal('chrome', chrome); diff --git a/extension/src/background.ts b/extension/src/background.ts index 006776022..d14961333 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -20,6 +20,29 @@ let currentContextId = 'default'; let contextIdPromise: Promise | null = null; let connectInFlight: Promise | null = null; +type AutofillResponseMessage = { + type: 'autofill-response'; + requestId: string; + ok: boolean; + credential?: CredentialFillRequest; + error?: string; +}; + +const AUTOFILL_CANDIDATE_HOSTS = [ + 'login.taobao.com', + 'loginmyseller.taobao.com', + 'havanalogin.taobao.com', +]; +const AUTOFILL_REQUEST_TIMEOUT_MS = 2500; +const AUTOFILL_ATTEMPT_TTL_MS = 20_000; +const AUTOFILL_ATTEMPT_DELAYS_MS = [0, 400, 1200, 2500]; +let autofillRequestCounter = 0; +const pendingAutofillRequests = new Map void; + timer: ReturnType; +}>(); +const recentAutofillAttempts = new Map(); + async function getCurrentContextId(): Promise { if (contextIdPromise) return contextIdPromise; contextIdPromise = (async () => { @@ -156,7 +179,12 @@ async function connectAttempt(): Promise { thisWs.onmessage = async (event) => { if (ws !== thisWs) return; try { - const command = JSON.parse(event.data as string) as Command; + const payload = JSON.parse(event.data as string) as unknown; + if (isAutofillResponseMessage(payload)) { + handleAutofillResponse(payload); + return; + } + const command = payload as Command; const result = await handleCommand(command); if (ws !== thisWs) return; safeSend(thisWs, result); @@ -195,6 +223,151 @@ function scheduleReconnect(): void { }, delay); } +function isAutofillResponseMessage(value: unknown): value is AutofillResponseMessage { + if (!value || typeof value !== 'object') return false; + const record = value as Record; + return record.type === 'autofill-response' + && typeof record.requestId === 'string' + && typeof record.ok === 'boolean'; +} + +function handleAutofillResponse(message: AutofillResponseMessage): void { + const pending = pendingAutofillRequests.get(message.requestId); + if (!pending) return; + clearTimeout(pending.timer); + pendingAutofillRequests.delete(message.requestId); + pending.resolve(message.ok && message.credential ? message.credential : null); +} + +function isAutofillCandidateUrl(rawUrl: string | undefined): rawUrl is string { + if (!rawUrl) return false; + try { + const url = new URL(rawUrl); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; + const normalizedHost = url.hostname.trim().toLowerCase(); + return AUTOFILL_CANDIDATE_HOSTS.some((host) => ( + normalizedHost === host || normalizedHost.endsWith(`.${host}`) + )); + } catch { + return false; + } +} + +function cleanupRecentAutofillAttempts(now: number): void { + for (const [key, timestamp] of recentAutofillAttempts.entries()) { + if (now - timestamp > AUTOFILL_ATTEMPT_TTL_MS * 3) recentAutofillAttempts.delete(key); + } +} + +async function triggerAutofillForTab(tabId: number, rawUrl: string | undefined, options: { bypassRecent?: boolean } = {}): Promise { + if (!isAutofillCandidateUrl(rawUrl)) return; + console.log(`[opencli] Passive credential autofill candidate tab=${tabId} url=${rawUrl}`); + const now = Date.now(); + cleanupRecentAutofillAttempts(now); + const key = `${tabId}\n${rawUrl}`; + const lastAttempt = recentAutofillAttempts.get(key) ?? 0; + if (!options.bypassRecent && now - lastAttempt < AUTOFILL_ATTEMPT_TTL_MS) return; + recentAutofillAttempts.set(key, now); + await passiveAutofillTab(tabId, rawUrl); +} + +function scheduleAutofillForTab(tabId: number, rawUrl: string | undefined): void { + void triggerAutofillForTab(tabId, rawUrl); +} + +async function requestAutofillCredential(rawUrl: string): Promise { + const connected = await waitForOpenDaemonSocket(AUTOFILL_REQUEST_TIMEOUT_MS); + if (!connected || !ws || ws.readyState !== WebSocket.OPEN) { + console.log('[opencli] Passive credential autofill skipped: daemon socket not ready'); + return null; + } + const requestId = `autofill_${Date.now()}_${++autofillRequestCounter}`; + const contextId = await getCurrentContextId(); + return new Promise((resolve) => { + const timer = setTimeout(() => { + pendingAutofillRequests.delete(requestId); + console.log('[opencli] Passive credential autofill skipped: credential request timed out'); + resolve(null); + }, AUTOFILL_REQUEST_TIMEOUT_MS); + pendingAutofillRequests.set(requestId, { resolve, timer }); + const sent = safeSend(ws, { + type: 'autofill-request', + requestId, + contextId, + url: rawUrl, + }); + if (!sent) { + clearTimeout(timer); + pendingAutofillRequests.delete(requestId); + console.log('[opencli] Passive credential autofill skipped: credential request not sent'); + resolve(null); + } + }); +} + +async function runCredentialFillOnTab(tabId: number, request: CredentialFillRequest): Promise { + const results = await chrome.scripting.executeScript({ + target: { tabId, allFrames: true }, + world: 'MAIN', + func: credentialFillInFrame, + args: [request], + }); + const frameResults = results + .map((entry) => entry.result) + .filter((entry): entry is CredentialFillFrameResult => isCredentialFillFrameResult(entry)); + return frameResults.find((entry) => entry.ok) + ?? frameResults.find((entry) => entry.reason !== 'host_not_allowed') + ?? frameResults[0] + ?? null; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForOpenDaemonSocket(timeoutMs: number): Promise { + await connect(); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (ws?.readyState === WebSocket.OPEN) return true; + await sleep(100); + } + return ws?.readyState === WebSocket.OPEN; +} + +async function passiveAutofillTab(tabId: number, rawUrl: string): Promise { + const credential = await requestAutofillCredential(rawUrl); + if (!credential) { + console.log('[opencli] Passive credential autofill skipped: no matching credential'); + return; + } + const request: CredentialFillRequest = { + ...credential, + submit: false, + allowedHosts: cleanStringArray(credential.allowedHosts, []), + usernameSelectors: cleanStringArray(credential.usernameSelectors, DEFAULT_USERNAME_SELECTORS), + passwordSelectors: cleanStringArray(credential.passwordSelectors, DEFAULT_PASSWORD_SELECTORS), + activateTextPatterns: cleanStringArray(credential.activateTextPatterns, DEFAULT_LOGIN_ACTIVATION_TEXT), + submitSelectors: cleanStringArray(credential.submitSelectors, DEFAULT_SUBMIT_SELECTORS), + }; + if (request.allowedHosts.length === 0) return; + for (const delayMs of AUTOFILL_ATTEMPT_DELAYS_MS) { + if (delayMs > 0) await sleep(delayMs); + try { + const result = await runCredentialFillOnTab(tabId, request); + if (result?.ok) { + console.log(`[opencli] Passive credential autofill completed on ${result.host}`); + return; + } + if (result) console.log(`[opencli] Passive credential autofill attempt did not fill: ${result.reason || 'unknown'}`); + if (result && result.reason !== 'login_inputs_not_found' && result.reason !== 'host_not_allowed') return; + } catch { + console.log('[opencli] Passive credential autofill attempt failed during scripting'); + return; + } + } +} + // ─── Browser target leases ─────────────────────────────────────────── // A browser session owns or borrows a tab lease. Owned leases live in either // the interactive browser window or the background adapter window; bound leases @@ -207,6 +380,7 @@ type LeaseLifecycle = 'ephemeral' | 'persistent' | 'pinned'; type WindowRole = 'interactive' | 'automation' | 'borrowed-user'; type OwnedWindowRole = Exclude; type WindowMode = 'foreground' | 'background'; +type TabPlacement = 'owned-container' | 'existing-window'; type BrowserSurface = 'browser' | 'adapter'; type LeaseKind = 'owned' | 'bound'; @@ -223,6 +397,7 @@ type TargetLease = { ownership: LeaseOwnership; lifecycle: LeaseLifecycle; windowRole: WindowRole; + tabPlacement: TabPlacement; }; const automationSessions = new Map(); @@ -271,6 +446,7 @@ class CommandFailure extends Error { /** Per-session custom timeout overrides set via command.idleTimeout */ const sessionTimeoutOverrides = new Map(); const sessionWindowModeOverrides = new Map(); +const sessionTabPlacementOverrides = new Map(); const sessionLifecycleOverrides = new Map(); const LEASE_KEY_SEPARATOR = '\u0000'; @@ -337,6 +513,16 @@ function getWindowMode(key: string): WindowMode { ?? (getOwnedWindowRole(key) === 'interactive' ? 'foreground' : 'background'); } +function getTabPlacement(key: string): TabPlacement { + return sessionTabPlacementOverrides.get(key) + ?? automationSessions.get(key)?.tabPlacement + ?? 'owned-container'; +} + +function usesOwnedContainer(key: string): boolean { + return getTabPlacement(key) === 'owned-container'; +} + function makeAlarmName(leaseKey: string): string { return `${LEASE_IDLE_ALARM_PREFIX}${encodeURIComponent(leaseKey)}`; } @@ -358,7 +544,7 @@ function withLeaseMutation(fn: () => Promise): Promise { function makeSession( key: string, - session: Omit, + session: Omit & { tabPlacement?: TabPlacement }, ): Omit { const ownership = session.owned ? 'owned' : 'borrowed'; return { @@ -367,6 +553,7 @@ function makeSession( ownership, lifecycle: getLeaseLifecycle(key, session.kind), windowRole: getWindowRole(key, ownership), + tabPlacement: session.tabPlacement ?? getTabPlacement(key), }; } @@ -440,6 +627,7 @@ async function persistRuntimeState(): Promise { ownership: session.ownership, lifecycle: session.lifecycle, windowRole: session.windowRole, + tabPlacement: session.tabPlacement, idleDeadlineAt: session.idleDeadlineAt, updatedAt: Date.now(), }; @@ -489,6 +677,7 @@ async function removeLeaseSession(leaseKey: string): Promise { automationSessions.delete(leaseKey); sessionTimeoutOverrides.delete(leaseKey); sessionWindowModeOverrides.delete(leaseKey); + sessionTabPlacementOverrides.delete(leaseKey); sessionLifecycleOverrides.delete(leaseKey); scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); await persistRuntimeState(); @@ -921,6 +1110,68 @@ async function findReusableOwnedContainerTab(windowId: number, ownedGroupId?: nu } } +function isNormalChromeWindow(win: chrome.windows.Window | undefined): win is chrome.windows.Window & { id: number } { + return typeof win?.id === 'number' && (win.type === undefined || win.type === 'normal'); +} + +async function selectExistingNormalWindow(mode: WindowMode): Promise { + const windowsApi = chrome.windows as typeof chrome.windows & { + getLastFocused?: (queryOptions?: chrome.windows.QueryOptions) => Promise; + query?: (queryOptions?: chrome.windows.QueryOptions) => Promise; + }; + + try { + const focused = await windowsApi.getLastFocused?.({ windowTypes: ['normal'] }); + if (isNormalChromeWindow(focused)) { + await focusOwnedWindowIfRequested(focused.id, mode); + return focused.id; + } + } catch { + // Fall back to any normal window below. + } + + const windows = typeof windowsApi.query === 'function' + ? await windowsApi.query({ windowTypes: ['normal'] }).catch(() => []) + : []; + const selected = windows + .filter(isNormalChromeWindow) + .sort((a, b) => { + if (!!a.focused !== !!b.focused) return a.focused ? -1 : 1; + return a.id - b.id; + })[0]; + + if (!selected) { + throw new CommandFailure( + 'existing_window_required', + 'No normal Chrome window is open for this Browser Bridge profile.', + 'Open the target Chrome profile window first, then retry the command.', + ); + } + + await focusOwnedWindowIfRequested(selected.id, mode); + return selected.id; +} + +async function createExistingWindowTabLease(leaseKey: string, initialUrl?: string): Promise { + const targetUrl = (initialUrl && isSafeNavigationUrl(initialUrl)) ? initialUrl : BLANK_PAGE; + const windowId = await selectExistingNormalWindow(getWindowMode(leaseKey)); + const tab = await chrome.tabs.create({ windowId, url: targetUrl, active: true }); + const tabId = tab.id; + if (!tabId) throw new Error('Failed to create tab in existing Chrome window'); + + setLeaseSession(leaseKey, { + session: getSessionFromKey(leaseKey), + surface: getSurfaceFromKey(leaseKey), + kind: 'owned', + windowId, + owned: true, + preferredTabId: tabId, + tabPlacement: 'existing-window', + }); + resetWindowIdleTimer(leaseKey); + return { tabId, tab }; +} + function initialTabIsAvailable(tabId: number | undefined): tabId is number { if (tabId === undefined) return false; for (const session of automationSessions.values()) { @@ -934,6 +1185,10 @@ async function createOwnedTabLease(leaseKey: string, initialUrl?: string): Promi } async function createOwnedTabLeaseUnlocked(leaseKey: string, initialUrl?: string): Promise { + if (getTabPlacement(leaseKey) === 'existing-window') { + return createExistingWindowTabLease(leaseKey, initialUrl); + } + const targetUrl = (initialUrl && isSafeNavigationUrl(initialUrl)) ? initialUrl : BLANK_PAGE; const role = getOwnedWindowRole(leaseKey); const { windowId, initialTabId } = await ensureOwnedContainerWindow(role, targetUrl, getWindowMode(leaseKey)); @@ -997,6 +1252,9 @@ async function getAutomationWindow(leaseKey: string, initialUrl?: string): Promi } const role = getOwnedWindowRole(leaseKey); + if (!usesOwnedContainer(leaseKey)) { + return selectExistingNormalWindow(getWindowMode(leaseKey)); + } return (await ensureOwnedContainerWindow(role, initialUrl, getWindowMode(leaseKey))).windowId; } @@ -1015,6 +1273,7 @@ chrome.windows.onRemoved.addListener(async (windowId) => { automationSessions.delete(leaseKey); sessionTimeoutOverrides.delete(leaseKey); sessionWindowModeOverrides.delete(leaseKey); + sessionTabPlacementOverrides.delete(leaseKey); sessionLifecycleOverrides.delete(leaseKey); scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); } @@ -1031,6 +1290,7 @@ chrome.tabs.onRemoved.addListener(async (tabId) => { automationSessions.delete(leaseKey); sessionTimeoutOverrides.delete(leaseKey); sessionWindowModeOverrides.delete(leaseKey); + sessionTabPlacementOverrides.delete(leaseKey); sessionLifecycleOverrides.delete(leaseKey); scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); console.log(`[opencli] Session ${session.session} detached from tab ${tabId} (tab closed)`); @@ -1047,6 +1307,26 @@ function initialize(): void { if (initialized) return; initialized = true; chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds + chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + const rawUrl = changeInfo.url ?? tab.url; + if (changeInfo.status === 'complete' || changeInfo.url) { + scheduleAutofillForTab(tabId, rawUrl); + } + }); + if (chrome.webNavigation?.onCommitted) { + chrome.webNavigation.onCommitted.addListener((details) => { + scheduleAutofillForTab(details.tabId, details.url); + }, { + url: AUTOFILL_CANDIDATE_HOSTS.map((hostEquals) => ({ hostEquals })), + }); + } + if (chrome.webNavigation?.onCompleted) { + chrome.webNavigation.onCompleted.addListener((details) => { + scheduleAutofillForTab(details.tabId, details.url); + }, { + url: AUTOFILL_CANDIDATE_HOSTS.map((hostEquals) => ({ hostEquals })), + }); + } executor.registerListeners(); try { const registerFrameTracking = (executor as { registerFrameTracking?: () => void }).registerFrameTracking; @@ -1058,8 +1338,12 @@ function initialize(): void { await getCurrentContextId(); await reconcileTargetLeaseRegistry(); await connect(); + const tabs = await chrome.tabs.query({}); + for (const tab of tabs) { + if (typeof tab.id === 'number') scheduleAutofillForTab(tab.id, tab.url); + } })(); - console.log('[opencli] OpenCLI extension initialized'); + console.log('[opencli] OpenCLI extension initialized; passive credential autofill listener active'); } chrome.runtime.onInstalled.addListener(() => { @@ -1083,7 +1367,14 @@ chrome.alarms.onAlarm.addListener(async (alarm) => { // ─── Popup status API ─────────────────────────────────────────────── -chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { +chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + if (msg?.type === 'autofill-page-ready') { + const tabId = sender.tab?.id; + const rawUrl = typeof msg.url === 'string' ? msg.url : sender.url; + if (typeof tabId === 'number') scheduleAutofillForTab(tabId, rawUrl); + sendResponse({ ok: true }); + return false; + } if (msg?.type === 'getStatus') { void (async () => { const contextId = await getCurrentContextId(); @@ -1129,9 +1420,17 @@ async function handleCommand(cmd: Command): Promise { const session = getSessionName(cmd.session); const surface = getCommandSurface(cmd); const leaseKey = getLeaseKey(session, surface); + const requestedExistingWindowPlacement = cmd.tabPlacement === 'existing-window'; if (cmd.windowMode === 'foreground' || cmd.windowMode === 'background') { sessionWindowModeOverrides.set(leaseKey, cmd.windowMode); } + if (cmd.tabPlacement === 'owned-container' || cmd.tabPlacement === 'existing-window') { + sessionTabPlacementOverrides.set(leaseKey, cmd.tabPlacement); + } + if (requestedExistingWindowPlacement) { + await releaseOwnedContainerSessionsForSurface(surface, 'tab placement changed'); + sessionTabPlacementOverrides.set(leaseKey, 'existing-window'); + } if (surface === 'adapter' && (cmd.siteSession === 'persistent' || cmd.siteSession === 'ephemeral')) { sessionLifecycleOverrides.set(leaseKey, cmd.siteSession); } @@ -1161,6 +1460,10 @@ async function handleCommand(cmd: Command): Promise { return await handleSetFileInput(cmd, leaseKey); case 'insert-text': return await handleInsertText(cmd, leaseKey); + case 'credential-fill': + return await handleCredentialFill(cmd, leaseKey); + case 'credential-autofill': + return await handleCredentialAutofill(cmd, leaseKey); case 'bind': return await handleBind(cmd, leaseKey); case 'network-capture-start': @@ -1263,7 +1566,7 @@ function enumerateCrossOriginFrames(tree: any): Array<{ index: number; frameId: function setLeaseSession( leaseKey: string, - session: Omit, + session: Omit & { tabPlacement?: TabPlacement }, ): void { const existing = automationSessions.get(leaseKey); if (existing?.idleTimer) clearTimeout(existing.idleTimer); @@ -1293,6 +1596,10 @@ type ResolvedTab = { tabId: number; tab: chrome.tabs.Tab | null }; */ async function resolveTab(tabId: number | undefined, leaseKey: string, initialUrl?: string): Promise { const existingSession = automationSessions.get(leaseKey); + if (existingSession?.owned && existingSession.tabPlacement !== getTabPlacement(leaseKey)) { + await releaseLease(leaseKey, 'tab placement changed'); + return createOwnedTabLease(leaseKey, initialUrl); + } // Even when an explicit tabId is provided, validate it is still debuggable. if (tabId !== undefined) { try { @@ -1402,13 +1709,21 @@ async function resolveTab(tabId: number | undefined, leaseKey: string, initialUr // Fallback: create a new tab const newTab = await chrome.tabs.create({ windowId: scopedWindowId, url: BLANK_PAGE, active: true }); if (!newTab.id) throw new Error('Failed to create tab in automation container'); - await ensureOwnedContainerGroup(role, scopedWindowId, [newTab.id]); + if (usesOwnedContainer(leaseKey)) { + await ensureOwnedContainerGroup(role, scopedWindowId, [newTab.id]); + } return { tabId: newTab.id, tab: await chrome.tabs.get(newTab.id) }; } /** Build a page-scoped success result with targetId resolved from tabId */ async function pageScopedResult(id: string, tabId: number, data?: unknown): Promise { const page = await identity.resolveTargetId(tabId); + try { + const tab = await chrome.tabs.get(tabId); + await triggerAutofillForTab(tabId, tab.url, { bypassRecent: true }); + } catch { + // Passive autofill is best-effort and must not affect command results. + } return { id, ok: true, data, page }; } @@ -1491,6 +1806,7 @@ async function handleNavigate(cmd: Command, leaseKey: string): Promise { // Fast-path: tab is already at the target URL and fully loaded. if (beforeTab.status === 'complete' && isTargetUrl(beforeTab.url, targetUrl)) { + await triggerAutofillForTab(tabId, beforeTab.url ?? targetUrl, { bypassRecent: true }); return pageScopedResult(cmd.id, tabId, { title: beforeTab.title, url: beforeTab.url, timedOut: false }); } @@ -1572,6 +1888,7 @@ async function handleNavigate(cmd: Command, leaseKey: string): Promise { } } + await triggerAutofillForTab(tabId, tab.url ?? targetUrl, { bypassRecent: true }); return pageScopedResult(cmd.id, tabId, { title: tab.title, url: tab.url, timedOut }); } @@ -1608,7 +1925,9 @@ async function handleTabs(cmd: Command, leaseKey: string): Promise { let tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true }); const tabId = tab.id; if (!tabId) return { id: cmd.id, ok: false, error: 'Failed to create tab' }; - const group = await ensureOwnedContainerGroup(getOwnedWindowRole(leaseKey), windowId, [tabId]); + const group = usesOwnedContainer(leaseKey) + ? await ensureOwnedContainerGroup(getOwnedWindowRole(leaseKey), windowId, [tabId]) + : null; const sessionWindowId = group?.windowId ?? tab.windowId; if (tab.windowId !== sessionWindowId) tab = await chrome.tabs.get(tabId); setLeaseSession(leaseKey, { @@ -1618,6 +1937,7 @@ async function handleTabs(cmd: Command, leaseKey: string): Promise { windowId: sessionWindowId, owned: true, preferredTabId: tabId, + tabPlacement: getTabPlacement(leaseKey), }); resetWindowIdleTimer(leaseKey); return pageScopedResult(cmd.id, tabId, { url: tab.url }); @@ -1813,6 +2133,312 @@ async function handleInsertText(cmd: Command, leaseKey: string): Promise } } +type CredentialFillFrameResult = { + ok: boolean; + host: string; + username_filled: boolean; + password_filled: boolean; + submitted: boolean; + reason?: string; +}; + +type CredentialFillRequest = { + username: string; + password: string; + allowedHosts: string[]; + usernameSelectors: string[]; + passwordSelectors: string[]; + activateTextPatterns: string[]; + submitSelectors: string[]; + submit: boolean; +}; + +const DEFAULT_USERNAME_SELECTORS = [ + '#fm-login-id', + 'input[name="fm-login-id"]', + 'input[autocomplete="username"]', + 'input[type="email"]', + 'input[type="text"]', +]; + +const DEFAULT_PASSWORD_SELECTORS = [ + '#fm-login-password', + 'input[name="fm-login-password"]', + 'input[autocomplete="current-password"]', + 'input[type="password"]', +]; + +const DEFAULT_LOGIN_ACTIVATION_TEXT = ['账号密码登录', '密码登录', '账号登录']; + +const DEFAULT_SUBMIT_SELECTORS = [ + 'button[type="submit"]', + 'input[type="submit"]', + 'button', + 'a', +]; + +function cleanStringArray(value: unknown, fallback: string[]): string[] { + if (!Array.isArray(value)) return fallback; + const cleaned = value + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + return cleaned.length > 0 ? cleaned : fallback; +} + +async function handleCredentialFill(cmd: Command, leaseKey: string): Promise { + if (typeof cmd.username !== 'string' || typeof cmd.password !== 'string') { + return { id: cmd.id, ok: false, error: 'Missing credential payload' }; + } + if (!Array.isArray(cmd.allowedHosts) || cmd.allowedHosts.length === 0) { + return { id: cmd.id, ok: false, error: 'Missing allowedHosts payload' }; + } + + const request: CredentialFillRequest = { + username: cmd.username, + password: cmd.password, + allowedHosts: cleanStringArray(cmd.allowedHosts, []), + usernameSelectors: cleanStringArray(cmd.usernameSelectors, DEFAULT_USERNAME_SELECTORS), + passwordSelectors: cleanStringArray(cmd.passwordSelectors, DEFAULT_PASSWORD_SELECTORS), + activateTextPatterns: cleanStringArray(cmd.activateTextPatterns, DEFAULT_LOGIN_ACTIVATION_TEXT), + submitSelectors: cleanStringArray(cmd.submitSelectors, DEFAULT_SUBMIT_SELECTORS), + submit: cmd.submit !== false, + }; + + if (request.allowedHosts.length === 0) { + return { id: cmd.id, ok: false, error: 'Missing allowedHosts payload' }; + } + + const cmdTabId = await resolveCommandTabId(cmd); + const tabId = await resolveTabId(cmdTabId, leaseKey); + try { + const results = await chrome.scripting.executeScript({ + target: { tabId, allFrames: true }, + world: 'MAIN', + func: credentialFillInFrame, + args: [request], + }); + const frameResults = results + .map((entry) => ({ + frameId: entry.frameId, + result: entry.result, + })) + .filter((entry): entry is { frameId: number; result: CredentialFillFrameResult } => + isCredentialFillFrameResult(entry.result), + ); + const success = frameResults.find((entry) => entry.result.ok); + const selected = success ?? frameResults.find((entry) => entry.result.reason !== 'host_not_allowed') ?? frameResults[0]; + const data = selected + ? { ...selected.result, frameId: selected.frameId } + : { + ok: false, + host: '', + username_filled: false, + password_filled: false, + submitted: false, + reason: 'no_frame_result', + }; + return pageScopedResult(cmd.id, tabId, data); + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +async function handleCredentialAutofill(cmd: Command, leaseKey: string): Promise { + const cmdTabId = await resolveCommandTabId(cmd); + const tabId = await resolveTabId(cmdTabId, leaseKey); + try { + const tab = await chrome.tabs.get(tabId); + const rawUrl = cmd.url ?? tab.url; + await triggerAutofillForTab(tabId, rawUrl, { bypassRecent: true }); + return pageScopedResult(cmd.id, tabId, { + attempted: isAutofillCandidateUrl(rawUrl), + url: rawUrl, + }); + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +function isCredentialFillFrameResult(value: unknown): value is CredentialFillFrameResult { + if (!value || typeof value !== 'object') return false; + const record = value as Record; + return typeof record.ok === 'boolean' + && typeof record.host === 'string' + && typeof record.username_filled === 'boolean' + && typeof record.password_filled === 'boolean' + && typeof record.submitted === 'boolean'; +} + +async function credentialFillInFrame(request: CredentialFillRequest): Promise { + const host = window.location.hostname; + const normalizeHost = (value: string) => value.trim().toLowerCase().replace(/^\.+/, ''); + const normalizedHost = normalizeHost(host); + const allowed = request.allowedHosts.some((entry) => { + const allowedHost = normalizeHost(entry); + return allowedHost.length > 0 + && (normalizedHost === allowedHost || normalizedHost.endsWith(`.${allowedHost}`)); + }); + if (!allowed) { + return { + ok: false, + host, + username_filled: false, + password_filled: false, + submitted: false, + reason: 'host_not_allowed', + }; + } + + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + const clean = (value: unknown) => String(value ?? '').replace(/\s+/g, ' ').trim(); + const isVisible = (element: Element | null): element is HTMLElement => { + if (!(element instanceof HTMLElement)) return false; + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.visibility !== 'hidden' + && style.display !== 'none' + && rect.width > 0 + && rect.height > 0 + && !element.hasAttribute('disabled'); + }; + const queryFirstVisibleInput = (selectors: string[]) => { + for (const selector of selectors) { + let elements: Element[] = []; + try { + elements = Array.from(document.querySelectorAll(selector)); + } catch { + continue; + } + const found = elements.find((element): element is HTMLInputElement | HTMLTextAreaElement => + (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) + && isVisible(element) + && !element.readOnly, + ); + if (found) return found; + } + return null; + }; + const textOf = (element: HTMLElement) => clean([ + element.innerText, + element.getAttribute('aria-label'), + element.getAttribute('title'), + element.getAttribute('placeholder'), + element instanceof HTMLInputElement ? element.value : '', + ].filter(Boolean).join(' ')); + const matchesActivationText = (text: string) => request.activateTextPatterns.some((pattern) => { + try { + return new RegExp(pattern).test(text); + } catch { + return text === pattern || text.includes(pattern); + } + }); + const activateLoginMode = () => { + const candidates = Array.from(document.querySelectorAll('button, a, input, div, span')) + .filter(isVisible); + const target = candidates.find((element) => matchesActivationText(textOf(element))); + if (!target) return false; + target.click(); + return true; + }; + const makeInputEvent = (type: string, value: string) => { + try { + return new InputEvent(type, { bubbles: true, cancelable: false, inputType: 'insertText', data: value }); + } catch { + return new Event(type, { bubbles: true, cancelable: false }); + } + }; + const dispatchLegacyEvent = (field: HTMLElement, eventName: string) => { + const event = field.ownerDocument.createEvent('Event'); + event.initEvent(eventName, true, false); + field.dispatchEvent(event); + }; + const setValue = async (field: HTMLInputElement | HTMLTextAreaElement, value: string) => { + field.focus(); + await Promise.resolve(); + field.dispatchEvent(new FocusEvent('focus', { bubbles: false, cancelable: false })); + field.dispatchEvent(new FocusEvent('focusin', { bubbles: true, cancelable: false })); + field.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: false, key: value })); + field.dispatchEvent(makeInputEvent('beforeinput', value)); + field.dispatchEvent(new KeyboardEvent('keypress', { bubbles: true, cancelable: false, key: value })); + const descriptor = Object.getOwnPropertyDescriptor( + field instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype, + 'value', + ); + if (descriptor?.set) { + descriptor.set.call(field, value); + } else { + field.value = value; + } + field.dispatchEvent(new Event('input', { bubbles: true, cancelable: false })); + field.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: false, key: value })); + field.dispatchEvent(new Event('change', { bubbles: true, cancelable: false })); + dispatchLegacyEvent(field, 'input'); + dispatchLegacyEvent(field, 'change'); + }; + const findSubmitTarget = (usernameInput: HTMLInputElement | HTMLTextAreaElement | null, passwordInput: HTMLInputElement | HTMLTextAreaElement | null) => { + for (const selector of request.submitSelectors) { + let elements: Element[] = []; + try { + elements = Array.from(document.querySelectorAll(selector)); + } catch { + continue; + } + const found = elements.find((element): element is HTMLElement => { + if (!isVisible(element)) return false; + const text = textOf(element); + return /^(登录|登 录|提交|确认|继续)$/.test(text) + || element.getAttribute('type') === 'submit'; + }); + if (found) return found; + } + const form = passwordInput?.form ?? usernameInput?.form ?? null; + return form?.querySelector('button[type="submit"], input[type="submit"]') ?? null; + }; + + let usernameInput = queryFirstVisibleInput(request.usernameSelectors); + let passwordInput = queryFirstVisibleInput(request.passwordSelectors); + if ((!usernameInput || !passwordInput) && activateLoginMode()) { + await sleep(500); + usernameInput = queryFirstVisibleInput(request.usernameSelectors); + passwordInput = queryFirstVisibleInput(request.passwordSelectors); + } + if (!usernameInput || !passwordInput) { + return { + ok: false, + host, + username_filled: false, + password_filled: false, + submitted: false, + reason: 'login_inputs_not_found', + }; + } + + await setValue(usernameInput, request.username); + await setValue(passwordInput, request.password); + + let submitted = false; + if (request.submit) { + const submitTarget = findSubmitTarget(usernameInput, passwordInput); + if (submitTarget instanceof HTMLElement) { + submitTarget.click(); + submitted = true; + } else if (passwordInput.form && typeof passwordInput.form.requestSubmit === 'function') { + passwordInput.form.requestSubmit(); + submitted = true; + } + } + + return { + ok: true, + host, + username_filled: clean(usernameInput.value).length > 0, + password_filled: clean(passwordInput.value).length > 0, + submitted, + reason: '', + }; +} + async function handleNetworkCaptureStart(cmd: Command, leaseKey: string): Promise { const cmdTabId = await resolveCommandTabId(cmd); const tabId = await resolveTabId(cmdTabId, leaseKey); @@ -1844,11 +2470,47 @@ async function handleWaitDownload(cmd: Command): Promise { } } +async function releaseOwnedContainerSessionsForSurface(surface: BrowserSurface, reason: string): Promise { + const staleLeaseKeys = [...automationSessions.entries()] + .filter(([, session]) => session.surface === surface && session.owned && session.tabPlacement === 'owned-container') + .map(([leaseKey]) => leaseKey); + for (const staleLeaseKey of staleLeaseKeys) { + sessionTabPlacementOverrides.set(staleLeaseKey, 'existing-window'); + await releaseLease(staleLeaseKey, reason); + } + await closeEmptyOwnedContainerWindowForSurface(surface); +} + +async function closeEmptyOwnedContainerWindowForSurface(surface: BrowserSurface): Promise { + const role: OwnedWindowRole = surface === 'browser' ? 'interactive' : 'automation'; + const container = ownedContainers[role]; + const windowId = container.windowId; + if (windowId === null) return; + try { + const tabs = await chrome.tabs.query({ windowId }); + const hasNonBlankTab = tabs.some((tab) => { + const url = tab.url ?? ''; + return url !== '' && url !== BLANK_PAGE; + }); + if (hasNonBlankTab) return; + await chrome.windows.remove(windowId); + container.windowId = null; + container.groupId = null; + await persistRuntimeState(); + console.log(`[opencli] Closed empty ${role} owned-container window ${windowId} before existing-window command`); + } catch { + container.windowId = null; + container.groupId = null; + await persistRuntimeState(); + } +} + async function releaseLease(leaseKey: string, reason: string = 'released'): Promise { const session = automationSessions.get(leaseKey); if (!session) { sessionTimeoutOverrides.delete(leaseKey); sessionWindowModeOverrides.delete(leaseKey); + sessionTabPlacementOverrides.delete(leaseKey); sessionLifecycleOverrides.delete(leaseKey); scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); await persistRuntimeState(); @@ -1857,6 +2519,10 @@ async function releaseLease(leaseKey: string, reason: string = 'released'): Prom if (session.idleTimer) clearTimeout(session.idleTimer); scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); + const releaseTabPlacement = sessionTabPlacementOverrides.get(leaseKey) ?? session.tabPlacement; + const shouldRemoveOwnedTab = session.tabPlacement === 'existing-window' + || releaseTabPlacement === 'existing-window' + || reason === 'tab placement changed'; if (session.owned) { const tabId = session.preferredTabId; @@ -1872,10 +2538,15 @@ async function releaseLease(leaseKey: string, reason: string = 'released'): Prom if (hasOtherOwnedLease) { await chrome.tabs.remove(tabId).catch(() => {}); console.log(`[opencli] Released owned tab lease ${tabId} (session=${session.session}, surface=${session.surface}, ${reason})`); + } else if (shouldRemoveOwnedTab) { + await chrome.tabs.remove(tabId).catch(() => {}); + console.log(`[opencli] Released existing-window tab lease ${tabId} (session=${session.session}, surface=${session.surface}, ${reason})`); } else { try { const tab = await chrome.tabs.update(tabId, { url: BLANK_PAGE, active: true }); - const group = await ensureOwnedContainerGroup(getOwnedWindowRole(leaseKey), session.windowId, [tab.id ?? tabId]); + const group = releaseTabPlacement === 'owned-container' + ? await ensureOwnedContainerGroup(getOwnedWindowRole(leaseKey), session.windowId, [tab.id ?? tabId]) + : null; if (group) session.windowId = group.windowId; console.log(`[opencli] Released owned tab lease ${tabId} as reusable placeholder (session=${session.session}, surface=${session.surface}, ${reason})`); } catch { @@ -1894,6 +2565,7 @@ async function releaseLease(leaseKey: string, reason: string = 'released'): Prom automationSessions.delete(leaseKey); sessionTimeoutOverrides.delete(leaseKey); sessionWindowModeOverrides.delete(leaseKey); + sessionTabPlacementOverrides.delete(leaseKey); sessionLifecycleOverrides.delete(leaseKey); await persistRuntimeState(); @@ -1932,6 +2604,7 @@ async function reconcileTargetLeaseRegistry(): Promise { windowId: tab.windowId, owned: stored.owned, preferredTabId: tabId, + tabPlacement: stored.tabPlacement === 'existing-window' ? 'existing-window' : 'owned-container', }); const timeout = getIdleTimeout(leaseKey); automationSessions.set(leaseKey, { @@ -1939,7 +2612,7 @@ async function reconcileTargetLeaseRegistry(): Promise { idleTimer: null, idleDeadlineAt: stored.idleDeadlineAt, }); - if (session.owned) { + if (session.owned && session.tabPlacement === 'owned-container') { const role = getOwnedWindowRole(leaseKey); if (ownedContainers[role].windowId === null) ownedContainers[role].windowId = tab.windowId; const group = await ensureOwnedContainerGroup(role, tab.windowId, [tabId]); diff --git a/extension/src/cdp.test.ts b/extension/src/cdp.test.ts index 2eeb9a716..8338556fb 100644 --- a/extension/src/cdp.test.ts +++ b/extension/src/cdp.test.ts @@ -63,6 +63,42 @@ describe('cdp attach recovery', () => { expect(scripting.executeScript).not.toHaveBeenCalled(); }); + it('enables page events after attaching so native dialogs can be observed', async () => { + const { chrome, debuggerApi } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + await mod.evaluate(1, '1'); + + expect(debuggerApi.sendCommand).toHaveBeenCalledWith( + { tabId: 1 }, + 'Page.enable', + ); + }); + + it('auto-accepts beforeunload dialogs from Chrome', async () => { + const { chrome, debuggerApi, debuggerEventListeners } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + mod.registerListeners(); + + expect(debuggerEventListeners.length).toBeGreaterThanOrEqual(1); + for (const listener of debuggerEventListeners) { + await listener( + { tabId: 1 }, + 'Page.javascriptDialogOpening', + { type: 'beforeunload', message: 'Changes you made may not be saved.' }, + ); + } + + expect(debuggerApi.sendCommand).toHaveBeenCalledWith( + { tabId: 1 }, + 'Page.handleJavaScriptDialog', + { accept: true }, + ); + }); + it('uses the default execution context for a frame when isolated worlds also exist', async () => { const { chrome, debuggerApi, debuggerEventListeners } = createChromeMock(); vi.stubGlobal('chrome', chrome); diff --git a/extension/src/cdp.ts b/extension/src/cdp.ts index b292f516b..342b04bea 100644 --- a/extension/src/cdp.ts +++ b/extension/src/cdp.ts @@ -58,6 +58,7 @@ export type DownloadWaitResult = { }; const networkCaptures = new Map(); +const autoHandledDialogs = new Map(); /** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */ function isDebuggableUrl(url?: string): boolean { if (!url) return true; // empty/undefined = tab still loading, allow it @@ -153,6 +154,22 @@ export async function ensureAttached(tabId: number, aggressiveRetry: boolean = f } catch { // Some pages may not need explicit enable } + try { + await chrome.debugger.sendCommand({ tabId }, 'Page.enable'); + } catch { + // Dialog events are best-effort. Commands can still operate on pages that + // refuse Page.enable. + } +} + +function rememberAutoHandledDialog(tabId: number, type: string, message: string): void { + const existing = autoHandledDialogs.get(tabId); + autoHandledDialogs.set(tabId, { + count: (existing?.count ?? 0) + 1, + lastType: type, + lastMessage: message, + lastAt: Date.now(), + }); } export async function evaluate(tabId: number, expression: string, aggressiveRetry: boolean = false): Promise { @@ -701,6 +718,7 @@ export function registerListeners(): void { attached.delete(tabId); networkCaptures.delete(tabId); tabFrameContexts.delete(tabId); + autoHandledDialogs.delete(tabId); clearFrameTargetsForTab(tabId); }); chrome.debugger.onDetach.addListener((source) => { @@ -708,6 +726,7 @@ export function registerListeners(): void { attached.delete(source.tabId); networkCaptures.delete(source.tabId); tabFrameContexts.delete(source.tabId); + autoHandledDialogs.delete(source.tabId); clearFrameTargetsForTab(source.tabId); return; } @@ -722,9 +741,23 @@ export function registerListeners(): void { chrome.debugger.onEvent.addListener(async (source, method, params) => { const tabId = source.tabId; if (!tabId) return; + const eventParams = params as Record | undefined; + if (method === 'Page.javascriptDialogOpening') { + const type = String(eventParams?.type || ''); + const message = String(eventParams?.message || ''); + if (type === 'beforeunload') { + try { + await chrome.debugger.sendCommand({ tabId }, 'Page.handleJavaScriptDialog', { accept: true }); + rememberAutoHandledDialog(tabId, type, message); + console.warn(`[opencli] auto-accepted beforeunload dialog for tab ${tabId}: ${message || 'Leave site?'}`); + } catch (err) { + console.warn(`[opencli] failed to auto-accept beforeunload dialog for tab ${tabId}: ${err instanceof Error ? err.message : String(err)}`); + } + } + return; + } const state = networkCaptures.get(tabId); if (!state) return; - const eventParams = params as Record | undefined; if (method === 'Network.requestWillBeSent') { const requestId = String(eventParams?.requestId || ''); diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts index ba5acc79d..325fe2247 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -15,6 +15,8 @@ export type Action = | 'sessions' | 'set-file-input' | 'insert-text' + | 'credential-fill' + | 'credential-autofill' | 'bind' | 'network-capture-start' | 'network-capture-read' @@ -61,6 +63,22 @@ export interface Command { selector?: string; /** Raw text payload for insert-text action */ text?: string; + /** Username payload for credential-fill action */ + username?: string; + /** Password payload for credential-fill action */ + password?: string; + /** Allowed host suffixes for credential-fill action */ + allowedHosts?: string[]; + /** Username input selectors for credential-fill action */ + usernameSelectors?: string[]; + /** Password input selectors for credential-fill action */ + passwordSelectors?: string[]; + /** Optional login-mode labels to activate before filling */ + activateTextPatterns?: string[]; + /** Submit button selectors for credential-fill action */ + submitSelectors?: string[]; + /** Whether credential-fill should submit after filling */ + submit?: boolean; /** URL substring filter pattern for network capture actions */ pattern?: string; /** Download wait timeout in milliseconds */ @@ -71,6 +89,8 @@ export interface Command { cdpParams?: Record; /** Window foreground/background policy for owned Browser Bridge containers. */ windowMode?: 'foreground' | 'background'; + /** Owned tab placement policy. Defaults to OpenCLI-managed windows/groups. */ + tabPlacement?: 'owned-container' | 'existing-window'; /** Custom idle timeout in seconds for this session. Overrides the default. */ idleTimeout?: number; /** Frame index for cross-frame operations (0-based, from 'frames' action) */ @@ -98,7 +118,7 @@ export interface Result { /** Default daemon port */ export const DAEMON_PORT = 19825; -export const DAEMON_HOST = 'localhost'; +export const DAEMON_HOST = '127.0.0.1'; export const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; /** Lightweight health-check endpoint — probed before each WebSocket attempt. */ export const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; diff --git a/extension/vite.config.ts b/extension/vite.config.ts index a538ef51c..630d9ad67 100644 --- a/extension/vite.config.ts +++ b/extension/vite.config.ts @@ -13,9 +13,12 @@ export default defineConfig({ outDir: 'dist', emptyOutDir: true, rollupOptions: { - input: resolve(__dirname, 'src/background.ts'), + input: { + background: resolve(__dirname, 'src/background.ts'), + 'autofill-content': resolve(__dirname, 'src/autofill-content.ts'), + }, output: { - entryFileNames: 'background.js', + entryFileNames: '[name].js', format: 'es', }, }, diff --git a/src/browser/bridge.ts b/src/browser/bridge.ts index 9fc26d9f5..6c69de429 100644 --- a/src/browser/bridge.ts +++ b/src/browser/bridge.ts @@ -12,6 +12,7 @@ import { BrowserConnectError } from '../errors.js'; import { PKG_VERSION } from '../version.js'; import { resolveProfileContextId } from './profile.js'; import { resolveDaemonLaunchSpec, spawnDaemonProcess, waitForDaemonStop } from './daemon-lifecycle.js'; +import type { BrowserTabPlacement } from './tab-placement.js'; const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension @@ -29,7 +30,7 @@ export class BrowserBridge implements IBrowserFactory { return this._state; } - async connect(opts: { timeout?: number; session?: string; idleTimeout?: number; contextId?: string; windowMode?: 'foreground' | 'background'; surface?: 'browser' | 'adapter'; siteSession?: 'ephemeral' | 'persistent' } = {}): Promise { + async connect(opts: { timeout?: number; session?: string; idleTimeout?: number; contextId?: string; windowMode?: 'foreground' | 'background'; tabPlacement?: BrowserTabPlacement; surface?: 'browser' | 'adapter'; siteSession?: 'ephemeral' | 'persistent' } = {}): Promise { if (this._state === 'connected' && this._page) return this._page; if (this._state === 'connecting') throw new Error('Already connecting'); if (this._state === 'closing') throw new Error('Session is closing'); @@ -41,7 +42,7 @@ export class BrowserBridge implements IBrowserFactory { const contextId = opts.contextId ?? resolveProfileContextId(); await this._ensureDaemon(opts.timeout, contextId); if (!opts.session?.trim()) throw new Error('Browser session is required'); - this._page = new Page(opts.session.trim(), opts.idleTimeout, contextId, opts.windowMode, opts.surface, opts.siteSession); + this._page = new Page(opts.session.trim(), opts.idleTimeout, contextId, opts.windowMode, opts.surface, opts.siteSession, opts.tabPlacement); this._state = 'connected'; return this._page; } catch (err) { diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index a4be15c50..2daaad7d8 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -19,6 +19,7 @@ import { waitForDomStableJs } from './dom-helpers.js'; import { isRecord, saveBase64ToFile } from '../utils.js'; import { getAllElectronApps } from '../electron-apps.js'; import { BasePage } from './base-page.js'; +import { BROWSER_VIEWPORT_METRICS_SCRIPT, clickScreenPoint, viewportPointToScreenPoint, type BrowserViewportMetrics } from './system-input.js'; export interface CDPTarget { type?: string; @@ -443,6 +444,11 @@ class CDPPage extends BasePage { }); } + async systemClick(x: number, y: number): Promise { + const metrics = await this.evaluate(BROWSER_VIEWPORT_METRICS_SCRIPT); + await clickScreenPoint(viewportPointToScreenPoint(metrics, { x, y })); + } + async nativeType(text: string): Promise { await this.cdp('Input.insertText', { text }); } diff --git a/src/browser/daemon-client.test.ts b/src/browser/daemon-client.test.ts index 2a65f91d1..69ea2f99e 100644 --- a/src/browser/daemon-client.test.ts +++ b/src/browser/daemon-client.test.ts @@ -197,6 +197,32 @@ describe('daemon-client', () => { expect(body.windowMode).toBe('background'); }); + it('sendCommand forwards OPENCLI_TAB_PLACEMENT to the daemon command', async () => { + vi.stubEnv('OPENCLI_TAB_PLACEMENT', 'existing-window'); + vi.mocked(fetch).mockResolvedValue({ + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }), + } as Response); + + await sendCommand('exec', { code: '1 + 1' }); + + const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body)) as { tabPlacement?: string }; + expect(body.tabPlacement).toBe('existing-window'); + }); + + it('sendCommand rejects invalid OPENCLI_TAB_PLACEMENT values before dispatch', async () => { + vi.stubEnv('OPENCLI_TAB_PLACEMENT', 'new-window'); + vi.mocked(fetch).mockResolvedValue({ + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }), + } as Response); + + await expect(sendCommand('exec', { code: '1 + 1' })).rejects.toThrow( + 'OPENCLI_TAB_PLACEMENT must be one of: owned-container, existing-window. Received: "new-window"', + ); + expect(fetch).not.toHaveBeenCalled(); + }); + it('sendCommand retries with a new id when daemon reports a duplicate pending id', async () => { vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_123); const fetchMock = vi.mocked(fetch); diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index 1a789a87f..29392ed74 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -8,6 +8,7 @@ import { DEFAULT_DAEMON_PORT } from '../constants.js'; import { sleep } from '../utils.js'; import { classifyBrowserError } from './errors.js'; import { resolveProfileContextId } from './profile.js'; +import { resolveBrowserTabPlacementFromEnv, type BrowserTabPlacement } from './tab-placement.js'; const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10); const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`; @@ -21,7 +22,7 @@ function generateId(): string { export interface DaemonCommand { id: string; - action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'set-file-input' | 'insert-text' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'wait-download' | 'cdp' | 'frames'; + action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'set-file-input' | 'insert-text' | 'credential-fill' | 'credential-autofill' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'wait-download' | 'cdp' | 'frames'; /** Target page identity (targetId). Cross-layer contract with the extension. */ page?: string; code?: string; @@ -47,6 +48,22 @@ export interface DaemonCommand { selector?: string; /** Raw text payload for insert-text action */ text?: string; + /** Username payload for credential-fill action */ + username?: string; + /** Password payload for credential-fill action */ + password?: string; + /** Allowed host suffixes for credential-fill action */ + allowedHosts?: string[]; + /** Username input selectors for credential-fill action */ + usernameSelectors?: string[]; + /** Password input selectors for credential-fill action */ + passwordSelectors?: string[]; + /** Optional login-mode labels to activate before filling */ + activateTextPatterns?: string[]; + /** Submit button selectors for credential-fill action */ + submitSelectors?: string[]; + /** Whether credential-fill should submit after filling */ + submit?: boolean; /** URL substring filter pattern for network capture */ pattern?: string; /** Download wait timeout in milliseconds */ @@ -55,6 +72,8 @@ export interface DaemonCommand { cdpParams?: Record; /** Window foreground/background policy for owned Browser Bridge containers. */ windowMode?: 'foreground' | 'background'; + /** Tab placement policy for Browser Bridge owned sessions. */ + tabPlacement?: BrowserTabPlacement; /** Custom idle timeout in seconds for this session. Overrides the default. */ idleTimeout?: number; /** Frame index for cross-frame operations (0-based, from 'frames' action) */ @@ -186,7 +205,15 @@ async function sendCommandRaw( : undefined; const contextId = params.contextId ?? resolveProfileContextId(); const windowMode = params.windowMode ?? envWindowMode; - const command: DaemonCommand = { id, action, ...params, ...(contextId && { contextId }), ...(windowMode && { windowMode }) }; + const tabPlacement = params.tabPlacement ?? resolveBrowserTabPlacementFromEnv(); + const command: DaemonCommand = { + id, + action, + ...params, + ...(contextId && { contextId }), + ...(windowMode && { windowMode }), + ...(tabPlacement && { tabPlacement }), + }; try { const res = await requestDaemon('/command', { method: 'POST', diff --git a/src/browser/page.test.ts b/src/browser/page.test.ts index 549ea1b0f..cb7de8d89 100644 --- a/src/browser/page.test.ts +++ b/src/browser/page.test.ts @@ -72,6 +72,40 @@ describe('Page.getCurrentUrl', () => { page: 'page-1', })); }); + + it('rejects browser commands after closeWindow so timed-out work cannot recreate tabs', async () => { + sendCommandMock.mockResolvedValueOnce({ closed: true }); + + const page = new Page('site:taobao', undefined, undefined, undefined, 'adapter', 'ephemeral', 'existing-window'); + + await page.closeWindow(); + + await expect(page.evaluate('document.title')).rejects.toThrow('Browser page is closed'); + expect(sendCommandMock).toHaveBeenCalledTimes(1); + expect(sendCommandMock).toHaveBeenCalledWith('close-window', expect.objectContaining({ + session: 'site:taobao', + surface: 'adapter', + tabPlacement: 'existing-window', + })); + }); + + it('tracks the page created by network capture so trace pre-navigation reuses it', async () => { + sendCommandFullMock.mockResolvedValueOnce({ data: { started: true }, page: 'page-capture' }); + sendCommandFullMock.mockResolvedValueOnce({ data: { url: 'https://item.upload.taobao.com/' }, page: 'page-capture' }); + sendCommandMock.mockResolvedValueOnce(null); + + const page = new Page('site:taobao', undefined, undefined, undefined, 'adapter', 'ephemeral', 'existing-window'); + + await expect(page.startNetworkCapture()).resolves.toBe(true); + expect(page.getActivePage()).toBe('page-capture'); + + await page.goto('https://item.upload.taobao.com/sell/v2/publish.htm?catId=50008406', { waitUntil: 'none' }); + + expect(sendCommandFullMock).toHaveBeenNthCalledWith(2, 'navigate', expect.objectContaining({ + page: 'page-capture', + tabPlacement: 'existing-window', + })); + }); }); describe('Page.evaluate', () => { @@ -141,17 +175,17 @@ describe('Page network capture compatibility', () => { }); it('treats unknown network-capture-start as unsupported and memoizes it', async () => { - sendCommandMock.mockRejectedValueOnce(new Error('Unknown action: network-capture-start')); + sendCommandFullMock.mockRejectedValueOnce(new Error('Unknown action: network-capture-start')); const page = new Page('notebooklm', undefined, undefined, undefined, 'adapter'); await expect(page.startNetworkCapture()).resolves.toBe(false); await expect(page.startNetworkCapture()).resolves.toBe(false); - expect(sendCommandMock).toHaveBeenCalledTimes(1); + expect(sendCommandFullMock).toHaveBeenCalledTimes(1); expect(warnMock).toHaveBeenCalledTimes(1); expect(warnMock).toHaveBeenCalledWith(expect.stringContaining('does not support network capture')); - expect(sendCommandMock).toHaveBeenCalledWith('network-capture-start', expect.objectContaining({ + expect(sendCommandFullMock).toHaveBeenCalledWith('network-capture-start', expect.objectContaining({ session: 'notebooklm', surface: 'adapter', })); @@ -174,19 +208,18 @@ describe('Page network capture compatibility', () => { }); it('rethrows unrelated network capture failures', async () => { - sendCommandMock.mockRejectedValueOnce(new Error('Extension disconnected')); + sendCommandFullMock.mockRejectedValueOnce(new Error('Extension disconnected')); const page = new Page('notebooklm', undefined, undefined, undefined, 'adapter'); await expect(page.startNetworkCapture()).rejects.toThrow('Extension disconnected'); - expect(sendCommandMock).toHaveBeenCalledTimes(1); + expect(sendCommandFullMock).toHaveBeenCalledTimes(1); expect(warnMock).not.toHaveBeenCalled(); }); it('warns only once even if both start and read hit the compatibility fallback', async () => { - sendCommandMock - .mockRejectedValueOnce(new Error('Unknown action: network-capture-start')) - .mockRejectedValueOnce(new Error('Unknown action: network-capture-read')); + sendCommandFullMock.mockRejectedValueOnce(new Error('Unknown action: network-capture-start')); + sendCommandMock.mockRejectedValueOnce(new Error('Unknown action: network-capture-read')); const page = new Page('notebooklm', undefined, undefined, undefined, 'adapter'); @@ -230,6 +263,51 @@ describe('Page download waits', () => { }); }); +describe('Page credential fill', () => { + beforeEach(() => { + sendCommandMock.mockReset(); + sendCommandFullMock.mockReset(); + warnMock.mockReset(); + }); + + it('sends credentials through the dedicated daemon command', async () => { + sendCommandMock.mockResolvedValueOnce({ + ok: true, + host: 'login.taobao.com', + username_filled: true, + password_filled: true, + submitted: true, + }); + + const page = new Page('qn', undefined, 'gumo-studio', undefined, 'adapter', 'persistent', 'existing-window'); + const result = await page.fillCredentials({ + username: 'seller', + password: 'secret-password', + allowedHosts: ['taobao.com'], + submit: true, + }); + + expect(result).toEqual({ + ok: true, + host: 'login.taobao.com', + username_filled: true, + password_filled: true, + submitted: true, + }); + expect(sendCommandMock).toHaveBeenCalledWith('credential-fill', expect.objectContaining({ + session: 'qn', + surface: 'adapter', + contextId: 'gumo-studio', + siteSession: 'persistent', + tabPlacement: 'existing-window', + username: 'seller', + password: 'secret-password', + allowedHosts: ['taobao.com'], + submit: true, + })); + }); +}); + describe('Page CDP helpers', () => { beforeEach(() => { sendCommandMock.mockReset(); diff --git a/src/browser/page.ts b/src/browser/page.ts index ae749f72e..e4483333e 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -9,15 +9,21 @@ * page-scoped operations target the correct page without guessing. */ -import type { BrowserCookie, BrowserDownloadWaitResult, BrowserEvaluateFunction, ScreenshotOptions } from '../types.js'; +import type { BrowserCookie, BrowserCredentialFillOptions, BrowserCredentialFillResult, BrowserDownloadWaitResult, BrowserEvaluateFunction, ScreenshotOptions } from '../types.js'; import { sendCommand, sendCommandFull } from './daemon-client.js'; import { buildEvaluateExpression } from './utils.js'; -import { saveBase64ToFile } from '../utils.js'; +import { saveBase64ToFile, sleep } from '../utils.js'; import { generateStealthJs } from './stealth.js'; import { waitForDomStableJs } from './dom-helpers.js'; import { BasePage } from './base-page.js'; import { classifyBrowserError } from './errors.js'; import { log } from '../logger.js'; +import type { BrowserTabPlacement } from './tab-placement.js'; +import { BROWSER_VIEWPORT_METRICS_SCRIPT, clickScreenPoint, viewportPointToScreenPoint, type BrowserViewportMetrics } from './system-input.js'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { execFileSync } from 'node:child_process'; function isUnsupportedNetworkCaptureError(err: unknown): boolean { const message = err instanceof Error ? err.message : String(err); @@ -36,6 +42,106 @@ function isStalePageIdentityError(err: unknown): boolean { return message.includes('stale page identity') || /^Page not found:\s*\S+\s*$/.test(message); } +type BrowserAutofillEntry = { + contextId?: string; + allowedHosts?: string[]; + matchHosts?: string[]; + usernameSelectors?: string[]; + passwordSelectors?: string[]; + activateTextPatterns?: string[]; + submitSelectors?: string[]; + submit?: boolean; + keychain?: { + service?: string; + account?: string; + format?: string; + }; +}; + +type StoredBrowserCredential = { + username: string; + password: string; +}; + +function normalizeHost(value: string): string { + return value.trim().toLowerCase().replace(/^\.+/, ''); +} + +function hostMatches(host: string, candidates: string[] | undefined): boolean { + const normalized = normalizeHost(host); + if (!normalized || !Array.isArray(candidates)) return false; + return candidates.some((candidate) => { + const allowed = normalizeHost(String(candidate || '')); + return allowed.length > 0 && (normalized === allowed || normalized.endsWith(`.${allowed}`)); + }); +} + +function urlHost(rawUrl: string | undefined): string { + if (!rawUrl) return ''; + try { + const parsed = new URL(rawUrl); + return parsed.hostname; + } catch { + return ''; + } +} + +function openCliConfigDir(): string { + return process.env.OPENCLI_CONFIG_DIR || path.join(os.homedir(), '.opencli'); +} + +function loadBrowserAutofillEntries(): BrowserAutofillEntry[] { + try { + const raw = fs.readFileSync(path.join(openCliConfigDir(), 'browser-autofill.json'), 'utf8'); + const parsed = JSON.parse(raw) as { entries?: unknown }; + return Array.isArray(parsed.entries) + ? parsed.entries.filter((entry): entry is BrowserAutofillEntry => Boolean(entry) && typeof entry === 'object') + : []; + } catch { + return []; + } +} + +function parseStoredBrowserCredential(raw: string): StoredBrowserCredential | null { + const text = raw.trim(); + if (!text) return null; + let payload = text; + if (/^[0-9a-f]+$/i.test(text) && text.length % 2 === 0) { + const decoded = Buffer.from(text, 'hex').toString('utf8').trim(); + if (decoded.startsWith('{')) payload = decoded; + } + try { + const parsed = JSON.parse(payload) as { username?: unknown; password?: unknown }; + const username = typeof parsed.username === 'string' ? parsed.username : ''; + const password = typeof parsed.password === 'string' ? parsed.password : ''; + return username && password ? { username, password } : null; + } catch { + return null; + } +} + +function readBrowserAutofillCredential(entry: BrowserAutofillEntry): StoredBrowserCredential | null { + const service = entry.keychain?.service; + const account = entry.keychain?.account; + if (!service || !account || process.platform !== 'darwin') return null; + try { + const raw = execFileSync('/usr/bin/security', [ + 'find-generic-password', + '-s', + service, + '-a', + account, + '-w', + ], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + return parseStoredBrowserCredential(raw); + } catch { + return null; + } +} + /** * Page — implements IPage by talking to the daemon via HTTP. */ @@ -49,6 +155,7 @@ export class Page extends BasePage { private readonly windowMode?: 'foreground' | 'background', private readonly surface: 'browser' | 'adapter' = 'browser', private readonly siteSession?: 'ephemeral' | 'persistent', + private readonly tabPlacement?: BrowserTabPlacement, ) { super(); this._idleTimeout = idleTimeout; @@ -56,23 +163,33 @@ export class Page extends BasePage { /** Active page identity (targetId), set after navigate and used in all subsequent commands */ private _page: string | undefined; + private _closed = false; private _networkCaptureUnsupported = false; private _networkCaptureWarned = false; + private _assertOpen(): void { + if (this._closed) { + throw new Error('Browser page is closed'); + } + } + /** Helper: spread session into command params */ - private _sessionOpts(): { session: string; surface: 'browser' | 'adapter'; idleTimeout?: number; contextId?: string; windowMode?: 'foreground' | 'background'; siteSession?: 'ephemeral' | 'persistent' } { + private _sessionOpts(opts: { allowClosed?: boolean } = {}): { session: string; surface: 'browser' | 'adapter'; idleTimeout?: number; contextId?: string; windowMode?: 'foreground' | 'background'; tabPlacement?: BrowserTabPlacement; siteSession?: 'ephemeral' | 'persistent' } { + if (!opts.allowClosed) this._assertOpen(); return { session: this.session, surface: this.surface, ...(this.contextId && { contextId: this.contextId }), ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }), ...(this.windowMode && { windowMode: this.windowMode }), + ...(this.tabPlacement && { tabPlacement: this.tabPlacement }), ...(this.siteSession && { siteSession: this.siteSession }), }; } /** Helper: spread session + page identity into command params */ private _cmdOpts(): Record { + this._assertOpen(); return { session: this.session, surface: this.surface, @@ -80,6 +197,7 @@ export class Page extends BasePage { ...(this._page !== undefined && { page: this._page }), ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }), ...(this.windowMode && { windowMode: this.windowMode }), + ...(this.tabPlacement && { tabPlacement: this.tabPlacement }), ...(this.siteSession && { siteSession: this.siteSession }), }; } @@ -108,6 +226,7 @@ export class Page extends BasePage { if (result.page) { this._page = result.page; } + await this._maybeFillConfiguredCredential(url); this._lastUrl = url; // Inject stealth + settle in a single round-trip instead of two sequential exec calls. // The stealth guard flag prevents double-injection; settle uses DOM stability detection. @@ -187,11 +306,13 @@ export class Page extends BasePage { /** Release the current browser session lease in the extension */ async closeWindow(): Promise { + if (this._closed) return; try { - await sendCommand('close-window', { ...this._sessionOpts() }); + await sendCommand('close-window', { ...this._sessionOpts({ allowClosed: true }) }); } catch { // Window may already be closed or daemon may be down } finally { + this._closed = true; this._page = undefined; this._lastUrl = null; this._networkCaptureUnsupported = false; @@ -262,10 +383,13 @@ export class Page extends BasePage { async startNetworkCapture(pattern: string = ''): Promise { if (this._networkCaptureUnsupported) return false; try { - await sendCommand('network-capture-start', { + const result = await sendCommandFull('network-capture-start', { pattern, ...this._cmdOpts(), }); + if (result?.page) { + this._page = result.page; + } return true; } catch (err) { if (!isUnsupportedNetworkCaptureError(err)) throw err; @@ -323,6 +447,72 @@ export class Page extends BasePage { } } + async fillCredentials(options: BrowserCredentialFillOptions): Promise { + const result = await sendCommand('credential-fill', { + username: options.username, + password: options.password, + allowedHosts: options.allowedHosts, + usernameSelectors: options.usernameSelectors, + passwordSelectors: options.passwordSelectors, + activateTextPatterns: options.activateTextPatterns, + submitSelectors: options.submitSelectors, + submit: options.submit, + ...this._cmdOpts(), + }) as BrowserCredentialFillResult; + return result; + } + + private async _maybeFillConfiguredCredential(targetUrl: string): Promise { + if (!this.contextId) return; + const entries = loadBrowserAutofillEntries().filter((entry) => entry.contextId === this.contextId); + if (entries.length === 0) return; + + let currentUrl = targetUrl; + try { + const value = await sendCommand('exec', { + code: buildEvaluateExpression('location.href'), + ...this._cmdOpts(), + }); + if (typeof value === 'string' && value.trim()) currentUrl = value; + } catch { + // Use the requested URL when reading the page URL is not available. + } + + const targetHost = urlHost(targetUrl); + const currentHost = urlHost(currentUrl); + const entry = entries.find((candidate) => ( + hostMatches(currentHost, candidate.matchHosts) + || hostMatches(targetHost, candidate.matchHosts) + || hostMatches(currentHost, candidate.allowedHosts) + || hostMatches(targetHost, candidate.allowedHosts) + )); + if (!entry) return; + + const credential = readBrowserAutofillCredential(entry); + if (!credential) return; + + const delays = [0, 500, 1200, 2500]; + for (const delay of delays) { + if (delay > 0) await sleep(delay); + try { + const result = await this.fillCredentials({ + username: credential.username, + password: credential.password, + allowedHosts: Array.isArray(entry.allowedHosts) ? entry.allowedHosts : [], + usernameSelectors: Array.isArray(entry.usernameSelectors) ? entry.usernameSelectors : undefined, + passwordSelectors: Array.isArray(entry.passwordSelectors) ? entry.passwordSelectors : undefined, + activateTextPatterns: Array.isArray(entry.activateTextPatterns) ? entry.activateTextPatterns : undefined, + submitSelectors: Array.isArray(entry.submitSelectors) ? entry.submitSelectors : undefined, + submit: entry.submit === true, + }); + if (result.username_filled && result.password_filled) return; + if (result.reason && result.reason !== 'login_inputs_not_found' && result.reason !== 'host_not_allowed') return; + } catch { + return; + } + } + } + async frames(): Promise> { const result = await sendCommand('frames', { ...this._cmdOpts() }); return Array.isArray(result) ? result : []; @@ -440,6 +630,11 @@ export class Page extends BasePage { }); } + async systemClick(x: number, y: number): Promise { + const metrics = await this.evaluate(BROWSER_VIEWPORT_METRICS_SCRIPT); + await clickScreenPoint(viewportPointToScreenPoint(metrics, { x, y })); + } + async nativeType(text: string): Promise { // Use Input.insertText for reliable Unicode/CJK text insertion await this.cdp('Input.insertText', { text }); diff --git a/src/browser/system-input.test.ts b/src/browser/system-input.test.ts new file mode 100644 index 000000000..a8f0d75dc --- /dev/null +++ b/src/browser/system-input.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { viewportPointToScreenPoint } from './system-input.js'; + +describe('viewportPointToScreenPoint', () => { + it('adds browser chrome offsets to viewport coordinates', () => { + const point = viewportPointToScreenPoint({ + screenX: 100, + screenY: 50, + outerWidth: 1200, + outerHeight: 900, + innerWidth: 1180, + innerHeight: 760, + }, { x: 90, y: 200 }); + + expect(point).toEqual({ x: 200, y: 380 }); + }); + + it('does not invent negative offsets for fullscreen-like windows', () => { + const point = viewportPointToScreenPoint({ + screenX: 10, + screenY: 20, + outerWidth: 1200, + outerHeight: 800, + innerWidth: 1200, + innerHeight: 800, + }, { x: 12, y: 34 }); + + expect(point).toEqual({ x: 22, y: 54 }); + }); +}); diff --git a/src/browser/system-input.ts b/src/browser/system-input.ts new file mode 100644 index 000000000..a256b57ab --- /dev/null +++ b/src/browser/system-input.ts @@ -0,0 +1,85 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +export interface ViewportPoint { + readonly x: number; + readonly y: number; +} + +export interface ScreenPoint { + readonly x: number; + readonly y: number; +} + +export interface BrowserViewportMetrics { + readonly screenX: number; + readonly screenY: number; + readonly outerWidth: number; + readonly outerHeight: number; + readonly innerWidth: number; + readonly innerHeight: number; +} + +function assertFiniteNumber(name: string, value: number): void { + if (!Number.isFinite(value)) { + throw new Error(`${name} must be a finite number`); + } +} + +export function viewportPointToScreenPoint( + metrics: BrowserViewportMetrics, + point: ViewportPoint, +): ScreenPoint { + assertFiniteNumber('point.x', point.x); + assertFiniteNumber('point.y', point.y); + assertFiniteNumber('metrics.screenX', metrics.screenX); + assertFiniteNumber('metrics.screenY', metrics.screenY); + assertFiniteNumber('metrics.outerWidth', metrics.outerWidth); + assertFiniteNumber('metrics.outerHeight', metrics.outerHeight); + assertFiniteNumber('metrics.innerWidth', metrics.innerWidth); + assertFiniteNumber('metrics.innerHeight', metrics.innerHeight); + + const sideInset = Math.max(0, Math.round((metrics.outerWidth - metrics.innerWidth) / 2)); + const topInset = Math.max(0, Math.round(metrics.outerHeight - metrics.innerHeight - sideInset)); + return { + x: Math.round(metrics.screenX + sideInset + point.x), + y: Math.round(metrics.screenY + topInset + point.y), + }; +} + +export async function clickScreenPoint(point: ScreenPoint): Promise { + assertFiniteNumber('point.x', point.x); + assertFiniteNumber('point.y', point.y); + if (process.platform !== 'darwin') { + throw new Error('systemClick is currently implemented only on macOS'); + } + + await execFileAsync('/usr/bin/swift', [ + '-e', + [ + 'import AppKit', + 'import CoreGraphics', + 'import Foundation', + 'if let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.google.Chrome").first { app.activate(options: [.activateAllWindows]) }', + 'usleep(150000)', + `let point = CGPoint(x: ${Math.round(point.x)}, y: ${Math.round(point.y)})`, + 'let source = CGEventSource(stateID: .hidSystemState)', + 'CGEvent(mouseEventSource: source, mouseType: .mouseMoved, mouseCursorPosition: point, mouseButton: .left)?.post(tap: .cghidEventTap)', + 'usleep(50000)', + 'CGEvent(mouseEventSource: source, mouseType: .leftMouseDown, mouseCursorPosition: point, mouseButton: .left)?.post(tap: .cghidEventTap)', + 'usleep(50000)', + 'CGEvent(mouseEventSource: source, mouseType: .leftMouseUp, mouseCursorPosition: point, mouseButton: .left)?.post(tap: .cghidEventTap)', + ].join('; '), + ], { timeout: 5000 }); +} + +export const BROWSER_VIEWPORT_METRICS_SCRIPT = `(() => ({ + screenX: window.screenX, + screenY: window.screenY, + outerWidth: window.outerWidth, + outerHeight: window.outerHeight, + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, +}))()`; diff --git a/src/browser/tab-placement.ts b/src/browser/tab-placement.ts new file mode 100644 index 000000000..3b1c0b17c --- /dev/null +++ b/src/browser/tab-placement.ts @@ -0,0 +1,13 @@ +export type BrowserTabPlacement = 'owned-container' | 'existing-window'; + +const VALUES: BrowserTabPlacement[] = ['owned-container', 'existing-window']; + +export function normalizeBrowserTabPlacement(name: string, raw: unknown): BrowserTabPlacement | null { + if (raw === undefined || raw === '') return null; + if (raw === 'owned-container' || raw === 'existing-window') return raw; + throw new Error(`${name} must be one of: ${VALUES.join(', ')}. Received: "${String(raw)}"`); +} + +export function resolveBrowserTabPlacementFromEnv(): BrowserTabPlacement | undefined { + return normalizeBrowserTabPlacement('OPENCLI_TAB_PLACEMENT', process.env.OPENCLI_TAB_PLACEMENT) ?? 'existing-window'; +} diff --git a/src/cli.test.ts b/src/cli.test.ts index 1e3fd0b1a..f56d0af1e 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -1000,11 +1000,13 @@ describe('browser tab targeting commands', () => { beforeEach(() => { process.exitCode = undefined; process.env.OPENCLI_CACHE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browser-tab-state-')); + process.env.OPENCLI_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browser-tab-config-')); consoleLogSpy.mockClear(); stderrSpy.mockClear(); mockBrowserConnect.mockClear(); mockBrowserClose.mockReset().mockResolvedValue(undefined); delete process.env.OPENCLI_WINDOW; + delete process.env.OPENCLI_TAB_PLACEMENT; mockBindTab.mockReset().mockResolvedValue({ session: 'test', page: 'tab-2', @@ -1050,6 +1052,10 @@ describe('browser tab targeting commands', () => { } as unknown as IPage; }); + afterEach(() => { + delete process.env.OPENCLI_CONFIG_DIR; + }); + function lastJsonLog(): any { const calls = consoleLogSpy.mock.calls; if (calls.length === 0) throw new Error('Expected at least one console.log call'); @@ -1087,7 +1093,13 @@ describe('browser tab targeting commands', () => { await program.parseAsync(['node', 'opencli', 'browser', '--session', 'test', 'state']); - expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 30, session: 'test', surface: 'browser', windowMode: 'foreground' }); + expect(mockBrowserConnect).toHaveBeenCalledWith({ + timeout: 30, + session: 'test', + surface: 'browser', + windowMode: 'foreground', + tabPlacement: 'existing-window', + }); expect(browserState.page?.snapshot).toHaveBeenCalled(); }); @@ -1096,7 +1108,29 @@ describe('browser tab targeting commands', () => { await program.parseAsync(['node', 'opencli', 'browser', '--session', 'test', '--window', 'background', 'state']); - expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 30, session: 'test', surface: 'browser', windowMode: 'background' }); + expect(mockBrowserConnect).toHaveBeenCalledWith({ + timeout: 30, + session: 'test', + surface: 'browser', + windowMode: 'background', + tabPlacement: 'existing-window', + }); + expect(browserState.page?.snapshot).toHaveBeenCalled(); + }); + + it('passes OPENCLI_TAB_PLACEMENT through opencli browser sessions', async () => { + process.env.OPENCLI_TAB_PLACEMENT = 'existing-window'; + const program = createProgram('', ''); + + await program.parseAsync(['node', 'opencli', 'browser', '--session', 'test', 'state']); + + expect(mockBrowserConnect).toHaveBeenCalledWith({ + timeout: 30, + session: 'test', + surface: 'browser', + windowMode: 'foreground', + tabPlacement: 'existing-window', + }); expect(browserState.page?.snapshot).toHaveBeenCalled(); }); diff --git a/src/cli.ts b/src/cli.ts index c9d98a220..68690a720 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -36,6 +36,7 @@ import { daemonRestart, daemonStatus, daemonStop } from './commands/daemon.js'; import { log } from './logger.js'; import { bindTab, BrowserCommandError, fetchDaemonStatus, sendCommand } from './browser/daemon-client.js'; import { aliasForContextId, loadProfileConfig, renameProfile, resolveProfileContextId, setDefaultProfile } from './browser/profile.js'; +import { resolveBrowserTabPlacementFromEnv } from './browser/tab-placement.js'; import { formatDaemonVersion, isDaemonStale } from './browser/daemon-version.js'; import type { BrowserDownloadWaitResult, IPage, ScreenshotOptions } from './types.js'; import type { BrowserWindowMode } from './runtime.js'; @@ -524,6 +525,7 @@ async function getBrowserPage( // Internal GC timeout for browser sessions. Not the per-command runtime timeout. const envTimeout = process.env.OPENCLI_BROWSER_IDLE_TIMEOUT; const idleTimeout = envTimeout ? parseInt(envTimeout, 10) : undefined; + const tabPlacement = resolveBrowserTabPlacementFromEnv(); const page = await bridge.connect({ timeout: 30, session, @@ -531,6 +533,7 @@ async function getBrowserPage( ...(contextId && { contextId }), ...(idleTimeout && idleTimeout > 0 && { idleTimeout }), windowMode: opts.windowMode ?? getBrowserWindowMode(undefined, 'foreground'), + ...(tabPlacement && { tabPlacement }), }); const targetScope = getBrowserScope(session, contextId); const resolvedTargetPage = targetPage diff --git a/src/daemon.ts b/src/daemon.ts index ed50ee27f..b1b6b8160 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -21,6 +21,10 @@ */ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { WebSocketServer, WebSocket, type RawData } from 'ws'; import { DEFAULT_DAEMON_PORT } from './constants.js'; import { EXIT_CODES } from './errors.js'; @@ -73,6 +77,213 @@ class DaemonCommandFailure extends Error { } } +type AutofillKeychainConfig = { + service: string; + account: string; + format: 'json'; +}; + +type AutofillConfigEntry = { + id: string; + contextId: string; + allowedHosts: string[]; + matchHosts?: string[]; + usernameSelectors?: string[]; + passwordSelectors?: string[]; + activateTextPatterns?: string[]; + submitSelectors?: string[]; + submit?: boolean; + keychain: AutofillKeychainConfig; +}; + +type AutofillConfig = { + version: 1; + entries: AutofillConfigEntry[]; +}; + +type AutofillRequestMessage = { + type: 'autofill-request'; + requestId: string; + contextId?: string; + url?: string; +}; + +type StoredCredential = { + username: string; + password: string; +}; + +function openCliConfigDir(): string { + return process.env.OPENCLI_CONFIG_DIR || path.join(os.homedir(), '.opencli'); +} + +function browserAutofillConfigPath(): string { + return path.join(openCliConfigDir(), 'browser-autofill.json'); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === 'string'); +} + +function cleanStringArray(value: unknown): string[] { + if (!isStringArray(value)) return []; + return value.map((entry) => entry.trim()).filter((entry) => entry.length > 0); +} + +function parseAutofillEntry(value: unknown): AutofillConfigEntry | null { + if (!value || typeof value !== 'object') return null; + const record = value as Record; + const keychain = record.keychain; + if (!keychain || typeof keychain !== 'object') return null; + const keychainRecord = keychain as Record; + const id = typeof record.id === 'string' ? record.id.trim() : ''; + const contextId = typeof record.contextId === 'string' ? record.contextId.trim() : ''; + const service = typeof keychainRecord.service === 'string' ? keychainRecord.service.trim() : ''; + const account = typeof keychainRecord.account === 'string' ? keychainRecord.account.trim() : ''; + const allowedHosts = cleanStringArray(record.allowedHosts); + if (!id || !contextId || !service || !account || allowedHosts.length === 0) return null; + return { + id, + contextId, + allowedHosts, + matchHosts: cleanStringArray(record.matchHosts), + usernameSelectors: cleanStringArray(record.usernameSelectors), + passwordSelectors: cleanStringArray(record.passwordSelectors), + activateTextPatterns: cleanStringArray(record.activateTextPatterns), + submitSelectors: cleanStringArray(record.submitSelectors), + submit: record.submit === true, + keychain: { + service, + account, + format: 'json', + }, + }; +} + +function loadAutofillConfig(): AutofillConfig { + try { + const parsed = JSON.parse(fs.readFileSync(browserAutofillConfigPath(), 'utf-8')) as unknown; + if (!parsed || typeof parsed !== 'object') return { version: 1, entries: [] }; + const entriesValue = (parsed as Record).entries; + const entries = Array.isArray(entriesValue) + ? entriesValue.map(parseAutofillEntry).filter((entry): entry is AutofillConfigEntry => entry !== null) + : []; + return { version: 1, entries }; + } catch { + return { version: 1, entries: [] }; + } +} + +function normalizeHost(value: string): string { + return value.trim().toLowerCase().replace(/^\.+/, ''); +} + +function hostMatches(host: string, allowedHost: string): boolean { + const normalizedHost = normalizeHost(host); + const normalizedAllowed = normalizeHost(allowedHost); + return normalizedAllowed.length > 0 + && (normalizedHost === normalizedAllowed || normalizedHost.endsWith(`.${normalizedAllowed}`)); +} + +function selectAutofillEntry(contextId: string, rawUrl: string): AutofillConfigEntry | null { + let host = ''; + try { + const url = new URL(rawUrl); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; + host = url.hostname; + } catch { + return null; + } + const config = loadAutofillConfig(); + return config.entries.find((entry) => { + if (entry.contextId !== contextId) return false; + const matchHosts = entry.matchHosts && entry.matchHosts.length > 0 ? entry.matchHosts : entry.allowedHosts; + return matchHosts.some((allowedHost) => hostMatches(host, allowedHost)); + }) ?? null; +} + +function parseStoredCredential(raw: string): StoredCredential | null { + const text = raw.trim(); + if (!text) return null; + const payload = /^[0-9a-f]+$/i.test(text) && text.length % 2 === 0 + ? Buffer.from(text, 'hex').toString('utf8').trim() + : text; + try { + const parsed = JSON.parse(payload) as unknown; + if (!parsed || typeof parsed !== 'object') return null; + const record = parsed as Record; + const username = typeof record.username === 'string' ? record.username : ''; + const password = typeof record.password === 'string' ? record.password : ''; + if (!username || !password) return null; + return { username, password }; + } catch { + return null; + } +} + +function readKeychainCredential(config: AutofillKeychainConfig): StoredCredential | null { + if (process.platform !== 'darwin') return null; + const result = spawnSync('/usr/bin/security', [ + 'find-generic-password', + '-s', config.service, + '-a', config.account, + '-w', + ], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status !== 0 || !result.stdout.trim()) return null; + return parseStoredCredential(result.stdout); +} + +function isAutofillRequestMessage(value: unknown): value is AutofillRequestMessage { + if (!value || typeof value !== 'object') return false; + const record = value as Record; + return record.type === 'autofill-request' && typeof record.requestId === 'string'; +} + +function respondToAutofillRequest(ws: WebSocket, requestId: string, payload: Record): void { + if (ws.readyState !== WebSocket.OPEN) return; + ws.send(JSON.stringify({ + type: 'autofill-response', + requestId, + ...payload, + })); +} + +function handleAutofillRequest(ws: WebSocket, msg: AutofillRequestMessage): void { + const connection = [...extensionProfiles.values()].find((entry) => entry.ws === ws && entry.ws.readyState === WebSocket.OPEN); + const contextId = connection?.contextId ?? (typeof msg.contextId === 'string' ? msg.contextId.trim() : ''); + const rawUrl = typeof msg.url === 'string' ? msg.url : ''; + if (!contextId || !rawUrl) { + respondToAutofillRequest(ws, msg.requestId, { ok: false, error: 'missing_context_or_url' }); + return; + } + const entry = selectAutofillEntry(contextId, rawUrl); + if (!entry) { + respondToAutofillRequest(ws, msg.requestId, { ok: false, error: 'no_matching_autofill_entry' }); + return; + } + const credential = readKeychainCredential(entry.keychain); + if (!credential) { + respondToAutofillRequest(ws, msg.requestId, { ok: false, error: 'credential_not_found' }); + return; + } + respondToAutofillRequest(ws, msg.requestId, { + ok: true, + credential: { + username: credential.username, + password: credential.password, + allowedHosts: entry.allowedHosts, + usernameSelectors: entry.usernameSelectors ?? [], + passwordSelectors: entry.passwordSelectors ?? [], + activateTextPatterns: entry.activateTextPatterns ?? [], + submitSelectors: entry.submitSelectors ?? [], + submit: entry.submit === true, + }, + }); +} + function pushLog(entry: LogEntry): void { logBuffer.push(entry); if (logBuffer.length > LOG_BUFFER_SIZE) logBuffer.shift(); @@ -435,6 +646,14 @@ wss.on('connection', (ws: WebSocket) => { return; } + // Extension-initiated passive autofill requests. These are not CLI + // commands: the extension observes a configured login page and asks the + // local daemon for that profile's Keychain-backed credential. + if (isAutofillRequestMessage(msg)) { + handleAutofillRequest(ws, msg); + return; + } + // Handle command results const p = pending.get(msg.id); if (p) { diff --git a/src/discovery.ts b/src/discovery.ts index 4b47847b3..38be3cfa9 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -12,7 +12,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { type InternalCliCommand, Strategy, registerCommand } from './registry.js'; +import { type InternalCliCommand, Strategy, cli, registerCommand } from './registry.js'; import { getErrorMessage } from './errors.js'; import { log } from './logger.js'; import type { ManifestEntry } from './manifest-types.js'; @@ -35,6 +35,15 @@ function parseStrategy(rawStrategy: string | undefined, fallback: Strategy = Str const PACKAGE_ROOT = findPackageRoot(fileURLToPath(import.meta.url)); +function ensureLegacyPluginGlobals(): void { + const legacyGlobals = globalThis as typeof globalThis & { + cli?: typeof cli; + Strategy?: typeof Strategy; + }; + legacyGlobals.cli ??= cli; + legacyGlobals.Strategy ??= Strategy; +} + /** * Ensure ~/.opencli/node_modules/@jackwener/opencli symlink exists so that * user CLIs in ~/.opencli/clis/ can `import { cli } from '@jackwener/opencli/registry'`. @@ -189,6 +198,7 @@ async function discoverClisFromFs(dir: string): Promise { */ export async function discoverPlugins(): Promise { try { await fs.promises.access(PLUGINS_DIR); } catch { return; } + ensureLegacyPluginGlobals(); const entries = await fs.promises.readdir(PLUGINS_DIR, { withFileTypes: true }); await Promise.all(entries.map(async (entry) => { const pluginDir = path.join(PLUGINS_DIR, entry.name); diff --git a/src/doctor.test.ts b/src/doctor.test.ts index f56701f02..074904028 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -1,10 +1,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { mockGetDaemonHealth, mockConnect, mockClose, mockFindShadowedUserAdapters } = vi.hoisted(() => ({ +const { + mockGetDaemonHealth, + mockConnect, + mockClose, + mockFindShadowedUserAdapters, + mockResolveProfileContextId, +} = vi.hoisted(() => ({ mockGetDaemonHealth: vi.fn(), mockConnect: vi.fn(), mockClose: vi.fn(), mockFindShadowedUserAdapters: vi.fn(), + mockResolveProfileContextId: vi.fn(), })); vi.mock('./browser/daemon-client.js', () => ({ @@ -26,6 +33,14 @@ vi.mock('./adapter-shadow.js', async () => { }; }); +vi.mock('./browser/profile.js', async () => { + const actual = await vi.importActual('./browser/profile.js'); + return { + ...actual, + resolveProfileContextId: mockResolveProfileContextId, + }; +}); + import { renderBrowserDoctorReport, runBrowserDoctor } from './doctor.js'; describe('doctor report rendering', () => { @@ -34,12 +49,13 @@ describe('doctor report rendering', () => { beforeEach(() => { vi.clearAllMocks(); mockFindShadowedUserAdapters.mockReturnValue([]); - // Doctor always runs live connectivity. Tests that want connect to fail override. + // Doctor falls back to a live browser command only when status is not ready. mockConnect.mockResolvedValue({ evaluate: vi.fn().mockResolvedValue(2), closeWindow: vi.fn().mockResolvedValue(undefined), }); mockClose.mockResolvedValue(undefined); + mockResolveProfileContextId.mockReturnValue(undefined); }); it('renders OK-style report when daemon and extension connected', () => { @@ -165,7 +181,9 @@ describe('doctor report rendering', () => { it('reports daemon not running when connectivity fails and daemon stays stopped', async () => { mockConnect.mockRejectedValueOnce(new Error('Could not start daemon')); - mockGetDaemonHealth.mockResolvedValueOnce({ state: 'stopped', status: null }); + mockGetDaemonHealth + .mockResolvedValueOnce({ state: 'stopped', status: null }) + .mockResolvedValueOnce({ state: 'stopped', status: null }); const report = await runBrowserDoctor(); @@ -178,7 +196,9 @@ describe('doctor report rendering', () => { }); it('reports flapping when live check succeeds but final status shows extension disconnected', async () => { - mockGetDaemonHealth.mockResolvedValueOnce({ state: 'no-extension', status: { extensionConnected: false } }); + mockGetDaemonHealth + .mockResolvedValueOnce({ state: 'no-extension', status: { extensionConnected: false } }) + .mockResolvedValueOnce({ state: 'no-extension', status: { extensionConnected: false } }); const report = await runBrowserDoctor(); @@ -191,7 +211,9 @@ describe('doctor report rendering', () => { }); it('reports daemon flapping when live check succeeds but daemon disappears afterward', async () => { - mockGetDaemonHealth.mockResolvedValueOnce({ state: 'stopped', status: null }); + mockGetDaemonHealth + .mockResolvedValueOnce({ state: 'stopped', status: null }) + .mockResolvedValueOnce({ state: 'stopped', status: null }); const report = await runBrowserDoctor(); @@ -215,7 +237,9 @@ describe('doctor report rendering', () => { closeWindow, }; }); - mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { extensionConnected: true } }); + mockGetDaemonHealth + .mockResolvedValueOnce({ state: 'no-extension', status: { extensionConnected: false } }) + .mockResolvedValueOnce({ state: 'ready', status: { extensionConnected: true } }); await runBrowserDoctor(); @@ -309,4 +333,50 @@ describe('doctor report rendering', () => { expect.stringContaining('Multiple Chrome profiles are connected'), ])); }); + + it('reads daemon health for the resolved default profile after live connectivity succeeds', async () => { + mockResolveProfileContextId.mockReturnValue('work'); + mockGetDaemonHealth + .mockResolvedValueOnce({ state: 'no-extension', status: { extensionConnected: false } }) + .mockResolvedValueOnce({ + state: 'ready', + status: { + contextId: 'work', + extensionConnected: true, + extensionVersion: '1.2.3', + profiles: [ + { contextId: 'work', extensionConnected: true, extensionVersion: '1.2.3', pending: 0 }, + { contextId: 'personal', extensionConnected: true, extensionVersion: '1.2.3', pending: 0 }, + ], + }, + }); + + const report = await runBrowserDoctor(); + + expect(mockGetDaemonHealth).toHaveBeenCalledWith({ contextId: 'work' }); + expect(report.extensionConnected).toBe(true); + expect(report.extensionFlaky).toBe(false); + expect(report.issues).not.toEqual(expect.arrayContaining([ + expect.stringContaining('Extension connection is unstable'), + ])); + }); + + it('does not open a live browser page when selected profile status is already ready', async () => { + mockResolveProfileContextId.mockReturnValue('work'); + mockGetDaemonHealth.mockResolvedValueOnce({ + state: 'ready', + status: { + contextId: 'work', + extensionConnected: true, + extensionVersion: '1.2.3', + }, + }); + + const report = await runBrowserDoctor(); + + expect(mockGetDaemonHealth).toHaveBeenCalledWith({ contextId: 'work' }); + expect(mockConnect).not.toHaveBeenCalled(); + expect(report.connectivity?.ok).toBe(true); + expect(report.extensionConnected).toBe(true); + }); }); diff --git a/src/doctor.ts b/src/doctor.ts index fb1d3d60e..ba289bf6f 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -11,7 +11,7 @@ import { getErrorMessage } from './errors.js'; import { getRuntimeLabel } from './runtime-detect.js'; import { getCachedLatestExtensionVersion } from './update-check.js'; import type { BrowserProfileStatus } from './browser/daemon-client.js'; -import { aliasForContextId, loadProfileConfig } from './browser/profile.js'; +import { aliasForContextId, loadProfileConfig, resolveProfileContextId } from './browser/profile.js'; import { formatDaemonVersion, isDaemonStale, staleDaemonIssue } from './browser/daemon-version.js'; import { findShadowedUserAdapters, formatAdapterShadowIssue, type AdapterShadow } from './adapter-shadow.js'; @@ -100,12 +100,23 @@ export async function checkConnectivity(opts?: { timeout?: number }): Promise { - // Live connectivity check is the core of doctor — it doubles as auto-start - // (bridge.connect spawns daemon) and validates end-to-end browser bridge health. - const connectivity = await checkConnectivity(); + // Single status read *after* connectivity side-effects settle. Use the same + // default/env profile selection as BrowserBridge.connect so multi-profile + // setups do not report profile-required after the live check succeeds. + const contextId = resolveProfileContextId(); + const healthOpts = contextId ? { contextId } : undefined; + const statusStart = Date.now(); + let health = await getDaemonHealth(healthOpts); - // Single status read *after* connectivity side-effects settle. - const health = await getDaemonHealth(); + // If the selected profile is already ready, do not open a temporary page just + // to prove connectivity. This keeps doctor read-only for healthy profiles and + // avoids creating visible Chrome windows, groups, or blank tabs. + const connectivity = health.state === 'ready' + ? { ok: true, durationMs: Date.now() - statusStart } + : await checkConnectivity(); + if (health.state !== 'ready') { + health = await getDaemonHealth(healthOpts); + } const daemonRunning = health.state !== 'stopped'; const extensionConnected = health.state === 'ready'; const daemonFlaky = connectivity.ok && !daemonRunning; diff --git a/src/engine.test.ts b/src/engine.test.ts index 6cd7ccb8d..34f2a57e6 100644 --- a/src/engine.test.ts +++ b/src/engine.test.ts @@ -179,6 +179,27 @@ browser: false await expect(discoverPlugins()).resolves.not.toThrow(); }); + it('supports legacy plugin cmd.js files that use global cli()', async () => { + await fs.promises.mkdir(testPluginDir, { recursive: true }); + await fs.promises.writeFile(path.join(testPluginDir, 'cmd.js'), ` +cli({ + site: '__test-plugin__', + name: 'legacy', + access: 'read', + description: 'Legacy plugin command', + strategy: Strategy.PUBLIC, + browser: false, + func: async () => [{ ok: true }], +}); +`); + + await discoverPlugins(); + + const cmd = getRegistry().get('__test-plugin__/legacy'); + expect(cmd).toBeDefined(); + expect(cmd?.browser).toBe(false); + }); + it('ignores YAML files in symlinked plugin directories (YAML format removed)', async () => { await fs.promises.mkdir(PLUGINS_DIR, { recursive: true }); await fs.promises.mkdir(symlinkTargetDir, { recursive: true }); diff --git a/src/execution.test.ts b/src/execution.test.ts index 287b12e5f..5098dd75a 100644 --- a/src/execution.test.ts +++ b/src/execution.test.ts @@ -545,6 +545,37 @@ describe('executeCommand — non-browser timeout', () => { vi.restoreAllMocks(); }); + it('passes OPENCLI_TAB_PLACEMENT through browser-backed adapter sessions', async () => { + vi.stubEnv('OPENCLI_TAB_PLACEMENT', 'existing-window'); + const closeWindow = vi.fn().mockResolvedValue(undefined); + const mockPage = { closeWindow } as any; + const sessionOpts: Array> = []; + + vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true); + vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn, opts) => { + sessionOpts.push(opts ?? {}); + return fn(mockPage); + }); + + try { + const cmd = cli({ + site: 'test-execution', + name: 'browser-tab-placement-env', access: 'read', + description: 'test browser tab placement env', + browser: true, + strategy: Strategy.PUBLIC, + func: async () => [{ ok: true }], + }); + + await executeCommand(cmd, {}); + + expect(sessionOpts[0]).toMatchObject({ tabPlacement: 'existing-window' }); + } finally { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + } + }); + it('does not re-run custom validation when args are already prepared', async () => { const validateArgs = vi.fn(); const cmd: CliCommand = { diff --git a/src/execution.ts b/src/execution.ts index e8d1a5c0d..4fd28efd3 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -30,6 +30,7 @@ import { adapterLoadError, ArgumentError, CommandExecutionError, attachTraceRece import { shouldUseBrowserSession } from './capabilityRouting.js'; import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT, type BrowserWindowMode } from './runtime.js'; import { resolveProfileContextId } from './browser/profile.js'; +import { resolveBrowserTabPlacementFromEnv } from './browser/tab-placement.js'; import { emitHook, type HookContext } from './hooks.js'; import { log } from './logger.js'; import { isElectronApp } from './electron-apps.js'; @@ -255,6 +256,7 @@ export async function executeCommand( const session = resolveAdapterBrowserSession(cmd, siteSession); const keepTab = resolveKeepTab(siteSession, opts.keepTab); const windowMode = resolveBrowserWindowMode(cmd.defaultWindowMode ?? 'background', opts.windowMode); + const tabPlacement = resolveBrowserTabPlacementFromEnv(); result = await browserSession(BrowserFactory, async (page) => { const observation = traceMode === 'off' ? null @@ -369,7 +371,7 @@ export async function executeCommand( if (!keepTab) await page.closeWindow?.().catch(() => {}); throw err; } - }, { session, cdpEndpoint, contextId, windowMode, surface: 'adapter', siteSession }); + }, { session, cdpEndpoint, contextId, windowMode, tabPlacement, surface: 'adapter', siteSession }); } else { // Non-browser commands: enforce a timeout only when the command exposes // a `--timeout` arg (and the resolved value is positive). Without that diff --git a/src/runtime.ts b/src/runtime.ts index bed5c7eff..fbe65727b 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -3,6 +3,7 @@ import type { IPage } from './types.js'; import { TimeoutError } from './errors.js'; import { isElectronApp } from './electron-apps.js'; import { log } from './logger.js'; +import type { BrowserTabPlacement } from './browser/tab-placement.js'; /** * Returns the appropriate browser factory based on site type. @@ -29,6 +30,7 @@ export const DEFAULT_BROWSER_COMMAND_TIMEOUT = parseEnvTimeout('OPENCLI_BROWSER_ export type BrowserWindowMode = 'foreground' | 'background'; export type BrowserSurface = 'browser' | 'adapter'; +export type { BrowserTabPlacement }; /** * Timeout with seconds unit. Used for high-level command timeouts. @@ -66,14 +68,14 @@ export function withTimeoutMs( /** Interface for browser factory (BrowserBridge or test mocks) */ export interface IBrowserFactory { - connect(opts?: { timeout?: number; session?: string; cdpEndpoint?: string; contextId?: string; idleTimeout?: number; windowMode?: BrowserWindowMode; surface?: BrowserSurface; siteSession?: 'ephemeral' | 'persistent' }): Promise; + connect(opts?: { timeout?: number; session?: string; cdpEndpoint?: string; contextId?: string; idleTimeout?: number; windowMode?: BrowserWindowMode; tabPlacement?: BrowserTabPlacement; surface?: BrowserSurface; siteSession?: 'ephemeral' | 'persistent' }): Promise; close(): Promise; } export async function browserSession( BrowserFactory: new () => IBrowserFactory, fn: (page: IPage) => Promise, - opts: { session?: string; cdpEndpoint?: string; contextId?: string; idleTimeout?: number; windowMode?: BrowserWindowMode; surface?: BrowserSurface; siteSession?: 'ephemeral' | 'persistent' } = {}, + opts: { session?: string; cdpEndpoint?: string; contextId?: string; idleTimeout?: number; windowMode?: BrowserWindowMode; tabPlacement?: BrowserTabPlacement; surface?: BrowserSurface; siteSession?: 'ephemeral' | 'persistent' } = {}, ): Promise { const browser = new BrowserFactory(); try { @@ -84,6 +86,7 @@ export async function browserSession( contextId: opts.contextId, idleTimeout: opts.idleTimeout, windowMode: opts.windowMode, + tabPlacement: opts.tabPlacement, surface: opts.surface, siteSession: opts.siteSession, }); diff --git a/src/types.ts b/src/types.ts index 9ed140552..b456a11fe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -67,6 +67,27 @@ export interface FetchJsonOptions { timeoutMs?: number; } +export interface BrowserCredentialFillOptions { + username: string; + password: string; + allowedHosts: string[]; + usernameSelectors?: string[]; + passwordSelectors?: string[]; + activateTextPatterns?: string[]; + submitSelectors?: string[]; + submit?: boolean; +} + +export interface BrowserCredentialFillResult { + ok: boolean; + host: string; + frameId?: number; + username_filled: boolean; + password_filled: boolean; + submitted: boolean; + reason?: string; +} + export type BrowserEvaluateFunction = (...args: Args) => Result | Promise; export interface IPage { @@ -131,6 +152,11 @@ export interface IPage { * Useful for rich editors that ignore synthetic DOM value/text mutations. */ insertText?(text: string): Promise; + /** + * Fill login credentials from the extension content-script layer. This is + * intentionally narrow and should not expose the password in its result. + */ + fillCredentials?(options: BrowserCredentialFillOptions): Promise; closeWindow?(): Promise; /** Returns the current page URL, or null if unavailable. */ getCurrentUrl?(): Promise; @@ -148,6 +174,8 @@ export interface IPage { evaluateInFrame?(js: string, frameIndex: number): Promise; /** Click at native coordinates via CDP Input.dispatchMouseEvent. */ nativeClick?(x: number, y: number): Promise; + /** Click at page viewport coordinates via the operating-system mouse. */ + systemClick?(x: number, y: number): Promise; /** Type text via CDP Input.insertText. */ nativeType?(text: string): Promise; /** Press a key via CDP Input.dispatchKeyEvent. */