/ extension / src / background.ts
background.ts
   1  /**
   2   * OpenCLI — Service Worker (background script).
   3   *
   4   * Connects to the opencli daemon via WebSocket, receives commands,
   5   * dispatches them to Chrome APIs (debugger/tabs/cookies), returns results.
   6   */
   7  
   8  declare const __OPENCLI_COMPAT_RANGE__: string;
   9  
  10  import type { Command, Result } from './protocol';
  11  import { DAEMON_WS_URL, DAEMON_PING_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol';
  12  import * as executor from './cdp';
  13  import * as identity from './identity';
  14  
  15  let ws: WebSocket | null = null;
  16  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
  17  let reconnectAttempts = 0;
  18  
  19  // ─── Console log forwarding ──────────────────────────────────────────
  20  // Hook console.log/warn/error to forward logs to daemon via WebSocket.
  21  
  22  const _origLog = console.log.bind(console);
  23  const _origWarn = console.warn.bind(console);
  24  const _origError = console.error.bind(console);
  25  
  26  function forwardLog(level: 'info' | 'warn' | 'error', args: unknown[]): void {
  27    if (!ws || ws.readyState !== WebSocket.OPEN) return;
  28    try {
  29      const msg = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
  30      ws.send(JSON.stringify({ type: 'log', level, msg, ts: Date.now() }));
  31    } catch { /* don't recurse */ }
  32  }
  33  
  34  console.log = (...args: unknown[]) => { _origLog(...args); forwardLog('info', args); };
  35  console.warn = (...args: unknown[]) => { _origWarn(...args); forwardLog('warn', args); };
  36  console.error = (...args: unknown[]) => { _origError(...args); forwardLog('error', args); };
  37  
  38  // ─── WebSocket connection ────────────────────────────────────────────
  39  
  40  /**
  41   * Probe the daemon via its /ping HTTP endpoint before attempting a WebSocket
  42   * connection.  fetch() failures are silently catchable; new WebSocket() is not
  43   * — Chrome logs ERR_CONNECTION_REFUSED to the extension error page before any
  44   * JS handler can intercept it.  By keeping the probe inside connect() every
  45   * call site remains unchanged and the guard can never be accidentally skipped.
  46   */
  47  async function connect(): Promise<void> {
  48    if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
  49  
  50    try {
  51      const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1000) });
  52      if (!res.ok) return; // unexpected response — not our daemon
  53    } catch {
  54      return; // daemon not running — skip WebSocket to avoid console noise
  55    }
  56  
  57    try {
  58      ws = new WebSocket(DAEMON_WS_URL);
  59    } catch {
  60      scheduleReconnect();
  61      return;
  62    }
  63  
  64    ws.onopen = () => {
  65      console.log('[opencli] Connected to daemon');
  66      reconnectAttempts = 0; // Reset on successful connection
  67      if (reconnectTimer) {
  68        clearTimeout(reconnectTimer);
  69        reconnectTimer = null;
  70      }
  71      // Send version + compatibility range so the daemon can report mismatches to the CLI
  72      ws?.send(JSON.stringify({
  73        type: 'hello',
  74        version: chrome.runtime.getManifest().version,
  75        compatRange: __OPENCLI_COMPAT_RANGE__,
  76      }));
  77    };
  78  
  79    ws.onmessage = async (event) => {
  80      try {
  81        const command = JSON.parse(event.data as string) as Command;
  82        const result = await handleCommand(command);
  83        ws?.send(JSON.stringify(result));
  84      } catch (err) {
  85        console.error('[opencli] Message handling error:', err);
  86      }
  87    };
  88  
  89    ws.onclose = () => {
  90      console.log('[opencli] Disconnected from daemon');
  91      ws = null;
  92      scheduleReconnect();
  93    };
  94  
  95    ws.onerror = () => {
  96      ws?.close();
  97    };
  98  }
  99  
 100  /**
 101   * After MAX_EAGER_ATTEMPTS (reaching 60s backoff), stop scheduling reconnects.
 102   * The keepalive alarm (~24s) will still call connect() periodically, but at a
 103   * much lower frequency — reducing console noise when the daemon is not running.
 104   */
 105  const MAX_EAGER_ATTEMPTS = 6; // 2s, 4s, 8s, 16s, 32s, 60s — then stop
 106  
 107  function scheduleReconnect(): void {
 108    if (reconnectTimer) return;
 109    reconnectAttempts++;
 110    if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return; // let keepalive alarm handle it
 111    const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
 112    reconnectTimer = setTimeout(() => {
 113      reconnectTimer = null;
 114      void connect();
 115    }, delay);
 116  }
 117  
 118  // ─── Automation window isolation ─────────────────────────────────────
 119  // All opencli operations happen in a dedicated Chrome window so the
 120  // user's active browsing session is never touched.
 121  // The window auto-closes after a period of idle (no commands).
 122  // Interactive workspaces (browser:*, operate:*) get a longer timeout (10 min)
 123  // since users type commands manually; adapter workspaces keep a short 30s timeout.
 124  
 125  type AutomationSession = {
 126    windowId: number;
 127    idleTimer: ReturnType<typeof setTimeout> | null;
 128    idleDeadlineAt: number;
 129    owned: boolean;
 130    preferredTabId: number | null;
 131  };
 132  
 133  const automationSessions = new Map<string, AutomationSession>();
 134  const IDLE_TIMEOUT_DEFAULT = 30_000;      // 30s — adapter-driven automation
 135  const IDLE_TIMEOUT_INTERACTIVE = 600_000; // 10min — human-paced browser:* / operate:*
 136  
 137  /** Per-workspace custom timeout overrides set via command.idleTimeout */
 138  const workspaceTimeoutOverrides = new Map<string, number>();
 139  
 140  function getIdleTimeout(workspace: string): number {
 141    const override = workspaceTimeoutOverrides.get(workspace);
 142    if (override !== undefined) return override;
 143    if (workspace.startsWith('browser:') || workspace.startsWith('operate:')) {
 144      return IDLE_TIMEOUT_INTERACTIVE;
 145    }
 146    return IDLE_TIMEOUT_DEFAULT;
 147  }
 148  
 149  let windowFocused = false; // set per-command from daemon's OPENCLI_WINDOW_FOCUSED
 150  
 151  function getWorkspaceKey(workspace?: string): string {
 152    return workspace?.trim() || 'default';
 153  }
 154  
 155  function resetWindowIdleTimer(workspace: string): void {
 156    const session = automationSessions.get(workspace);
 157    if (!session) return;
 158    if (session.idleTimer) clearTimeout(session.idleTimer);
 159    const timeout = getIdleTimeout(workspace);
 160    session.idleDeadlineAt = Date.now() + timeout;
 161    session.idleTimer = setTimeout(async () => {
 162      const current = automationSessions.get(workspace);
 163      if (!current) return;
 164      if (!current.owned) {
 165        console.log(`[opencli] Borrowed workspace ${workspace} detached from window ${current.windowId} (idle timeout)`);
 166        workspaceTimeoutOverrides.delete(workspace);
 167        automationSessions.delete(workspace);
 168        return;
 169      }
 170      try {
 171        await chrome.windows.remove(current.windowId);
 172        console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout, ${timeout / 1000}s)`);
 173      } catch {
 174        // Already gone
 175      }
 176      workspaceTimeoutOverrides.delete(workspace);
 177      automationSessions.delete(workspace);
 178    }, timeout);
 179  }
 180  
 181  /** Get or create the dedicated automation window.
 182   *  @param initialUrl — if provided (http/https), used as the initial page instead of about:blank.
 183   *    This avoids an extra blank-page→target-domain navigation on first command.
 184   */
 185  async function getAutomationWindow(workspace: string, initialUrl?: string): Promise<number> {
 186    // Check if our window is still alive
 187    const existing = automationSessions.get(workspace);
 188    if (existing) {
 189      try {
 190        await chrome.windows.get(existing.windowId);
 191        return existing.windowId;
 192      } catch {
 193        // Window was closed by user
 194        automationSessions.delete(workspace);
 195      }
 196    }
 197  
 198    // Use the target URL directly if it's a safe navigation URL, otherwise fall back to about:blank.
 199    const startUrl = (initialUrl && isSafeNavigationUrl(initialUrl)) ? initialUrl : BLANK_PAGE;
 200  
 201    // Note: Do NOT set `state` parameter here. Chrome 146+ rejects 'normal' as an invalid
 202    // state value for windows.create(). The window defaults to 'normal' state anyway.
 203    const win = await chrome.windows.create({
 204      url: startUrl,
 205      focused: windowFocused,
 206      width: 1280,
 207      height: 900,
 208      type: 'normal',
 209    });
 210    const session: AutomationSession = {
 211      windowId: win.id!,
 212      idleTimer: null,
 213      idleDeadlineAt: Date.now() + getIdleTimeout(workspace),
 214      owned: true,
 215      preferredTabId: null,
 216    };
 217    automationSessions.set(workspace, session);
 218    console.log(`[opencli] Created automation window ${session.windowId} (${workspace}, start=${startUrl})`);
 219    resetWindowIdleTimer(workspace);
 220    // Wait for the initial tab to finish loading instead of a fixed 200ms sleep.
 221    const tabs = await chrome.tabs.query({ windowId: win.id! });
 222    if (tabs[0]?.id) {
 223      await new Promise<void>((resolve) => {
 224        const timeout = setTimeout(resolve, 500); // fallback cap
 225        const listener = (tabId: number, info: chrome.tabs.TabChangeInfo) => {
 226          if (tabId === tabs[0].id && info.status === 'complete') {
 227            chrome.tabs.onUpdated.removeListener(listener);
 228            clearTimeout(timeout);
 229            resolve();
 230          }
 231        };
 232        // Check if already complete before listening
 233        if (tabs[0].status === 'complete') {
 234          clearTimeout(timeout);
 235          resolve();
 236        } else {
 237          chrome.tabs.onUpdated.addListener(listener);
 238        }
 239      });
 240    }
 241    return session.windowId;
 242  }
 243  
 244  // Clean up when the automation window is closed
 245  chrome.windows.onRemoved.addListener(async (windowId) => {
 246    for (const [workspace, session] of automationSessions.entries()) {
 247      if (session.windowId === windowId) {
 248        console.log(`[opencli] Automation window closed (${workspace})`);
 249        if (session.idleTimer) clearTimeout(session.idleTimer);
 250        automationSessions.delete(workspace);
 251        workspaceTimeoutOverrides.delete(workspace);
 252      }
 253    }
 254  });
 255  
 256  // Evict identity mappings when tabs are closed
 257  chrome.tabs.onRemoved.addListener((tabId) => {
 258    identity.evictTab(tabId);
 259  });
 260  
 261  // ─── Lifecycle events ────────────────────────────────────────────────
 262  
 263  let initialized = false;
 264  
 265  function initialize(): void {
 266    if (initialized) return;
 267    initialized = true;
 268    chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds
 269    executor.registerListeners();
 270    executor.registerFrameTracking();
 271    void connect();
 272    console.log('[opencli] OpenCLI extension initialized');
 273  }
 274  
 275  chrome.runtime.onInstalled.addListener(() => {
 276    initialize();
 277  });
 278  
 279  chrome.runtime.onStartup.addListener(() => {
 280    initialize();
 281  });
 282  
 283  chrome.alarms.onAlarm.addListener((alarm) => {
 284    if (alarm.name === 'keepalive') void connect();
 285  });
 286  
 287  // ─── Popup status API ───────────────────────────────────────────────
 288  
 289  chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
 290    if (msg?.type === 'getStatus') {
 291      sendResponse({
 292        connected: ws?.readyState === WebSocket.OPEN,
 293        reconnecting: reconnectTimer !== null,
 294      });
 295    }
 296    return false;
 297  });
 298  
 299  // ─── Command dispatcher ─────────────────────────────────────────────
 300  
 301  async function handleCommand(cmd: Command): Promise<Result> {
 302    const workspace = getWorkspaceKey(cmd.workspace);
 303    windowFocused = cmd.windowFocused === true;
 304    // Apply custom idle timeout if specified in the command
 305    if (cmd.idleTimeout != null && cmd.idleTimeout > 0) {
 306      workspaceTimeoutOverrides.set(workspace, cmd.idleTimeout * 1000);
 307    }
 308    // Reset idle timer on every command (window stays alive while active)
 309    resetWindowIdleTimer(workspace);
 310    try {
 311      switch (cmd.action) {
 312        case 'exec':
 313          return await handleExec(cmd, workspace);
 314        case 'navigate':
 315          return await handleNavigate(cmd, workspace);
 316        case 'tabs':
 317          return await handleTabs(cmd, workspace);
 318        case 'cookies':
 319          return await handleCookies(cmd);
 320        case 'screenshot':
 321          return await handleScreenshot(cmd, workspace);
 322        case 'close-window':
 323          return await handleCloseWindow(cmd, workspace);
 324        case 'cdp':
 325          return await handleCdp(cmd, workspace);
 326        case 'sessions':
 327          return await handleSessions(cmd);
 328        case 'set-file-input':
 329          return await handleSetFileInput(cmd, workspace);
 330        case 'insert-text':
 331          return await handleInsertText(cmd, workspace);
 332        case 'bind-current':
 333          return await handleBindCurrent(cmd, workspace);
 334        case 'network-capture-start':
 335          return await handleNetworkCaptureStart(cmd, workspace);
 336        case 'network-capture-read':
 337          return await handleNetworkCaptureRead(cmd, workspace);
 338        case 'frames':
 339          return await handleFrames(cmd, workspace);
 340        default:
 341          return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
 342      }
 343    } catch (err) {
 344      return {
 345        id: cmd.id,
 346        ok: false,
 347        error: err instanceof Error ? err.message : String(err),
 348      };
 349    }
 350  }
 351  
 352  // ─── Action handlers ─────────────────────────────────────────────────
 353  
 354  /** Internal blank page used when no user URL is provided. */
 355  const BLANK_PAGE = 'about:blank';
 356  
 357  /** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */
 358  function isDebuggableUrl(url?: string): boolean {
 359    if (!url) return true;  // empty/undefined = tab still loading, allow it
 360    return url.startsWith('http://') || url.startsWith('https://') || url === 'about:blank' || url.startsWith('data:');
 361  }
 362  
 363  /** Check if a URL is safe for user-facing navigation (http/https only). */
 364  function isSafeNavigationUrl(url: string): boolean {
 365    return url.startsWith('http://') || url.startsWith('https://');
 366  }
 367  
 368  /** Minimal URL normalization for same-page comparison: root slash + default port only. */
 369  function normalizeUrlForComparison(url?: string): string {
 370    if (!url) return '';
 371    try {
 372      const parsed = new URL(url);
 373      if ((parsed.protocol === 'https:' && parsed.port === '443') || (parsed.protocol === 'http:' && parsed.port === '80')) {
 374        parsed.port = '';
 375      }
 376      const pathname = parsed.pathname === '/' ? '' : parsed.pathname;
 377      return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`;
 378    } catch {
 379      return url;
 380    }
 381  }
 382  
 383  function isTargetUrl(currentUrl: string | undefined, targetUrl: string): boolean {
 384    return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl);
 385  }
 386  
 387  function matchesDomain(url: string | undefined, domain: string): boolean {
 388    if (!url) return false;
 389    try {
 390      const parsed = new URL(url);
 391      return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`);
 392    } catch {
 393      return false;
 394    }
 395  }
 396  
 397  function matchesBindCriteria(tab: chrome.tabs.Tab, cmd: Command): boolean {
 398    if (!tab.id || !isDebuggableUrl(tab.url)) return false;
 399    if (cmd.matchDomain && !matchesDomain(tab.url, cmd.matchDomain)) return false;
 400    if (cmd.matchPathPrefix) {
 401      try {
 402        const parsed = new URL(tab.url!);
 403        if (!parsed.pathname.startsWith(cmd.matchPathPrefix)) return false;
 404      } catch {
 405        return false;
 406      }
 407    }
 408    return true;
 409  }
 410  
 411  function getUrlOrigin(url: string | undefined): string | null {
 412    if (!url) return null;
 413    try {
 414      return new URL(url).origin;
 415    } catch {
 416      return null;
 417    }
 418  }
 419  
 420  function enumerateCrossOriginFrames(tree: any): Array<{ index: number; frameId: string; url: string; name: string }> {
 421    const frames: Array<{ index: number; frameId: string; url: string; name: string }> = [];
 422  
 423    function collect(node: any, accessibleOrigin: string | null) {
 424      for (const child of (node.childFrames || [])) {
 425        const frame = child.frame;
 426        const frameUrl = frame.url || frame.unreachableUrl || '';
 427        const frameOrigin = getUrlOrigin(frameUrl);
 428  
 429        // Mirror dom-snapshot's [F#] rules:
 430        // - same-origin frames expand inline and do not get an [F#] slot
 431        // - cross-origin / blocked frames get one slot and stop recursion there
 432        if (accessibleOrigin && frameOrigin && frameOrigin === accessibleOrigin) {
 433          collect(child, frameOrigin);
 434          continue;
 435        }
 436  
 437        frames.push({
 438          index: frames.length,
 439          frameId: frame.id,
 440          url: frameUrl,
 441          name: frame.name || '',
 442        });
 443      }
 444    }
 445  
 446    const rootFrame = tree?.frameTree?.frame;
 447    const rootUrl = rootFrame?.url || rootFrame?.unreachableUrl || '';
 448    collect(tree.frameTree, getUrlOrigin(rootUrl));
 449    return frames;
 450  }
 451  
 452  function setWorkspaceSession(workspace: string, session: Omit<AutomationSession, 'idleTimer' | 'idleDeadlineAt'>): void {
 453    const existing = automationSessions.get(workspace);
 454    if (existing?.idleTimer) clearTimeout(existing.idleTimer);
 455    automationSessions.set(workspace, {
 456      ...session,
 457      idleTimer: null,
 458      idleDeadlineAt: Date.now() + getIdleTimeout(workspace),
 459    });
 460  }
 461  
 462  /**
 463   * Resolve tabId from command's page (targetId).
 464   * Returns undefined if no page identity is provided.
 465   */
 466  async function resolveCommandTabId(cmd: Command): Promise<number | undefined> {
 467    if (cmd.page) return identity.resolveTabId(cmd.page);
 468    return undefined;
 469  }
 470  
 471  type ResolvedTab = { tabId: number; tab: chrome.tabs.Tab | null };
 472  
 473  /**
 474   * Resolve target tab in the automation window, returning both the tabId and
 475   * the Tab object (when available) so callers can skip a redundant chrome.tabs.get().
 476   */
 477  async function resolveTab(tabId: number | undefined, workspace: string, initialUrl?: string): Promise<ResolvedTab> {
 478    // Even when an explicit tabId is provided, validate it is still debuggable.
 479    if (tabId !== undefined) {
 480      try {
 481        const tab = await chrome.tabs.get(tabId);
 482        const session = automationSessions.get(workspace);
 483        const matchesSession = session
 484          ? (session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId)
 485          : false;
 486        if (isDebuggableUrl(tab.url) && matchesSession) return { tabId, tab };
 487        if (session && !matchesSession && session.preferredTabId === null && isDebuggableUrl(tab.url)) {
 488          // Tab drifted to another window but content is still valid.
 489          // Try to move it back instead of abandoning it.
 490          console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId}, moving back to ${session.windowId}`);
 491          try {
 492            await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 });
 493            const moved = await chrome.tabs.get(tabId);
 494            if (moved.windowId === session.windowId && isDebuggableUrl(moved.url)) {
 495              return { tabId, tab: moved };
 496            }
 497          } catch (moveErr) {
 498            console.warn(`[opencli] Failed to move tab back: ${moveErr}`);
 499          }
 500        } else if (!isDebuggableUrl(tab.url)) {
 501          console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
 502        }
 503      } catch {
 504        console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`);
 505      }
 506    }
 507  
 508    const existingSession = automationSessions.get(workspace);
 509    if (existingSession?.preferredTabId !== null) {
 510      try {
 511        const preferredTab = await chrome.tabs.get(existingSession.preferredTabId);
 512        if (isDebuggableUrl(preferredTab.url)) return { tabId: preferredTab.id!, tab: preferredTab };
 513      } catch {
 514        automationSessions.delete(workspace);
 515      }
 516    }
 517  
 518    // Get (or create) the automation window
 519    const windowId = await getAutomationWindow(workspace, initialUrl);
 520  
 521    // Prefer an existing debuggable tab
 522    const tabs = await chrome.tabs.query({ windowId });
 523    const debuggableTab = tabs.find(t => t.id && isDebuggableUrl(t.url));
 524    if (debuggableTab?.id) return { tabId: debuggableTab.id, tab: debuggableTab };
 525  
 526    // No debuggable tab — another extension may have hijacked the tab URL.
 527    const reuseTab = tabs.find(t => t.id);
 528    if (reuseTab?.id) {
 529      await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE });
 530      await new Promise(resolve => setTimeout(resolve, 300));
 531      try {
 532        const updated = await chrome.tabs.get(reuseTab.id);
 533        if (isDebuggableUrl(updated.url)) return { tabId: reuseTab.id, tab: updated };
 534        console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`);
 535      } catch {
 536        // Tab was closed during navigation
 537      }
 538    }
 539  
 540    // Fallback: create a new tab
 541    const newTab = await chrome.tabs.create({ windowId, url: BLANK_PAGE, active: true });
 542    if (!newTab.id) throw new Error('Failed to create tab in automation window');
 543    return { tabId: newTab.id, tab: newTab };
 544  }
 545  
 546  /** Build a page-scoped success result with targetId resolved from tabId */
 547  async function pageScopedResult(id: string, tabId: number, data?: unknown): Promise<Result> {
 548    const page = await identity.resolveTargetId(tabId);
 549    return { id, ok: true, data, page };
 550  }
 551  
 552  /** Convenience wrapper returning just the tabId (used by most handlers) */
 553  async function resolveTabId(tabId: number | undefined, workspace: string, initialUrl?: string): Promise<number> {
 554    const resolved = await resolveTab(tabId, workspace, initialUrl);
 555    return resolved.tabId;
 556  }
 557  
 558  async function listAutomationTabs(workspace: string): Promise<chrome.tabs.Tab[]> {
 559    const session = automationSessions.get(workspace);
 560    if (!session) return [];
 561    if (session.preferredTabId !== null) {
 562      try {
 563        return [await chrome.tabs.get(session.preferredTabId)];
 564      } catch {
 565        automationSessions.delete(workspace);
 566        return [];
 567      }
 568    }
 569    try {
 570      return await chrome.tabs.query({ windowId: session.windowId });
 571    } catch {
 572      automationSessions.delete(workspace);
 573      return [];
 574    }
 575  }
 576  
 577  async function listAutomationWebTabs(workspace: string): Promise<chrome.tabs.Tab[]> {
 578    const tabs = await listAutomationTabs(workspace);
 579    return tabs.filter((tab) => isDebuggableUrl(tab.url));
 580  }
 581  
 582  async function handleExec(cmd: Command, workspace: string): Promise<Result> {
 583    if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' };
 584    const cmdTabId = await resolveCommandTabId(cmd);
 585    const tabId = await resolveTabId(cmdTabId, workspace);
 586    try {
 587      const aggressive = workspace.startsWith('browser:') || workspace.startsWith('operate:');
 588      if (cmd.frameIndex != null) {
 589        const tree = await executor.getFrameTree(tabId);
 590        const frames = enumerateCrossOriginFrames(tree);
 591        if (cmd.frameIndex < 0 || cmd.frameIndex >= frames.length) {
 592          return { id: cmd.id, ok: false, error: `Frame index ${cmd.frameIndex} out of range (${frames.length} cross-origin frames available)` };
 593        }
 594        const data = await executor.evaluateInFrame(tabId, cmd.code, frames[cmd.frameIndex].frameId, aggressive);
 595        return pageScopedResult(cmd.id, tabId, data);
 596      }
 597      const data = await executor.evaluateAsync(tabId, cmd.code, aggressive);
 598      return pageScopedResult(cmd.id, tabId, data);
 599    } catch (err) {
 600      return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
 601    }
 602  }
 603  
 604  async function handleFrames(cmd: Command, workspace: string): Promise<Result> {
 605    const cmdTabId = await resolveCommandTabId(cmd);
 606    const tabId = await resolveTabId(cmdTabId, workspace);
 607    try {
 608      const tree = await executor.getFrameTree(tabId);
 609      return { id: cmd.id, ok: true, data: enumerateCrossOriginFrames(tree) };
 610    } catch (err) {
 611      return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
 612    }
 613  }
 614  
 615  async function handleNavigate(cmd: Command, workspace: string): Promise<Result> {
 616    if (!cmd.url) return { id: cmd.id, ok: false, error: 'Missing url' };
 617    if (!isSafeNavigationUrl(cmd.url)) {
 618      return { id: cmd.id, ok: false, error: 'Blocked URL scheme -- only http:// and https:// are allowed' };
 619    }
 620    // Pass target URL so that first-time window creation can start on the right domain
 621    const cmdTabId = await resolveCommandTabId(cmd);
 622    const resolved = await resolveTab(cmdTabId, workspace, cmd.url);
 623    const tabId = resolved.tabId;
 624  
 625    const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId);
 626    const beforeNormalized = normalizeUrlForComparison(beforeTab.url);
 627    const targetUrl = cmd.url;
 628  
 629    // Fast-path: tab is already at the target URL and fully loaded.
 630    if (beforeTab.status === 'complete' && isTargetUrl(beforeTab.url, targetUrl)) {
 631      return pageScopedResult(cmd.id, tabId, { title: beforeTab.title, url: beforeTab.url, timedOut: false });
 632    }
 633  
 634    // Detach any existing debugger before top-level navigation unless network
 635    // capture is already armed on this tab. Otherwise we would clear the capture
 636    // state right before the page load we are trying to observe.
 637    // Some sites (observed on creator.xiaohongshu.com flows) can invalidate the
 638    // current inspected target during navigation, which leaves a stale CDP attach
 639    // state and causes the next Runtime.evaluate to fail with
 640    // "Inspected target navigated or closed". Resetting here forces a clean
 641    // re-attach after navigation when capture is not active.
 642    if (!executor.hasActiveNetworkCapture(tabId)) {
 643      await executor.detach(tabId);
 644    }
 645  
 646    await chrome.tabs.update(tabId, { url: targetUrl });
 647  
 648    // Wait until navigation completes. Resolve when status is 'complete' AND either:
 649    // - the URL matches the target (handles same-URL / canonicalized navigations), OR
 650    // - the URL differs from the pre-navigation URL (handles redirects).
 651    let timedOut = false;
 652    await new Promise<void>((resolve) => {
 653      let settled = false;
 654      let checkTimer: ReturnType<typeof setTimeout> | null = null;
 655      let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
 656  
 657      const finish = () => {
 658        if (settled) return;
 659        settled = true;
 660        chrome.tabs.onUpdated.removeListener(listener);
 661        if (checkTimer) clearTimeout(checkTimer);
 662        if (timeoutTimer) clearTimeout(timeoutTimer);
 663        resolve();
 664      };
 665  
 666      const isNavigationDone = (url: string | undefined): boolean => {
 667        return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized;
 668      };
 669  
 670      const listener = (id: number, info: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) => {
 671        if (id !== tabId) return;
 672        if (info.status === 'complete' && isNavigationDone(tab.url ?? info.url)) {
 673          finish();
 674        }
 675      };
 676      chrome.tabs.onUpdated.addListener(listener);
 677  
 678      // Also check if the tab already navigated (e.g. instant cache hit)
 679      checkTimer = setTimeout(async () => {
 680        try {
 681          const currentTab = await chrome.tabs.get(tabId);
 682          if (currentTab.status === 'complete' && isNavigationDone(currentTab.url)) {
 683            finish();
 684          }
 685        } catch { /* tab gone */ }
 686      }, 100);
 687  
 688      // Timeout fallback with warning
 689      timeoutTimer = setTimeout(() => {
 690        timedOut = true;
 691        console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`);
 692        finish();
 693      }, 15000);
 694    });
 695  
 696    let tab = await chrome.tabs.get(tabId);
 697  
 698    // Post-navigation drift detection: if the tab moved to another window
 699    // during navigation (e.g. a tab-management extension regrouped it),
 700    // try to move it back to maintain session isolation.
 701    const session = automationSessions.get(workspace);
 702    if (session && tab.windowId !== session.windowId) {
 703      console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${session.windowId}`);
 704      try {
 705        await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 });
 706        tab = await chrome.tabs.get(tabId);
 707      } catch (moveErr) {
 708        console.warn(`[opencli] Failed to recover drifted tab: ${moveErr}`);
 709      }
 710    }
 711  
 712    return pageScopedResult(cmd.id, tabId, { title: tab.title, url: tab.url, timedOut });
 713  }
 714  
 715  async function handleTabs(cmd: Command, workspace: string): Promise<Result> {
 716    switch (cmd.op) {
 717      case 'list': {
 718        const tabs = await listAutomationWebTabs(workspace);
 719        const data = await Promise.all(tabs.map(async (t, i) => {
 720          let page: string | undefined;
 721          try { page = t.id ? await identity.resolveTargetId(t.id) : undefined; } catch { /* skip */ }
 722          return { index: i, page, url: t.url, title: t.title, active: t.active };
 723        }));
 724        return { id: cmd.id, ok: true, data };
 725      }
 726      case 'new': {
 727        if (cmd.url && !isSafeNavigationUrl(cmd.url)) {
 728          return { id: cmd.id, ok: false, error: 'Blocked URL scheme -- only http:// and https:// are allowed' };
 729        }
 730        const windowId = await getAutomationWindow(workspace);
 731        const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true });
 732        if (!tab.id) return { id: cmd.id, ok: false, error: 'Failed to create tab' };
 733        return pageScopedResult(cmd.id, tab.id, { url: tab.url });
 734      }
 735      case 'close': {
 736        if (cmd.index !== undefined) {
 737          const tabs = await listAutomationWebTabs(workspace);
 738          const target = tabs[cmd.index];
 739          if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
 740          const closedPage = await identity.resolveTargetId(target.id).catch(() => undefined);
 741          await chrome.tabs.remove(target.id);
 742          await executor.detach(target.id);
 743          return { id: cmd.id, ok: true, data: { closed: closedPage } };
 744        }
 745        const cmdTabId = await resolveCommandTabId(cmd);
 746        const tabId = await resolveTabId(cmdTabId, workspace);
 747        const closedPage = await identity.resolveTargetId(tabId).catch(() => undefined);
 748        await chrome.tabs.remove(tabId);
 749        await executor.detach(tabId);
 750        return { id: cmd.id, ok: true, data: { closed: closedPage } };
 751      }
 752      case 'select': {
 753        if (cmd.index === undefined && cmd.page === undefined)
 754          return { id: cmd.id, ok: false, error: 'Missing index or page' };
 755        const cmdTabId = await resolveCommandTabId(cmd);
 756        if (cmdTabId !== undefined) {
 757          const session = automationSessions.get(workspace);
 758          let tab: chrome.tabs.Tab;
 759          try {
 760            tab = await chrome.tabs.get(cmdTabId);
 761          } catch {
 762            return { id: cmd.id, ok: false, error: `Page no longer exists` };
 763          }
 764          if (!session || tab.windowId !== session.windowId) {
 765            return { id: cmd.id, ok: false, error: `Page is not in the automation window` };
 766          }
 767          await chrome.tabs.update(cmdTabId, { active: true });
 768          return pageScopedResult(cmd.id, cmdTabId, { selected: true });
 769        }
 770        const tabs = await listAutomationWebTabs(workspace);
 771        const target = tabs[cmd.index!];
 772        if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
 773        await chrome.tabs.update(target.id, { active: true });
 774        return pageScopedResult(cmd.id, target.id, { selected: true });
 775      }
 776      default:
 777        return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` };
 778    }
 779  }
 780  
 781  async function handleCookies(cmd: Command): Promise<Result> {
 782    if (!cmd.domain && !cmd.url) {
 783      return { id: cmd.id, ok: false, error: 'Cookie scope required: provide domain or url to avoid dumping all cookies' };
 784    }
 785    const details: chrome.cookies.GetAllDetails = {};
 786    if (cmd.domain) details.domain = cmd.domain;
 787    if (cmd.url) details.url = cmd.url;
 788    const cookies = await chrome.cookies.getAll(details);
 789    const data = cookies.map((c) => ({
 790      name: c.name,
 791      value: c.value,
 792      domain: c.domain,
 793      path: c.path,
 794      secure: c.secure,
 795      httpOnly: c.httpOnly,
 796      expirationDate: c.expirationDate,
 797    }));
 798    return { id: cmd.id, ok: true, data };
 799  }
 800  
 801  async function handleScreenshot(cmd: Command, workspace: string): Promise<Result> {
 802    const cmdTabId = await resolveCommandTabId(cmd);
 803    const tabId = await resolveTabId(cmdTabId, workspace);
 804    try {
 805      const data = await executor.screenshot(tabId, {
 806        format: cmd.format,
 807        quality: cmd.quality,
 808        fullPage: cmd.fullPage,
 809      });
 810      return pageScopedResult(cmd.id, tabId, data);
 811    } catch (err) {
 812      return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
 813    }
 814  }
 815  
 816  /** CDP methods permitted via the 'cdp' passthrough action. */
 817  const CDP_ALLOWLIST = new Set([
 818    // Agent DOM context
 819    'Accessibility.getFullAXTree',
 820    'DOM.getDocument',
 821    'DOM.getBoxModel',
 822    'DOM.getContentQuads',
 823    'DOM.querySelectorAll',
 824    'DOM.scrollIntoViewIfNeeded',
 825    'DOMSnapshot.captureSnapshot',
 826    // Native input events
 827    'Input.dispatchMouseEvent',
 828    'Input.dispatchKeyEvent',
 829    'Input.insertText',
 830    // Page metrics & screenshots
 831    'Page.getLayoutMetrics',
 832    'Page.captureScreenshot',
 833    'Page.getFrameTree',
 834    // Runtime.enable needed for CDP attach setup (Runtime.evaluate goes through 'exec' action)
 835    'Runtime.enable',
 836    // Emulation (used by screenshot full-page)
 837    'Emulation.setDeviceMetricsOverride',
 838    'Emulation.clearDeviceMetricsOverride',
 839  ]);
 840  
 841  async function handleCdp(cmd: Command, workspace: string): Promise<Result> {
 842    if (!cmd.cdpMethod) return { id: cmd.id, ok: false, error: 'Missing cdpMethod' };
 843    if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) {
 844      return { id: cmd.id, ok: false, error: `CDP method not permitted: ${cmd.cdpMethod}` };
 845    }
 846    const cmdTabId = await resolveCommandTabId(cmd);
 847    const tabId = await resolveTabId(cmdTabId, workspace);
 848    try {
 849      const aggressive = workspace.startsWith('browser:') || workspace.startsWith('operate:');
 850      await executor.ensureAttached(tabId, aggressive);
 851      const data = await chrome.debugger.sendCommand(
 852        { tabId },
 853        cmd.cdpMethod,
 854        cmd.cdpParams ?? {},
 855      );
 856      return pageScopedResult(cmd.id, tabId, data);
 857    } catch (err) {
 858      return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
 859    }
 860  }
 861  
 862  async function handleCloseWindow(cmd: Command, workspace: string): Promise<Result> {
 863    const session = automationSessions.get(workspace);
 864    if (session) {
 865      if (session.owned) {
 866        try {
 867          await chrome.windows.remove(session.windowId);
 868        } catch {
 869          // Window may already be closed
 870        }
 871      }
 872      if (session.idleTimer) clearTimeout(session.idleTimer);
 873      workspaceTimeoutOverrides.delete(workspace);
 874      automationSessions.delete(workspace);
 875    }
 876    return { id: cmd.id, ok: true, data: { closed: true } };
 877  }
 878  
 879  async function handleSetFileInput(cmd: Command, workspace: string): Promise<Result> {
 880    if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) {
 881      return { id: cmd.id, ok: false, error: 'Missing or empty files array' };
 882    }
 883    const cmdTabId = await resolveCommandTabId(cmd);
 884    const tabId = await resolveTabId(cmdTabId, workspace);
 885    try {
 886      await executor.setFileInputFiles(tabId, cmd.files, cmd.selector);
 887      return pageScopedResult(cmd.id, tabId, { count: cmd.files.length });
 888    } catch (err) {
 889      return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
 890    }
 891  }
 892  
 893  async function handleInsertText(cmd: Command, workspace: string): Promise<Result> {
 894    if (typeof cmd.text !== 'string') {
 895      return { id: cmd.id, ok: false, error: 'Missing text payload' };
 896    }
 897    const cmdTabId = await resolveCommandTabId(cmd);
 898    const tabId = await resolveTabId(cmdTabId, workspace);
 899    try {
 900      await executor.insertText(tabId, cmd.text);
 901      return pageScopedResult(cmd.id, tabId, { inserted: true });
 902    } catch (err) {
 903      return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
 904    }
 905  }
 906  
 907  async function handleNetworkCaptureStart(cmd: Command, workspace: string): Promise<Result> {
 908    const cmdTabId = await resolveCommandTabId(cmd);
 909    const tabId = await resolveTabId(cmdTabId, workspace);
 910    try {
 911      await executor.startNetworkCapture(tabId, cmd.pattern);
 912      return pageScopedResult(cmd.id, tabId, { started: true });
 913    } catch (err) {
 914      return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
 915    }
 916  }
 917  
 918  async function handleNetworkCaptureRead(cmd: Command, workspace: string): Promise<Result> {
 919    const cmdTabId = await resolveCommandTabId(cmd);
 920    const tabId = await resolveTabId(cmdTabId, workspace);
 921    try {
 922      const data = await executor.readNetworkCapture(tabId);
 923      return pageScopedResult(cmd.id, tabId, data);
 924    } catch (err) {
 925      return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
 926    }
 927  }
 928  
 929  async function handleSessions(cmd: Command): Promise<Result> {
 930    const now = Date.now();
 931    const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({
 932      workspace,
 933      windowId: session.windowId,
 934      tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length,
 935      idleMsRemaining: Math.max(0, session.idleDeadlineAt - now),
 936    })));
 937    return { id: cmd.id, ok: true, data };
 938  }
 939  
 940  async function handleBindCurrent(cmd: Command, workspace: string): Promise<Result> {
 941    const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
 942    const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true });
 943    const allTabs = await chrome.tabs.query({});
 944    const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd))
 945      ?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd))
 946      ?? allTabs.find((tab) => matchesBindCriteria(tab, cmd));
 947    if (!boundTab?.id) {
 948      return {
 949        id: cmd.id,
 950        ok: false,
 951        error: cmd.matchDomain || cmd.matchPathPrefix
 952          ? `No visible tab matching ${cmd.matchDomain ?? 'domain'}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ''}`
 953          : 'No active debuggable tab found',
 954      };
 955    }
 956  
 957    setWorkspaceSession(workspace, {
 958      windowId: boundTab.windowId,
 959      owned: false,
 960      preferredTabId: boundTab.id,
 961    });
 962    resetWindowIdleTimer(workspace);
 963    console.log(`[opencli] Workspace ${workspace} explicitly bound to tab ${boundTab.id} (${boundTab.url})`);
 964    return pageScopedResult(cmd.id, boundTab.id, {
 965      url: boundTab.url,
 966      title: boundTab.title,
 967      workspace,
 968    });
 969  }
 970  
 971  export const __test__ = {
 972    handleExec,
 973    handleNavigate,
 974    isTargetUrl,
 975    handleTabs,
 976    handleSessions,
 977    handleBindCurrent,
 978    resolveTabId,
 979    resetWindowIdleTimer,
 980    handleCommand,
 981    getIdleTimeout,
 982    workspaceTimeoutOverrides,
 983    getSession: (workspace: string = 'default') => automationSessions.get(workspace) ?? null,
 984    getAutomationWindowId: (workspace: string = 'default') => automationSessions.get(workspace)?.windowId ?? null,
 985    setAutomationWindowId: (workspace: string, windowId: number | null) => {
 986      if (windowId === null) {
 987        const session = automationSessions.get(workspace);
 988        if (session?.idleTimer) clearTimeout(session.idleTimer);
 989        automationSessions.delete(workspace);
 990        return;
 991      }
 992      setWorkspaceSession(workspace, {
 993        windowId,
 994        owned: true,
 995        preferredTabId: null,
 996      });
 997    },
 998    setSession: (workspace: string, session: { windowId: number; owned: boolean; preferredTabId: number | null }) => {
 999      setWorkspaceSession(workspace, session);
1000    },
1001  };